///|
priv struct Relationship {
  id : String
  relationship_type : String
  target : String
  target_mode : String?
}

///|
priv struct Relationships {
  relationships : Array[Relationship]
}

///|
/// One input-linear index shared by package projection and strict annotation
/// validation. Values stay arrays so duplicate relationship IDs retain their
/// fail-closed meaning instead of being silently overwritten.
priv struct RelationshipIndex {
  by_id : StableStringMap[Array[Relationship]]
}

///|
fn RelationshipIndex::build(relationships : Relationships) -> RelationshipIndex {
  let by_id : StableStringMap[Array[Relationship]] = SortedMap([])
  for relationship in relationships.relationships {
    let matches = match by_id.get(relationship.id) {
      Some(existing) => existing
      None => {
        let fresh : Array[Relationship] = []
        by_id[relationship.id] = fresh
        fresh
      }
    }
    matches.push(relationship)
  }
  { by_id, }
}

///|
/// Relationship IDs are package-scoped identities. Missing and duplicate IDs
/// are both unresolved here: callers must never choose an attacker-controlled
/// first declaration from an ambiguous relationship set.
fn RelationshipIndex::find_unique_by_id(
  self : RelationshipIndex,
  id : String,
) -> Relationship? {
  match self.by_id.get(id) {
    Some([relationship]) => Some(relationship)
    _ => None
  }
}

///|
fn RelationshipIndex::find_unique_target_by_id(
  self : RelationshipIndex,
  id : String,
) -> String? {
  match self.find_unique_by_id(id) {
    Some(relationship) => Some(relationship.target)
    None => None
  }
}

///|
const TRANSITIONAL_OFFICE_DOCUMENT_RELATIONSHIP : String = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument"

///|
const STRICT_OFFICE_DOCUMENT_RELATIONSHIP : String = "http://purl.oclc.org/ooxml/officeDocument/relationships/officeDocument"

///|
const PACKAGE_RELATIONSHIPS_ROOT : String = "{http://schemas.openxmlformats.org/package/2006/relationships}Relationships"

///|
const PACKAGE_RELATIONSHIP_ELEMENT : String = "{http://schemas.openxmlformats.org/package/2006/relationships}Relationship"

///|
const TRANSITIONAL_COMMENTS_RELATIONSHIP : String = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments"

///|
const STRICT_COMMENTS_RELATIONSHIP : String = "http://purl.oclc.org/ooxml/officeDocument/relationships/comments"

///|
const COMMENTS_EXTENDED_RELATIONSHIP : String = "http://schemas.microsoft.com/office/2011/relationships/commentsExtended"

///|
const TRANSITIONAL_HEADER_RELATIONSHIP : String = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/header"

///|
const TRANSITIONAL_FOOTER_RELATIONSHIP : String = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer"

///|
const TRANSITIONAL_FOOTNOTES_RELATIONSHIP : String = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/footnotes"

///|
const TRANSITIONAL_ENDNOTES_RELATIONSHIP : String = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/endnotes"

///|
const STRICT_HEADER_RELATIONSHIP : String = "http://purl.oclc.org/ooxml/officeDocument/relationships/header"

///|
const STRICT_FOOTER_RELATIONSHIP : String = "http://purl.oclc.org/ooxml/officeDocument/relationships/footer"

///|
const STRICT_FOOTNOTES_RELATIONSHIP : String = "http://purl.oclc.org/ooxml/officeDocument/relationships/footnotes"

///|
const STRICT_ENDNOTES_RELATIONSHIP : String = "http://purl.oclc.org/ooxml/officeDocument/relationships/endnotes"

///|
const TRANSITIONAL_STYLES_RELATIONSHIP : String = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles"

///|
const STRICT_STYLES_RELATIONSHIP : String = "http://purl.oclc.org/ooxml/officeDocument/relationships/styles"

///|
const TRANSITIONAL_NUMBERING_RELATIONSHIP : String = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering"

///|
const STRICT_NUMBERING_RELATIONSHIP : String = "http://purl.oclc.org/ooxml/officeDocument/relationships/numbering"

///|
const STYLES_CONTENT_TYPE : String = "application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"

///|
const NUMBERING_CONTENT_TYPE : String = "application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml"

///|
const COMMENTS_CONTENT_TYPE : String = "application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml"

///|
const COMMENTS_EXTENDED_CONTENT_TYPE : String = "application/vnd.openxmlformats-officedocument.wordprocessingml.commentsExtended+xml"

///|
const FOOTNOTES_CONTENT_TYPE : String = "application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml"

///|
const ENDNOTES_CONTENT_TYPE : String = "application/vnd.openxmlformats-officedocument.wordprocessingml.endnotes+xml"

///|
const HEADER_CONTENT_TYPE : String = "application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml"

///|
const FOOTER_CONTENT_TYPE : String = "application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml"

///|
priv enum OoxmlDialect {
  TransitionalOoxml
  StrictOoxml
}

///|
fn same_ooxml_dialect(left : OoxmlDialect, right : OoxmlDialect) -> Bool {
  match (left, right) {
    (TransitionalOoxml, TransitionalOoxml) | (StrictOoxml, StrictOoxml) => true
    _ => false
  }
}

///|
fn wordprocessing_dialect(uri : String) -> OoxmlDialect? {
  match uri {
    WORDPROCESSINGML_NAMESPACE => Some(TransitionalOoxml)
    STRICT_WORDPROCESSINGML_NAMESPACE => Some(StrictOoxml)
    _ => None
  }
}

///|
fn office_relationship_dialect(relationship_type : String) -> OoxmlDialect? {
  match relationship_type {
    TRANSITIONAL_OFFICE_DOCUMENT_RELATIONSHIP => Some(TransitionalOoxml)
    STRICT_OFFICE_DOCUMENT_RELATIONSHIP => Some(StrictOoxml)
    _ => None
  }
}

///|
fn OoxmlDialect::namespace_uri(self : OoxmlDialect) -> String {
  match self {
    TransitionalOoxml => WORDPROCESSINGML_NAMESPACE
    StrictOoxml => STRICT_WORDPROCESSINGML_NAMESPACE
  }
}

///|
fn OoxmlDialect::comments_relationship_type(self : OoxmlDialect) -> String {
  match self {
    TransitionalOoxml => TRANSITIONAL_COMMENTS_RELATIONSHIP
    StrictOoxml => STRICT_COMMENTS_RELATIONSHIP
  }
}

///|
/// One normalized main-relationship target whose XML can carry annotation
/// markers or paragraph identities. `kind` is one of header/footer/footnotes/
/// endnotes. Relationship order is preserved; normalized duplicate targets are
/// emitted once.
priv struct ReachableAnnotationStoryPart {
  kind : String
  path : String
  relationship_dialect : OoxmlDialect
}

///|
priv struct RelatedWordprocessingPart {
  path : String
  relationship_dialect : OoxmlDialect
}

///|
priv enum WordprocessingPartRole {
  StylesPartRole
  NumberingPartRole
}

///|
fn WordprocessingPartRole::label(self : WordprocessingPartRole) -> String {
  match self {
    StylesPartRole => "styles"
    NumberingPartRole => "numbering"
  }
}

///|
fn WordprocessingPartRole::content_type(
  self : WordprocessingPartRole,
) -> String {
  match self {
    StylesPartRole => STYLES_CONTENT_TYPE
    NumberingPartRole => NUMBERING_CONTENT_TYPE
  }
}

///|
fn same_wordprocessing_part_role(
  left : WordprocessingPartRole,
  right : WordprocessingPartRole,
) -> Bool {
  match (left, right) {
    (StylesPartRole, StylesPartRole) | (NumberingPartRole, NumberingPartRole) =>
      true
    _ => false
  }
}

///|
priv struct StyleInfo {
  style_id : String
  style_type : String
  name : String
}

///|
priv struct NumberingLevel {
  is_ordered : Bool
  level : Int
  paragraph_style_id : String?
}

///|
priv struct AbstractNumbering {
  levels : StableStringMap[NumberingLevel]
  num_style_link : String?
}

///|
priv enum NumberingLinkResolution {
  ResolvedAbstractNumbering(String)
  UnresolvableNumbering
}

///|
priv struct NumberingMap {
  abstract_nums : StableStringMap[AbstractNumbering]
  levels_by_paragraph_style_id : StableStringMap[NumberingLevel]
  resolved_abstract_num_ids : StableStringMap[NumberingLinkResolution]
}

///|
fn NumberingMap::empty() -> NumberingMap {
  {
    abstract_nums: SortedMap([]),
    levels_by_paragraph_style_id: SortedMap([]),
    resolved_abstract_num_ids: SortedMap([]),
  }
}

///|
priv struct ContentTypes {
  defaults : StableStringMap[String]
  overrides : StableStringMap[String]
}

///|
/// Document tree plus diagnostics produced while reading a DOCX package.
pub(all) struct DocxReadResult {
  document : DocumentElement
  messages : Array[Message]
} derive(Debug, Eq)

///|
priv struct ExternalFileAccess {
  enabled : Bool
  read_file : (String) -> Bytes?
}

///|
fn no_external_file_reader(_path : String) -> Bytes? {
  None
}

///|
fn default_external_file_access() -> ExternalFileAccess {
  external_file_access_options(false, no_external_file_reader)
}

///|
fn external_file_access_options(
  enabled : Bool,
  read_file : (String) -> Bytes?,
) -> ExternalFileAccess {
  { enabled, read_file }
}

///|
priv struct NotesReadResult {
  notes : Array[Note]
  // Raw identity attributes stay aligned with `notes`. The package
  // projection intentionally keeps Mammoth's legacy `"undefined"` spelling;
  // D2 derives its missing-id spelling from this lossless source separately.
  annotation_ids : Array[String?]
  messages : Array[Message]
}

///|
priv struct CommentsReadResult {
  comments : Array[Comment]
  // Raw identity attributes stay aligned with `comments`; see
  // `NotesReadResult::annotation_ids` above.
  annotation_ids : Array[String?]
  messages : Array[Message]
}

///|
priv struct TableRowForSpan {
  cells : Array[TableCellForSpan]
  is_header : Bool
}

///|
priv struct TableCellForSpan {
  children : Array[DocumentElement]
  col_span : Int
  row_span : Int
}

///|
/// Converts DOCX bytes to HTML.
pub fn convert_to_html(
  docx : BytesView,
  style_map? : Array[String] = [],
  include_default_style_map? : Bool = true,
  include_embedded_style_map? : Bool = true,
  ignore_empty_paragraphs? : Bool = true,
  id_prefix? : String = "",
  pretty_print? : Bool = false,
  convert_image? : (Image) -> ImageConversion = data_uri_image_converter,
  transform_document? : (DocumentElement) -> DocumentElement = identity_document_transform,
  external_file_access? : Bool = false,
  read_external_file? : (String) -> Bytes? = no_external_file_reader,
) -> ConversionResult raise DocxError {
  convert(
    docx,
    output_format=Html,
    style_map~,
    include_default_style_map~,
    include_embedded_style_map~,
    ignore_empty_paragraphs~,
    id_prefix~,
    pretty_print~,
    convert_image~,
    transform_document~,
    external_file_access~,
    read_external_file~,
  )
}

///|
/// Converts DOCX bytes to Markdown.
pub fn convert_to_markdown(
  docx : BytesView,
  style_map? : Array[String] = [],
  include_default_style_map? : Bool = true,
  include_embedded_style_map? : Bool = true,
  ignore_empty_paragraphs? : Bool = true,
  id_prefix? : String = "",
  convert_image? : (Image) -> ImageConversion = data_uri_image_converter,
  transform_document? : (DocumentElement) -> DocumentElement = identity_document_transform,
  external_file_access? : Bool = false,
  read_external_file? : (String) -> Bytes? = no_external_file_reader,
) -> ConversionResult raise DocxError {
  convert(
    docx,
    output_format=Markdown,
    style_map~,
    include_default_style_map~,
    include_embedded_style_map~,
    ignore_empty_paragraphs~,
    id_prefix~,
    convert_image~,
    transform_document~,
    external_file_access~,
    read_external_file~,
  )
}

///|
/// Converts DOCX bytes using the requested output format.
pub fn convert(
  docx : BytesView,
  output_format? : OutputFormat = Html,
  style_map? : Array[String] = [],
  include_default_style_map? : Bool = true,
  include_embedded_style_map? : Bool = true,
  ignore_empty_paragraphs? : Bool = true,
  id_prefix? : String = "",
  pretty_print? : Bool = false,
  convert_image? : (Image) -> ImageConversion = data_uri_image_converter,
  transform_document? : (DocumentElement) -> DocumentElement = identity_document_transform,
  external_file_access? : Bool = false,
  read_external_file? : (String) -> Bytes? = no_external_file_reader,
) -> ConversionResult raise DocxError {
  let zip = open_zip(docx)
  let read_result = read_docx_zip_with_messages(
    zip,
    external_files=external_file_access_options(
      external_file_access, read_external_file,
    ),
  )
  let style_map = combine_docx_style_maps_from_zip(
    zip,
    style_map,
    include_embedded_style_map~,
  )
  let result = convert_document(
    read_result.document,
    output_format~,
    style_map~,
    include_default_style_map~,
    ignore_empty_paragraphs~,
    id_prefix~,
    pretty_print~,
    convert_image~,
    transform_document~,
  )
  prepend_messages(result, read_result.messages)
}

///|
/// Extracts raw text from DOCX bytes.
pub fn extract_raw_text(
  docx : BytesView,
  external_file_access? : Bool = false,
  read_external_file? : (String) -> Bytes? = no_external_file_reader,
) -> ConversionResult raise DocxError {
  let read_result = read_docx_zip_with_messages(
    open_zip(docx),
    external_files=external_file_access_options(
      external_file_access, read_external_file,
    ),
    read_images=false,
  )
  let result = extract_raw_text_from_document(read_result.document)
  prepend_messages(result, read_result.messages)
}

///|
fn prepend_messages(
  result : ConversionResult,
  messages : Array[Message],
) -> ConversionResult {
  if messages.is_empty() {
    result
  } else {
    let combined : Array[Message] = []
    combined.append(messages)
    combined.append(result.messages)
    { value: result.value, messages: @core.dedupe_messages(combined) }
  }
}

///|
/// Reads the embedded style map from DOCX bytes, if present.
pub fn read_embedded_style_map(docx : BytesView) -> String? raise DocxError {
  read_embedded_style_map_from_zip(open_zip(docx))
}

///|
fn combine_docx_style_maps_from_zip(
  zip : ZipArchive,
  explicit_style_map : Array[String],
  include_embedded_style_map~ : Bool,
) -> Array[String] raise DocxError {
  let combined : Array[String] = []
  combined.append(explicit_style_map)
  if include_embedded_style_map {
    match read_embedded_style_map_from_zip(zip) {
      Some(style_map) => combined.append(read_style_map_string(style_map))
      None => ()
    }
  }
  combined
}

///|
fn read_embedded_style_map_from_zip(
  zip : ZipArchive,
) -> String? raise DocxError {
  zip.read_text("mammoth/style-map")
}

///|
/// Reads DOCX bytes into a document tree.
pub fn read_docx(
  docx : BytesView,
  external_file_access? : Bool = false,
  read_external_file? : (String) -> Bytes? = no_external_file_reader,
) -> DocumentElement raise DocxError {
  read_docx_with_messages(docx, external_file_access~, read_external_file~).document
}

///|
/// Reads DOCX bytes into a document tree and diagnostics.
pub fn read_docx_with_messages(
  docx : BytesView,
  external_file_access? : Bool = false,
  read_external_file? : (String) -> Bytes? = no_external_file_reader,
) -> DocxReadResult raise DocxError {
  read_docx_zip_with_messages(
    open_zip(docx),
    external_files=external_file_access_options(
      external_file_access, read_external_file,
    ),
  )
}

///|
/// The package projection retains Mammoth's JavaScript-derived `"undefined"`
/// sentinel. D2 identity is derived separately from the raw optional
/// attribute retained beside each parsed annotation definition.
fn annotation_id_or_missing(value : String?) -> String {
  match value {
    Some(id) => id
    None => "undefined"
  }
}

///|
/// The assembled per-document reading context: every part-derived input the
/// body/story readers need. Shared by `read_docx_zip_with_messages` (the
/// frozen body-only surface) and `read_docx_package` (body + sections +
/// header/footer stories).
priv struct DocumentParts {
  zip : ZipArchive
  xml_budget : @xml.XmlReadBudget?
  diagnostics : ReaderDiagnosticCollector?
  main_document_path : String
  main_relationship_dialect : OoxmlDialect?
  root : XmlElement
  styles : StableStringMap[StyleInfo]
  numbering : NumberingMap
  content_types : ContentTypes
  relationships : Relationships
  relationship_index : RelationshipIndex
  base_path : String
  notes_result : NotesReadResult
  comments_result : CommentsReadResult
  // Authoritative main-part relationship targets. `None` means no such
  // relationship was declared; legacy filename fallbacks remain reader-only
  // and are never exposed as wired annotation parts.
  comments_part_path : String?
  comments_extended_part_path : String?
  footnotes_part_path : String?
  endnotes_part_path : String?
  external_files : ExternalFileAccess
  read_images : Bool
}

///|
fn DocumentParts::build(
  zip : ZipArchive,
  external_files? : ExternalFileAccess = default_external_file_access(),
  read_images? : Bool = true,
  xml_budget? : @xml.XmlReadBudget,
  diagnostics? : ReaderDiagnosticCollector,
  expected_main_document_path? : String,
  strict_main_document? : Bool = false,
) -> DocumentParts raise DocxError {
  let main_document = find_main_document(
    zip,
    xml_budget?,
    expected_main_document_path?,
  )
  let main_document_path = main_document.path
  let relationships = read_relationships(
    zip,
    relationships_path_for_part(zip, main_document_path),
    xml_budget?,
  )
  let relationship_index = RelationshipIndex::build(relationships)
  let content_types = read_content_types(zip, xml_budget?)
  let logical_main_document_path = zip
    .logical_path(main_document_path)
    .unwrap_or(main_document_path)
  let base_path = split_zip_path(logical_main_document_path).0
  let comments_part_path = resolve_unique_internal_part(
    zip,
    relationships,
    [TRANSITIONAL_COMMENTS_RELATIONSHIP, STRICT_COMMENTS_RELATIONSHIP],
    base_path,
    "comments",
    reject_multiple_relationships=true,
  )
  let comments_extended_part_path = resolve_unique_internal_part(
    zip,
    relationships,
    [COMMENTS_EXTENDED_RELATIONSHIP],
    base_path,
    "commentsExtended",
    reject_multiple_relationships=true,
  )
  let footnotes_part_path = resolve_unique_internal_part(
    zip,
    relationships,
    [TRANSITIONAL_FOOTNOTES_RELATIONSHIP, STRICT_FOOTNOTES_RELATIONSHIP],
    base_path,
    "footnotes",
    reject_multiple_relationships=true,
  )
  let endnotes_part_path = resolve_unique_internal_part(
    zip,
    relationships,
    [TRANSITIONAL_ENDNOTES_RELATIONSHIP, STRICT_ENDNOTES_RELATIONSHIP],
    base_path,
    "endnotes",
    reject_multiple_relationships=true,
  )
  content_types.require_annotation_part(zip, comments_part_path, "comments")
  content_types.require_annotation_part(
    zip, comments_extended_part_path, "commentsExtended",
  )
  content_types.require_annotation_part(zip, footnotes_part_path, "footnotes")
  content_types.require_annotation_part(zip, endnotes_part_path, "endnotes")
  let styles_part = resolve_unique_wordprocessing_part(
    zip,
    relationships,
    base_path,
    StylesPartRole,
    main_document.relationship_dialect,
  )
  let numbering_part = resolve_unique_wordprocessing_part(
    zip,
    relationships,
    base_path,
    NumberingPartRole,
    main_document.relationship_dialect,
  )
  let styles = match styles_part {
    Some(part) => {
      content_types.require_wordprocessing_part(zip, part, StylesPartRole)
      read_styles_xml(
        read_wordprocessing_role_xml_part(
          zip,
          part,
          StylesPartRole,
          xml_budget?,
        ),
      )
    }
    None => SortedMap([])
  }
  let numbering = match numbering_part {
    Some(part) => {
      content_types.require_wordprocessing_part(zip, part, NumberingPartRole)
      read_numbering_xml(
        read_wordprocessing_role_xml_part(
          zip,
          part,
          NumberingPartRole,
          xml_budget?,
        ),
        styles,
      )
    }
    None => NumberingMap::empty()
  }
  let notes_result = read_notes_for_document(
    zip,
    styles,
    numbering,
    content_types,
    footnotes_path=footnotes_part_path.unwrap_or("word/footnotes.xml"),
    endnotes_path=endnotes_part_path.unwrap_or("word/endnotes.xml"),
    external_files~,
    read_images~,
    xml_budget?,
    diagnostics?,
  )
  let comments_result = read_comments_for_document(
    zip,
    styles,
    numbering,
    content_types,
    relationships,
    base_path,
    external_files~,
    read_images~,
    xml_budget?,
    comments_part_path?,
    diagnostics?,
  )
  // Every public package projection uses the tolerant reader semantics.
  // Mutation-safe annotated reads validate the same source again through the
  // strict identity gate before exposing byte spans, without substituting the
  // strict parser's normalized DOM into the returned document model.
  let root = read_xml_part(zip, main_document_path, xml_budget?)
  if strict_main_document {
    validate_xml_part_strict_locally(zip, main_document_path)
  }
  {
    zip,
    xml_budget,
    diagnostics,
    main_document_path,
    main_relationship_dialect: main_document.relationship_dialect,
    root,
    styles,
    numbering,
    content_types,
    relationships,
    relationship_index,
    base_path,
    notes_result,
    comments_result,
    comments_part_path,
    comments_extended_part_path,
    footnotes_part_path,
    endnotes_part_path,
    external_files,
    read_images,
  }
}

///|
fn DocumentParts::read_document(
  self : DocumentParts,
) -> DocxReadResult raise DocxError {
  read_document_xml_with_messages(
    self.root,
    self.styles,
    self.numbering,
    self.zip,
    self.relationship_index,
    self.content_types,
    self.base_path,
    self.notes_result.notes,
    self.comments_result.comments,
    external_files=self.external_files,
    read_images=self.read_images,
    diagnostics?=self.diagnostics,
  )
}

///|
fn read_docx_zip_with_messages(
  zip : ZipArchive,
  external_files? : ExternalFileAccess = default_external_file_access(),
  read_images? : Bool = true,
) -> DocxReadResult raise DocxError {
  let parts = DocumentParts::build(zip, external_files~, read_images~)
  let document_result = parts.read_document()
  let messages : Array[Message] = []
  messages.append(parts.notes_result.messages)
  messages.append(parts.comments_result.messages)
  messages.append(document_result.messages)
  {
    document: document_result.document,
    messages: @core.dedupe_messages(messages),
  }
}

///|
fn read_xml_part(
  zip : ZipArchive,
  path : String,
  xml_budget? : @xml.XmlReadBudget,
) -> XmlElement raise DocxError {
  read_xml_part_with_root_namespace_uri(zip, path, xml_budget?).1
}

///|
/// Reads one tolerant XML part while retaining the namespace identity of its
/// document element. The DOM's canonical name alone cannot distinguish a
/// bound QName from an identical literal name whose prefix is unbound.
fn read_xml_part_with_root_namespace_uri(
  zip : ZipArchive,
  path : String,
  xml_budget? : @xml.XmlReadBudget,
) -> (String?, XmlElement) raise DocxError {
  match zip.read_bytes(path) {
    Some(bytes) =>
      match xml_budget {
        Some(budget) =>
          @xml.read_xml_bytes_limited_with_root_namespace_uri(
            bytes,
            budget,
            namespace_map=office_namespace_map(),
          )
        None => {
          let text = @utf8.decode(bytes, ignore_bom=true) catch {
            _ => raise InvalidXml(message="part is not valid UTF-8: " + path)
          }
          @xml.read_xml_string_with_root_namespace_uri(
            text,
            namespace_map=office_namespace_map(),
          )
        }
      }
    None => raise MissingPart(message="missing DOCX part: " + path)
  }
}

///|
/// Strictly validates the mutation-safe main story without charging its source
/// twice to the caller's cumulative retained-DOM budget. The tolerant DOM was
/// already charged by `read_xml_part`; this discarded validation DOM receives
/// an input-linear transient budget of its own.
fn validate_xml_part_strict_locally(
  zip : ZipArchive,
  path : String,
) -> Unit raise DocxError {
  guard zip.read_bytes(path) is Some(bytes) else {
    raise MissingPart(message="missing DOCX part: " + path)
  }
  @xml.read_xml_bytes_strict_limited(
    bytes,
    local_input_linear_xml_budget(bytes),
    namespace_map=office_namespace_map(),
  )
  |> ignore
}

///|
fn office_namespace_map() -> Map[String, String] {
  {
    "http://schemas.openxmlformats.org/wordprocessingml/2006/main": "w",
    "http://purl.oclc.org/ooxml/wordprocessingml/main": "w",
    "http://schemas.openxmlformats.org/officeDocument/2006/relationships": "r",
    "http://purl.oclc.org/ooxml/officeDocument/relationships": "r",
    "http://purl.oclc.org/ooxml/drawingml/wordprocessingDrawing": "wp",
    "http://purl.oclc.org/ooxml/drawingml/main": "a",
    "http://purl.oclc.org/ooxml/drawingml/picture": "pic",
    "http://schemas.openxmlformats.org/package/2006/relationships": "relationships",
    "http://schemas.openxmlformats.org/package/2006/content-types": "content-types",
    "http://schemas.openxmlformats.org/markup-compatibility/2006": "mc",
    "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing": "wp",
    "http://schemas.openxmlformats.org/drawingml/2006/main": "a",
    "http://schemas.openxmlformats.org/drawingml/2006/picture": "pic",
    "http://schemas.microsoft.com/office/word/2010/wordml": "w14",
    "http://schemas.microsoft.com/office/word/2012/wordml": "w15",
    "urn:schemas-microsoft-com:office:office": "o",
    "urn:schemas-microsoft-com:office:word": "office-word",
    "urn:schemas-microsoft-com:vml": "v",
  }
}

///|
fn wordprocessing_role_namespace_map(
  dialect : OoxmlDialect,
) -> Map[String, String] {
  let namespaces = office_namespace_map()
  match dialect {
    TransitionalOoxml =>
      namespaces[STRICT_WORDPROCESSINGML_NAMESPACE] = "w-incompatible"
    StrictOoxml => namespaces[WORDPROCESSINGML_NAMESPACE] = "w-incompatible"
  }
  namespaces
}

///|
fn read_wordprocessing_role_xml_part(
  zip : ZipArchive,
  part : RelatedWordprocessingPart,
  role : WordprocessingPartRole,
  xml_budget? : @xml.XmlReadBudget,
) -> XmlElement raise DocxError {
  guard zip.read_bytes(part.path) is Some(bytes) else {
    raise MissingPart(message="missing DOCX part: " + part.path)
  }
  let namespace_map = wordprocessing_role_namespace_map(
    part.relationship_dialect,
  )
  let root = match xml_budget {
    Some(budget) =>
      @xml.read_xml_bytes_strict_limited(bytes, budget, namespace_map~)
    None => {
      let text = @utf8.decode(bytes, ignore_bom=true) catch {
        _ => raise InvalidXml(message="part is not valid UTF-8: " + part.path)
      }
      @xml.read_xml_string_strict(text, namespace_map~)
    }
  }
  let kind = role.label()
  let expected_root = "w:" + kind
  if root.name != expected_root {
    raise Unsupported(
      message="the \{kind} relationship target '\{part.path}' has root '\{root.name}', expected '\{expected_root}' in the relationship dialect",
    )
  }
  root
}

///|
priv struct MainDocumentPart {
  path : String
  relationship_dialect : OoxmlDialect?
}

///|
fn find_main_document(
  zip : ZipArchive,
  xml_budget? : @xml.XmlReadBudget,
  expected_main_document_path? : String,
) -> MainDocumentPart raise DocxError {
  let relationships = read_relationships(zip, "_rels/.rels", xml_budget?)
  let path = resolve_unique_internal_part(
    zip,
    relationships,
    [
      TRANSITIONAL_OFFICE_DOCUMENT_RELATIONSHIP,
      STRICT_OFFICE_DOCUMENT_RELATIONSHIP,
    ],
    "",
    "officeDocument",
    reject_multiple_relationships=true,
  )
  fn authoritative_part(authoritative : String) -> MainDocumentPart {
    let mut relationship_dialect : OoxmlDialect? = None
    for relationship in relationships.relationships {
      match office_relationship_dialect(relationship.relationship_type) {
        Some(dialect) => relationship_dialect = Some(dialect)
        None => ()
      }
    }
    { path: authoritative, relationship_dialect }
  }
  match expected_main_document_path {
    Some(expected) =>
      match path {
        Some(authoritative) if authoritative == expected =>
          authoritative_part(authoritative)
        Some(_) =>
          raise Unsupported(
            message="the DOCX reader selected a different main document part than structural validation",
          )
        None =>
          raise Unsupported(
            message="the structurally validated officeDocument relationship was not recognized by the DOCX reader",
          )
      }
    None =>
      match path {
        Some(authoritative) => authoritative_part(authoritative)
        None =>
          match zip.resolve_path("word/document.xml") {
            Some(actual) => { path: actual, relationship_dialect: None }
            None =>
              raise MissingPart(
                message="Could not find main document part. Are you sure this is a valid .docx file?",
              )
          }
      }
  }
}

///|
fn strip_leading_slash(path : String) -> String {
  if path.has_prefix("/") {
    path[1:].to_owned()
  } else {
    path
  }
}

///|
fn read_relationships(
  zip : ZipArchive,
  path : String,
  xml_budget? : @xml.XmlReadBudget,
) -> Relationships raise DocxError {
  match zip.read_bytes(path) {
    Some(bytes) => decode_relationships_bytes(bytes, xml_budget?)
    None => { relationships: [] }
  }
}

///|
fn decode_relationships_bytes(
  bytes : BytesView,
  xml_budget? : @xml.XmlReadBudget,
) -> Relationships raise DocxError {
  let budget = match xml_budget {
    Some(value) => value
    None => local_input_linear_xml_budget(bytes)
  }
  read_relationships_xml(@opc.read_opc_relationships_xml_limited(bytes, budget))
}

///|
/// Strict DTD-free relationship read for security-sensitive identity walks.
/// The caller-owned budget is cumulative across the relationship graph and all
/// subsequently visited story parts.
fn read_relationships_strict_limited(
  zip : ZipArchive,
  path : String,
  xml_budget : @xml.XmlReadBudget,
) -> Relationships raise DocxError {
  match zip.read_bytes(path) {
    Some(bytes) => decode_relationships_bytes(bytes, xml_budget~)
    None => { relationships: [] }
  }
}

///|
fn read_relationships_xml(root : XmlElement) -> Relationships {
  let relationships : Array[Relationship] = []
  for node in root.children {
    guard node is XmlElement(rel) &&
      (
        rel.name == PACKAGE_RELATIONSHIP_ELEMENT ||
        rel.name == "relationships:Relationship"
      ) else {
      continue
    }
    relationships.push({
      id: @xml.collapse_xml_schema_whitespace(
        rel.attributes.get_or_default("Id", ""),
      ),
      relationship_type: @xml.collapse_xml_schema_whitespace(
        rel.attributes.get_or_default("Type", ""),
      ),
      target: @xml.collapse_xml_schema_whitespace(
        rel.attributes.get_or_default("Target", ""),
      ),
      target_mode: rel.attributes.get("TargetMode"),
    })
  }
  { relationships, }
}

///|
/// Gives otherwise-unbounded library reads an input-linear strict XML budget.
/// Namespace expansion can materialize substantially more text than the
/// compact source, so derived work receives a conservative constant factor.
fn local_input_linear_xml_budget(bytes : BytesView) -> @xml.XmlReadBudget {
  let scaled = bytes.length().to_int64() * 32L
  let allowance = if scaled <= 0L {
    1
  } else if scaled > 2147483647L {
    2147483647
  } else {
    scaled.to_int()
  }
  @xml.xml_read_budget(
    max_source_units=bytes.length(),
    max_tokens=allowance,
    max_materialized_chars=allowance,
    max_token_chars=if bytes.length() > 0 { bytes.length() } else { 1 },
  )
}

///|
fn Relationships::find_targets_by_type(
  self : Relationships,
  relationship_type : String,
) -> Array[String] {
  let targets : Array[String] = []
  for rel in self.relationships {
    if rel.relationship_type == relationship_type {
      targets.push(rel.target)
    }
  }
  targets
}

///|
/// Resolves one authoritative internal relationship target. Distinct targets,
/// external/invalid modes, empty targets, and missing declared targets fail
/// closed. Callers may apply a legacy filename fallback only when this returns
/// `None`, which means no matching relationship was declared at all.
fn resolve_unique_internal_part(
  zip : ZipArchive,
  relationships : Relationships,
  relationship_types : ReadOnlyArray[String],
  base_path : String,
  label : String,
  reject_multiple_relationships? : Bool = false,
) -> String? raise DocxError {
  let targets : Array[String] = []
  let seen : StableStringSet = SortedSet([])
  let mut matches = 0
  for relationship in relationships.relationships {
    if relationship_types.search(relationship.relationship_type) is None {
      continue
    }
    matches += 1
    match relationship.target_mode {
      None | Some("Internal") => ()
      _ =>
        raise Unsupported(message="the \{label} relationship must be internal")
    }
    if relationship.target == "" {
      raise Unsupported(message="the \{label} relationship has no target")
    }
    guard @opc.resolve_part_target(base_path, relationship.target)
      is Some(resolved) else {
      raise Unsupported(message="the \{label} relationship target is invalid")
    }
    guard zip.resolve_path(resolved) is Some(actual) else {
      raise MissingPart(
        message="the \{label} relationship targets a missing part",
      )
    }
    if !seen.contains(actual) {
      seen.add(actual)
      targets.push(actual)
    }
  }
  match targets {
    [] => None
    [target] =>
      if reject_multiple_relationships && matches > 1 {
        raise Unsupported(
          message="the document declares multiple \{label} relationships",
        )
      } else {
        Some(target)
      }
    _ =>
      raise Unsupported(
        message="the document declares multiple distinct \{label} relationship targets",
      )
  }
}

///|
fn wordprocessing_role_relationship_identity(
  relationship_type : String,
) -> (WordprocessingPartRole, OoxmlDialect)? {
  match relationship_type {
    TRANSITIONAL_STYLES_RELATIONSHIP =>
      Some((StylesPartRole, TransitionalOoxml))
    STRICT_STYLES_RELATIONSHIP => Some((StylesPartRole, StrictOoxml))
    TRANSITIONAL_NUMBERING_RELATIONSHIP =>
      Some((NumberingPartRole, TransitionalOoxml))
    STRICT_NUMBERING_RELATIONSHIP => Some((NumberingPartRole, StrictOoxml))
    _ => None
  }
}

///|
/// Styles and numbering are optional relationship roles, not conventional
/// filenames. A declared role must match the main package dialect and resolve
/// to exactly one internal part before any XML from that target is consumed.
fn resolve_unique_wordprocessing_part(
  zip : ZipArchive,
  relationships : Relationships,
  base_path : String,
  role : WordprocessingPartRole,
  main_dialect : OoxmlDialect?,
) -> RelatedWordprocessingPart? raise DocxError {
  let kind = role.label()
  let parts : Array[RelatedWordprocessingPart] = []
  let seen : StableStringSet = SortedSet([])
  let mut matches = 0
  for relationship in relationships.relationships {
    guard wordprocessing_role_relationship_identity(
        relationship.relationship_type,
      )
      is Some((relationship_kind, relationship_dialect)) else {
      continue
    }
    if !same_wordprocessing_part_role(relationship_kind, role) {
      continue
    }
    match main_dialect {
      Some(expected) if !same_ooxml_dialect(expected, relationship_dialect) =>
        raise Unsupported(
          message="the \{kind} relationship dialect does not match the main document relationship",
        )
      _ => ()
    }
    matches += 1
    match relationship.target_mode {
      None | Some("Internal") => ()
      _ =>
        raise Unsupported(message="the \{kind} relationship must be internal")
    }
    if relationship.target == "" {
      raise Unsupported(message="the \{kind} relationship has no target")
    }
    guard @opc.resolve_part_target(base_path, relationship.target)
      is Some(resolved) else {
      raise Unsupported(message="the \{kind} relationship target is invalid")
    }
    guard zip.resolve_path(resolved) is Some(actual) else {
      raise MissingPart(
        message="the \{kind} relationship targets a missing part",
      )
    }
    if !seen.contains(actual) {
      seen.add(actual)
      parts.push({ path: actual, relationship_dialect })
    }
  }
  if matches > 1 {
    raise Unsupported(
      message="the document declares multiple \{kind} relationships",
    )
  }
  match parts {
    [] => None
    [part] => Some(part)
    _ =>
      raise Unsupported(
        message="the document declares multiple distinct \{kind} relationship targets",
      )
  }
}

///|
fn annotation_story_relationship_identity(
  relationship_type : String,
) -> (String, OoxmlDialect)? {
  match relationship_type {
    TRANSITIONAL_COMMENTS_RELATIONSHIP => Some(("comments", TransitionalOoxml))
    STRICT_COMMENTS_RELATIONSHIP => Some(("comments", StrictOoxml))
    TRANSITIONAL_HEADER_RELATIONSHIP => Some(("header", TransitionalOoxml))
    STRICT_HEADER_RELATIONSHIP => Some(("header", StrictOoxml))
    TRANSITIONAL_FOOTER_RELATIONSHIP => Some(("footer", TransitionalOoxml))
    STRICT_FOOTER_RELATIONSHIP => Some(("footer", StrictOoxml))
    TRANSITIONAL_FOOTNOTES_RELATIONSHIP =>
      Some(("footnotes", TransitionalOoxml))
    STRICT_FOOTNOTES_RELATIONSHIP => Some(("footnotes", StrictOoxml))
    TRANSITIONAL_ENDNOTES_RELATIONSHIP => Some(("endnotes", TransitionalOoxml))
    STRICT_ENDNOTES_RELATIONSHIP => Some(("endnotes", StrictOoxml))
    _ => None
  }
}

///|
/// Resolves every annotation-bearing main-part relationship authoritatively.
/// External, empty, missing, or cross-kind aliasing targets fail closed; same-
/// kind aliases that normalize to one OPC path are deduplicated.
fn resolve_reachable_annotation_story_parts(
  zip : ZipArchive,
  relationships : Relationships,
  base_path : String,
) -> Array[ReachableAnnotationStoryPart] raise DocxError {
  let resolved_parts : Array[ReachableAnnotationStoryPart] = []
  let kind_by_path : StableStringMap[String] = SortedMap([])
  let dialect_by_path : StableStringMap[OoxmlDialect] = SortedMap([])
  for relationship in relationships.relationships {
    guard annotation_story_relationship_identity(relationship.relationship_type)
      is Some((kind, relationship_dialect)) else {
      continue
    }
    match relationship.target_mode {
      None | Some("Internal") => ()
      _ =>
        raise Unsupported(message="the \{kind} relationship must be internal")
    }
    if relationship.target == "" {
      raise Unsupported(message="the \{kind} relationship has no target")
    }
    guard @opc.resolve_part_target(base_path, relationship.target) is Some(path) else {
      raise Unsupported(message="the \{kind} relationship target is invalid")
    }
    guard zip.resolve_path(path) is Some(actual) else {
      raise MissingPart(
        message="the \{kind} relationship targets missing part '\{path}'",
      )
    }
    match kind_by_path.get(actual) {
      Some(existing_kind) if existing_kind != kind =>
        raise Unsupported(
          message="annotation story part '\{actual}' is targeted as both \{existing_kind} and \{kind}",
        )
      Some(_) => {
        guard dialect_by_path.get(actual) is Some(existing_dialect) else {
          raise Unsupported(
            message="the \{kind} relationship dialect could not be determined",
          )
        }
        if !same_ooxml_dialect(existing_dialect, relationship_dialect) {
          raise Unsupported(
            message="annotation story part '\{actual}' has conflicting Strict and Transitional \{kind} relationships",
          )
        }
        continue
      }
      None => ()
    }
    kind_by_path[actual] = kind
    dialect_by_path[actual] = relationship_dialect
    resolved_parts.push({ kind, path: actual, relationship_dialect })
  }
  resolved_parts
}

///|
/// Resolves a section's header/footer reference through exactly one internal
/// relationship of the matching semantic type. This is shared by the tolerant
/// package projection and the strict annotation-identity gate so the two paths
/// cannot disagree about what a section reference means.
fn resolve_header_footer_reference_part(
  zip : ZipArchive,
  relationship_index : RelationshipIndex,
  base_path : String,
  id : String,
  is_header : Bool,
) -> String raise DocxError {
  let kind = if is_header { "header" } else { "footer" }
  guard relationship_index.by_id.get(id) is Some(matches) else {
    raise Unsupported(
      message="the \{kind} reference names unknown relationship id '\{id}'",
    )
  }
  if matches.length() != 1 {
    raise Unsupported(
      message="the \{kind} reference names duplicate relationship id '\{id}'",
    )
  }
  let relationship = matches[0]
  let type_matches = if is_header {
    relationship.relationship_type == TRANSITIONAL_HEADER_RELATIONSHIP ||
    relationship.relationship_type == STRICT_HEADER_RELATIONSHIP
  } else {
    relationship.relationship_type == TRANSITIONAL_FOOTER_RELATIONSHIP ||
    relationship.relationship_type == STRICT_FOOTER_RELATIONSHIP
  }
  if !type_matches {
    raise Unsupported(
      message="the \{kind} reference relationship '\{id}' is not a \{kind} relationship",
    )
  }
  match relationship.target_mode {
    None | Some("Internal") => ()
    _ =>
      raise Unsupported(
        message="the \{kind} reference relationship '\{id}' must be internal",
      )
  }
  if relationship.target == "" {
    raise Unsupported(
      message="the \{kind} reference relationship '\{id}' has no target",
    )
  }
  guard @opc.resolve_part_target(base_path, relationship.target) is Some(path) else {
    raise Unsupported(
      message="the \{kind} reference relationship '\{id}' has an invalid target",
    )
  }
  match zip.resolve_path(path) {
    Some(actual) => actual
    None =>
      raise MissingPart(
        message="the \{kind} reference relationship '\{id}' targets missing part '\{path}'",
      )
  }
}

///|
fn relationships_path_for_part(zip : ZipArchive, path : String) -> String {
  let logical_path = zip.logical_path(path).unwrap_or(path)
  let (dir, base) = split_zip_path(logical_path)
  join_zip_path([dir, "_rels", base + ".rels"])
}

///|
fn read_content_types(
  zip : ZipArchive,
  xml_budget? : @xml.XmlReadBudget,
) -> ContentTypes raise DocxError {
  let defaults : StableStringMap[String] = SortedMap([])
  let overrides : StableStringMap[String] = SortedMap([])
  match zip.read_bytes("[Content_Types].xml") {
    Some(_) => {
      let root = read_xml_part(zip, "[Content_Types].xml", xml_budget?)
      for element in root.elements_by_tag_name("content-types:Default") {
        let extension = element.attributes.get_or_default("Extension", "")
        let content_type = element.attributes.get_or_default("ContentType", "")
        if extension != "" && content_type != "" {
          defaults[@opc.part_name_key(extension)] = content_type
        }
      }
      for element in root.elements_by_tag_name("content-types:Override") {
        let part_name = element.attributes.get_or_default("PartName", "")
        let content_type = element.attributes.get_or_default("ContentType", "")
        if part_name != "" && content_type != "" {
          match @opc.resolve_part_target("", part_name) {
            Some(logical) if "/" + logical == part_name =>
              overrides[@opc.part_name_key(logical)] = content_type
            _ => ()
          }
        }
      }
    }
    None => ()
  }
  { defaults, overrides }
}

///|
fn ContentTypes::find_content_type(
  self : ContentTypes,
  path : String,
  zip? : ZipArchive,
) -> String {
  let requested = strip_leading_slash(path)
  let logical = match zip {
    Some(archive) => archive.logical_path(requested).unwrap_or(requested)
    None => requested
  }
  let normalized = @opc.part_name_key(logical)
  match self.overrides.get(normalized) {
    Some(content_type) => content_type
    None =>
      match normalized.rev_find(".") {
        Some(index) => {
          let extension = normalized[index + 1:].to_owned()
          match self.defaults.get(extension) {
            Some(content_type) => content_type
            None => fallback_content_type(extension)
          }
        }
        None => ""
      }
  }
}

///|
fn expected_annotation_content_type(kind : String) -> String? {
  match kind {
    "comments" => Some(COMMENTS_CONTENT_TYPE)
    "commentsExtended" => Some(COMMENTS_EXTENDED_CONTENT_TYPE)
    "footnotes" => Some(FOOTNOTES_CONTENT_TYPE)
    "endnotes" => Some(ENDNOTES_CONTENT_TYPE)
    "header" => Some(HEADER_CONTENT_TYPE)
    "footer" => Some(FOOTER_CONTENT_TYPE)
    _ => None
  }
}

///|
/// Relationship roles, target existence, root QNames, and content types form one
/// identity contract. A generic XML default is not sufficient for an OOXML
/// annotation story even when the target's root element looks plausible.
fn ContentTypes::require_annotation_part(
  self : ContentTypes,
  zip : ZipArchive,
  path : String?,
  kind : String,
) -> Unit raise DocxError {
  guard path is Some(path) else { return }
  guard expected_annotation_content_type(kind) is Some(expected) else { return }
  let actual = self.find_content_type(path, zip~)
  if actual.to_lower() != expected.to_lower() {
    let declared = if actual == "" { "" } else { actual }
    raise Unsupported(
      message="the \{kind} relationship target '\{path}' has content type '\{declared}', expected '\{expected}'",
    )
  }
}

///|
fn ContentTypes::require_wordprocessing_part(
  self : ContentTypes,
  zip : ZipArchive,
  part : RelatedWordprocessingPart,
  role : WordprocessingPartRole,
) -> Unit raise DocxError {
  let kind = role.label()
  let expected = role.content_type()
  let actual = self.find_content_type(part.path, zip~)
  if actual.to_lower() != expected.to_lower() {
    let declared = if actual == "" { "" } else { actual }
    raise Unsupported(
      message="the \{kind} relationship target '\{part.path}' has content type '\{declared}', expected '\{expected}'",
    )
  }
}

///|
fn fallback_content_type(extension : String) -> String {
  match extension.to_lower() {
    "png" => "image/png"
    "gif" => "image/gif"
    "jpeg" | "jpg" => "image/jpeg"
    "bmp" => "image/bmp"
    "tif" | "tiff" => "image/tiff"
    _ => ""
  }
}

///|
fn read_numbering_xml(
  root : XmlElement,
  styles : StableStringMap[StyleInfo],
) -> NumberingMap {
  let abstract_nums = read_abstract_numberings(root)
  let nums = read_numbering_instances(root)
  let numbering_style_num_ids = index_numbering_style_num_ids(styles)
  {
    abstract_nums,
    levels_by_paragraph_style_id: index_levels_by_paragraph_style_id(
      abstract_nums,
    ),
    resolved_abstract_num_ids: resolve_numbering_style_links(
      nums, abstract_nums, numbering_style_num_ids,
    ),
  }
}

///|
fn read_abstract_numberings(
  root : XmlElement,
) -> StableStringMap[AbstractNumbering] {
  let abstract_nums : StableStringMap[AbstractNumbering] = SortedMap([])
  for element in root.elements_by_tag_name("w:abstractNum") {
    match element.attributes.get("w:abstractNumId") {
      Some(id) => abstract_nums[id] = read_abstract_numbering(element)
      None => ()
    }
  }
  abstract_nums
}

///|
fn read_abstract_numbering(element : XmlElement) -> AbstractNumbering {
  let levels : StableStringMap[NumberingLevel] = SortedMap([])
  let mut level_without_index : NumberingLevel? = None
  for level_element in element.elements_by_tag_name("w:lvl") {
    let level = read_numbering_level(level_element)
    match level_element.attributes.get("w:ilvl") {
      Some(index) => levels[index] = level
      None => level_without_index = Some(level)
    }
  }
  match level_without_index {
    Some(level) => if !levels.contains("0") { levels["0"] = level }
    None => ()
  }
  {
    levels,
    num_style_link: element.first_or_empty("w:numStyleLink").attributes.get(
      "w:val",
    ),
  }
}

///|
fn read_numbering_level(element : XmlElement) -> NumberingLevel {
  let level_index = @string.parse_int(
    element.attributes.get_or_default("w:ilvl", "0"),
  ) catch {
    _ => 0
  }
  let num_fmt = element.first_or_empty("w:numFmt").attributes.get("w:val")
  {
    is_ordered: num_fmt != Some("bullet"),
    level: level_index + 1,
    paragraph_style_id: element.first_or_empty("w:pStyle").attributes.get(
      "w:val",
    ),
  }
}

///|
fn read_numbering_instances(root : XmlElement) -> StableStringMap[String] {
  let nums : StableStringMap[String] = SortedMap([])
  for element in root.elements_by_tag_name("w:num") {
    let num_id = element.attributes.get_or_default("w:numId", "")
    let abstract_num_id = element.first_or_empty("w:abstractNumId").attributes.get_or_default(
      "w:val", "",
    )
    if num_id != "" && abstract_num_id != "" {
      nums[num_id] = abstract_num_id
    }
  }
  nums
}

///|
fn index_levels_by_paragraph_style_id(
  abstract_nums : StableStringMap[AbstractNumbering],
) -> StableStringMap[NumberingLevel] {
  let levels : StableStringMap[NumberingLevel] = SortedMap([])
  for item in abstract_nums.to_array() {
    let (_, abstract_num) = item
    for level_item in abstract_num.levels.to_array() {
      let (_, level) = level_item
      match level.paragraph_style_id {
        Some(style_id) => levels[style_id] = level
        None => ()
      }
    }
  }
  levels
}

///|
fn index_numbering_style_num_ids(
  styles : StableStringMap[StyleInfo],
) -> StableStringMap[String] {
  let style_num_ids : StableStringMap[String] = SortedMap([])
  for item in styles.to_array() {
    let (_, style) = item
    if style.style_type == "numbering" {
      if style.name != "" {
        style_num_ids[style.style_id] = style.name
      }
    }
  }
  style_num_ids
}

///|
fn resolve_numbering_style_links(
  nums : StableStringMap[String],
  abstract_nums : StableStringMap[AbstractNumbering],
  numbering_style_num_ids : StableStringMap[String],
) -> StableStringMap[NumberingLinkResolution] {
  let resolved : StableStringMap[NumberingLinkResolution] = SortedMap([])
  // Every successful loop iteration consumes one previously unresolved
  // `` node and resolves the entire path on exit. This explicit budget
  // pins total graph work to O(number of numbering instances), including
  // cycles and links to missing ids.
  let mut remaining = nums.length()
  for start, _ in nums {
    if resolved.contains(start) {
      continue
    }
    let path : Array[String] = []
    let path_ids : StableStringSet = SortedSet([])
    let mut current = start
    let mut resolution = UnresolvableNumbering
    let mut done = false
    while !done {
      match resolved.get(current) {
        Some(cached) => {
          resolution = cached
          done = true
        }
        None =>
          if path_ids.contains(current) {
            resolution = UnresolvableNumbering
            done = true
          } else {
            match nums.get(current) {
              None => {
                resolution = UnresolvableNumbering
                done = true
              }
              Some(abstract_num_id) => {
                if remaining <= 0 {
                  resolution = UnresolvableNumbering
                  done = true
                  continue
                }
                remaining = remaining - 1
                path.push(current)
                path_ids.add(current)
                match abstract_nums.get(abstract_num_id) {
                  None => {
                    resolution = UnresolvableNumbering
                    done = true
                  }
                  Some(abstract_num) =>
                    match abstract_num.num_style_link {
                      None => {
                        resolution = ResolvedAbstractNumbering(abstract_num_id)
                        done = true
                      }
                      Some(style_id) =>
                        match numbering_style_num_ids.get(style_id) {
                          Some(linked_num_id) => current = linked_num_id
                          None => {
                            resolution = UnresolvableNumbering
                            done = true
                          }
                        }
                    }
                }
              }
            }
          }
      }
    }
    for num_id in path {
      resolved[num_id] = resolution
    }
  }
  resolved
}

///|
fn NumberingMap::find_level(
  self : NumberingMap,
  num_id : String,
  level : String,
) -> Numbering? {
  guard self.resolved_abstract_num_ids.get(num_id)
    is Some(ResolvedAbstractNumbering(abstract_num_id)) else {
    return None
  }
  guard self.abstract_nums.get(abstract_num_id) is Some(abstract_num) else {
    return None
  }
  match abstract_num.levels.get(level) {
    Some(numbering_level) => Some(numbering_level.to_numbering())
    None => None
  }
}

///|
fn NumberingMap::find_level_by_paragraph_style_id(
  self : NumberingMap,
  style_id : String,
) -> Numbering? {
  match self.levels_by_paragraph_style_id.get(style_id) {
    Some(level) => Some(level.to_numbering())
    None => None
  }
}

///|
fn NumberingLevel::to_numbering(self : NumberingLevel) -> Numbering {
  { is_ordered: self.is_ordered, level: self.level }
}

///|
fn read_notes_for_document(
  zip : ZipArchive,
  styles : StableStringMap[StyleInfo],
  numbering : NumberingMap,
  content_types : ContentTypes,
  footnotes_path~ : String,
  endnotes_path~ : String,
  external_files? : ExternalFileAccess = default_external_file_access(),
  read_images? : Bool = true,
  xml_budget? : @xml.XmlReadBudget,
  diagnostics? : ReaderDiagnosticCollector,
) -> NotesReadResult raise DocxError {
  let notes : Array[Note] = []
  let annotation_ids : Array[String?] = []
  let messages : Array[Message] = []
  let footnotes = read_note_part_at_path(
    zip,
    note_type="footnote",
    path=footnotes_path,
    styles~,
    numbering~,
    content_types~,
    external_files~,
    read_images~,
    xml_budget?,
    diagnostics?,
  )
  notes.append(footnotes.notes)
  annotation_ids.append(footnotes.annotation_ids)
  messages.append(footnotes.messages)
  let endnotes = read_note_part_at_path(
    zip,
    note_type="endnote",
    path=endnotes_path,
    styles~,
    numbering~,
    content_types~,
    external_files~,
    read_images~,
    xml_budget?,
    diagnostics?,
  )
  notes.append(endnotes.notes)
  annotation_ids.append(endnotes.annotation_ids)
  messages.append(endnotes.messages)
  { notes, annotation_ids, messages: @core.dedupe_messages(messages) }
}

///|
fn read_note_part_at_path(
  zip : ZipArchive,
  note_type~ : String,
  path~ : String,
  styles~ : StableStringMap[StyleInfo],
  numbering~ : NumberingMap,
  content_types~ : ContentTypes,
  external_files? : ExternalFileAccess = default_external_file_access(),
  read_images? : Bool = true,
  xml_budget? : @xml.XmlReadBudget,
  diagnostics? : ReaderDiagnosticCollector,
) -> NotesReadResult raise DocxError {
  if !zip.exists(path) {
    return { notes: [], annotation_ids: [], messages: [] }
  }
  let part_relationships = read_relationships(
    zip,
    relationships_path_for_part(zip, path),
    xml_budget?,
  )
  let reader = BodyReader::{
    styles,
    zip,
    relationship_index: RelationshipIndex::build(part_relationships),
    content_types,
    base_path: split_zip_path(zip.logical_path(path).unwrap_or(path)).0,
    numbering,
    messages: [],
    diagnostics,
    external_files,
    read_images,
  }
  let root = read_xml_part(zip, path, xml_budget?)
  let notes : Array[Note] = []
  let annotation_ids : Array[String?] = []
  for note_element in root.elements_by_tag_name("w:" + note_type) {
    let note_kind = note_element.attributes.get("w:type")
    // separator/continuationSeparator/continuationNotice are Word's
    // plumbing notes (rule glyphs and "continued" notices), not user
    // content — surfacing them would invent phantom notes.
    if note_kind != Some("separator") &&
      note_kind != Some("continuationSeparator") &&
      note_kind != Some("continuationNotice") {
      let annotation_id = note_element.attributes.get("w:id")
      notes.push({
        note_type,
        note_id: annotation_id_or_missing(annotation_id),
        body: reader.read_children(note_element.children),
      })
      annotation_ids.push(annotation_id)
    }
  }
  { notes, annotation_ids, messages: reader.messages }
}

///|
fn read_comments_for_document(
  zip : ZipArchive,
  styles : StableStringMap[StyleInfo],
  numbering : NumberingMap,
  content_types : ContentTypes,
  document_relationships : Relationships,
  base_path : String,
  external_files? : ExternalFileAccess = default_external_file_access(),
  read_images? : Bool = true,
  xml_budget? : @xml.XmlReadBudget,
  comments_part_path? : String,
  diagnostics? : ReaderDiagnosticCollector,
) -> CommentsReadResult raise DocxError {
  let authoritative = match comments_part_path {
    Some(path) => Some(path)
    None =>
      resolve_unique_internal_part(
        zip,
        document_relationships,
        [TRANSITIONAL_COMMENTS_RELATIONSHIP, STRICT_COMMENTS_RELATIONSHIP],
        base_path,
        "comments",
        reject_multiple_relationships=true,
      )
  }
  let path = authoritative.unwrap_or("word/comments.xml")
  if !zip.exists(path) {
    return { comments: [], annotation_ids: [], messages: [] }
  }
  let part_relationships = read_relationships(
    zip,
    relationships_path_for_part(zip, path),
    xml_budget?,
  )
  let reader = BodyReader::{
    styles,
    zip,
    relationship_index: RelationshipIndex::build(part_relationships),
    content_types,
    base_path: split_zip_path(zip.logical_path(path).unwrap_or(path)).0,
    numbering,
    messages: [],
    diagnostics,
    external_files,
    read_images,
  }
  let root = read_xml_part(zip, path, xml_budget?)
  let comments : Array[Comment] = []
  let annotation_ids : Array[String?] = []
  for comment_element in root.elements_by_tag_name("w:comment") {
    let annotation_id = comment_element.attributes.get("w:id")
    comments.push({
      comment_id: annotation_id_or_missing(annotation_id),
      body: reader.read_children(comment_element.children),
      author_name: read_optional_attribute(comment_element, "w:author"),
      author_initials: read_optional_attribute(comment_element, "w:initials"),
    })
    annotation_ids.push(annotation_id)
  }
  { comments, annotation_ids, messages: reader.messages }
}

///|
fn read_optional_attribute(element : XmlElement, name : String) -> String {
  match element.attributes.get(name) {
    Some(value) => {
      let trimmed = value.trim()
      if trimmed.is_empty() {
        ""
      } else {
        trimmed.to_owned()
      }
    }
    None => ""
  }
}

///|
fn read_styles_xml(root : XmlElement) -> StableStringMap[StyleInfo] {
  let styles : StableStringMap[StyleInfo] = SortedMap([])
  for style in root.elements_by_tag_name("w:style") {
    let style_id = style.attributes.get_or_default("w:styleId", "")
    if style_id != "" {
      let style_info = read_style_info(style, style_id)
      let key = style_lookup_key(style_info.style_type, style_id)
      if !styles.contains(key) {
        styles[key] = style_info
      }
    }
  }
  styles
}

///|
fn style_lookup_key(style_type : String, style_id : String) -> String {
  style_type + ":" + style_id
}

///|
fn StableStringMap::find_style(
  self : StableStringMap[StyleInfo],
  style_type : String,
  style_id : String,
) -> StyleInfo? {
  match self.get(style_lookup_key(style_type, style_id)) {
    Some(style) => Some(style)
    None =>
      match self.get(style_id) {
        Some(style) =>
          if style.style_type == style_type {
            Some(style)
          } else {
            None
          }
        None => None
      }
  }
}

///|
fn read_style_info(element : XmlElement, style_id : String) -> StyleInfo {
  let style_type = element.attributes.get_or_default("w:type", "")
  {
    style_id,
    style_type,
    name: if style_type == "numbering" {
      element
      .first_or_empty("w:pPr")
      .first_or_empty("w:numPr")
      .first_or_empty("w:numId").attributes.get_or_default("w:val", "")
    } else {
      element.first_or_empty("w:name").attributes.get_or_default("w:val", "")
    },
  }
}

///|
fn read_document_xml_with_messages(
  root : XmlElement,
  styles : StableStringMap[StyleInfo],
  numbering : NumberingMap,
  zip : ZipArchive,
  relationship_index : RelationshipIndex,
  content_types : ContentTypes,
  base_path : String,
  notes : Array[Note],
  comments : Array[Comment],
  external_files? : ExternalFileAccess = default_external_file_access(),
  read_images? : Bool = true,
  diagnostics? : ReaderDiagnosticCollector,
) -> DocxReadResult raise DocxError {
  let body = match root.first("w:body") {
    Some(body) => body
    None =>
      raise InvalidXml(
        message="Could not find the body element: are you sure this is a docx file?",
      )
  }
  let reader = BodyReader::{
    styles,
    zip,
    relationship_index,
    content_types,
    base_path,
    numbering,
    messages: [],
    diagnostics,
    external_files,
    read_images,
  }
  {
    document: document(reader.read_children(body.children), notes~, comments~),
    messages: reader.messages,
  }
}

///|
priv struct BodyReader {
  styles : StableStringMap[StyleInfo]
  zip : ZipArchive
  relationship_index : RelationshipIndex
  content_types : ContentTypes
  base_path : String
  numbering : NumberingMap
  messages : Array[Message]
  diagnostics : ReaderDiagnosticCollector?
  external_files : ExternalFileAccess
  read_images : Bool
}

///|
fn BodyReader::read_children(
  self : BodyReader,
  children : Array[XmlNode],
) -> Array[DocumentElement] raise DocxError {
  let elements : Array[DocumentElement] = []
  let deleted_paragraph_contents : Array[XmlNode] = []
  for child in children {
    match child {
      XmlElement(element) =>
        match element.name {
          "w:p" if is_deleted_paragraph(element) =>
            deleted_paragraph_contents.append(element.children)
          "w:p" if !deleted_paragraph_contents.is_empty() => {
            elements.append(
              self.read_paragraph_elements_with_prefix(
                element, deleted_paragraph_contents,
              ),
            )
            deleted_paragraph_contents.clear()
          }
          _ => elements.append(self.read_element(element))
        }
      XmlText(_) => ()
    }
  }
  elements
}

///|
fn BodyReader::read_element(
  self : BodyReader,
  element : XmlElement,
) -> Array[DocumentElement] raise DocxError {
  match element.name {
    "w:p" =>
      if is_deleted_paragraph(element) {
        []
      } else {
        self.read_paragraph_elements(element)
      }
    "w:r" => [self.read_run(element)]
    "w:t" => [text(element.text())]
    "w:tab" => [tab()]
    "w:noBreakHyphen" => [text("\u{2011}")]
    "w:softHyphen" => [text("\u{00AD}")]
    "w:sym" => self.read_symbol(element)
    "w:footnoteReference" =>
      [
        NoteReference(
          note_type="footnote",
          note_id=annotation_id_or_missing(element.attributes.get("w:id")),
        ),
      ]
    "w:endnoteReference" =>
      [
        NoteReference(
          note_type="endnote",
          note_id=annotation_id_or_missing(element.attributes.get("w:id")),
        ),
      ]
    "w:footnoteRef" | "w:endnoteRef" => []
    "w:commentReference" =>
      [
        CommentReference(
          annotation_id_or_missing(element.attributes.get("w:id")),
        ),
      ]
    "w:br" => self.read_break(element)
    "w:bookmarkStart" =>
      match element.attributes.get("w:name") {
        Some("_GoBack") => []
        None => [BookmarkStart("undefined")]
        Some(name) => [BookmarkStart(name)]
      }
    "w:hyperlink" => self.read_hyperlink(element)
    "w:drawing" | "w:object" => self.read_children(element.children)
    "w:pict" => []
    "mc:AlternateContent" =>
      self.read_children(element.first_or_empty("mc:Fallback").children)
    "wp:inline" | "wp:anchor" => self.read_drawing_element(element)
    "v:roundrect" | "v:shape" | "v:textbox" | "v:group" | "v:rect" =>
      self.read_children(element.children)
    "v:imagedata" => self.read_vml_image_data(element)
    "w:tbl" => [self.read_table(element)]
    "w:tr" => self.read_table_row(element)
    "w:tc" => [self.read_table_cell(element)]
    "w:ins" | "w:smartTag" | "w:sdtContent" =>
      self.read_children(element.children)
    "w:sdt" => self.read_structured_document_tag(element)
    _ =>
      if is_ignored_body_element(element.name) {
        []
      } else {
        self.warn_parts(["An unrecognised element was ignored: ", element.name])
        []
      }
  }
}

///|
fn BodyReader::warn(self : BodyReader, message : String) -> Unit {
  push_reader_message(self.messages, self.diagnostics, Warning(message))
}

///|
fn BodyReader::warn_parts(self : BodyReader, parts : ArrayView[String]) -> Unit {
  push_reader_warning_parts(self.messages, self.diagnostics, parts)
}

///|
fn BodyReader::error_parts(
  self : BodyReader,
  parts : ArrayView[String],
) -> Unit {
  push_reader_error_parts(self.messages, self.diagnostics, parts)
}

///|
fn is_ignored_body_element(name : String) -> Bool {
  name == "office-word:wrap" ||
  name == "v:shadow" ||
  name == "v:shapetype" ||
  name == "w:annotationRef" ||
  name == "w:bookmarkEnd" ||
  name == "w:sectPr" ||
  name == "w:proofErr" ||
  name == "w:lastRenderedPageBreak" ||
  name == "w:commentRangeStart" ||
  name == "w:commentRangeEnd" ||
  name == "w:del" ||
  name == "w:fldChar" ||
  name == "w:footnoteRef" ||
  name == "w:endnoteRef" ||
  name == "w:instrText" ||
  name == "w:txbxContent" ||
  name == "w:pPr" ||
  name == "w:rPr" ||
  name == "w:tblPr" ||
  name == "w:tblGrid" ||
  name == "w:trPr" ||
  name == "w:tcPr"
}

///|
fn BodyReader::read_symbol(
  self : BodyReader,
  element : XmlElement,
) -> Array[DocumentElement] {
  ignore(self)
  let font_attribute = element.attributes.get("w:font")
  let char_attribute = element.attributes.get("w:char")
  let font = attribute_or_empty(font_attribute)
  let char = attribute_or_empty(char_attribute)
  match symbol_to_unicode(font, char) {
    Some(value) => [text(value)]
    None => {
      self.warn_parts([
        "A w:sym element with an unsupported character was ignored: char ",
        attribute_or_js_undefined(char_attribute),
        " in font ",
        attribute_or_js_undefined(font_attribute),
      ])
      []
    }
  }
}

///|
fn attribute_or_empty(value : String?) -> String {
  match value {
    Some(value) => value
    None => ""
  }
}

///|
fn attribute_or_js_undefined(value : String?) -> String {
  match value {
    Some(value) => value
    None => "undefined"
  }
}

///|
fn symbol_to_unicode(font : String, char : String) -> String? {
  match parse_dingbat_hex(char) {
    Some(code) =>
      match dingbat_to_unicode(font, code) {
        Some(value) => Some(value)
        None =>
          if char.length() == 4 && char.has_prefix("F0") {
            match parse_dingbat_hex(char[2:].to_owned()) {
              Some(shifted_code) => dingbat_to_unicode(font, shifted_code)
              None => None
            }
          } else {
            None
          }
      }
    None => None
  }
}

///|
fn parse_dingbat_hex(value : String) -> Int? {
  let mut index = 0
  while index < value.length() && is_field_whitespace(value[index]) {
    index += 1
  }
  let start = index
  let mut result = 0
  while index < value.length() {
    match hex_value(value[index]) {
      Some(digit) => {
        result = result * 16 + digit
        index += 1
      }
      None => break
    }
  }
  if index == start {
    None
  } else {
    Some(result)
  }
}

///|
fn is_deleted_paragraph(element : XmlElement) -> Bool {
  element.first_or_empty("w:pPr").first_or_empty("w:rPr").first("w:del") != None
}

///|
fn BodyReader::read_paragraph_elements(
  self : BodyReader,
  element : XmlElement,
) -> Array[DocumentElement] raise DocxError {
  self.read_paragraph_elements_with_prefix(element, [])
}

///|
fn BodyReader::read_paragraph_elements_with_prefix(
  self : BodyReader,
  element : XmlElement,
  prefix : Array[XmlNode],
) -> Array[DocumentElement] raise DocxError {
  let properties = self.read_paragraph_properties(
    element.first_or_empty("w:pPr"),
  )
  let elements = [
    paragraph(
      self.read_paragraph_children(prefix + element.children),
      properties~,
    ),
  ]
  elements.append(self.read_paragraph_extra_elements(element))
  elements
}

///|
priv enum ComplexField {
  ComplexFieldBegin(checkbox_checked~ : Bool?)
  ComplexHyperlink(href~ : String?, anchor~ : String?)
  ComplexCheckbox(Bool)
  ComplexFieldUnknown
}

///|
fn BodyReader::read_paragraph_children(
  self : BodyReader,
  children : Array[XmlNode],
) -> Array[DocumentElement] raise DocxError {
  let elements : Array[DocumentElement] = []
  let stack : Array[ComplexField] = []
  let mut instruction_text = ""
  for child in children {
    match child {
      XmlElement(element) => {
        instruction_text += collect_instruction_text(element)
        match read_field_char_type(element) {
          Some("begin") => {
            stack.push(
              ComplexFieldBegin(
                checkbox_checked=read_complex_field_checkbox_checked(element),
              ),
            )
            instruction_text = ""
            elements.append(self.read_element(element))
          }
          Some("separate") => {
            match stack.pop() {
              Some(ComplexFieldBegin(checkbox_checked~)) =>
                stack.push(
                  parse_complex_field_instruction(
                    instruction_text, checkbox_checked,
                  ),
                )
              Some(field) => stack.push(field)
              None => ()
            }
            instruction_text = ""
            elements.append(self.read_element(element))
          }
          Some("end") => {
            match stack.pop() {
              Some(ComplexFieldBegin(checkbox_checked~)) =>
                match
                  parse_complex_field_instruction(
                    instruction_text, checkbox_checked,
                  ) {
                  ComplexCheckbox(checked) =>
                    elements.push(run([checkbox(checked)]))
                  _ => ()
                }
              Some(ComplexCheckbox(checked)) =>
                elements.push(run([checkbox(checked)]))
              Some(_) => ()
              None => ()
            }
            instruction_text = ""
            elements.append(self.read_element(element))
          }
          _ =>
            if element.name != "w:instrText" {
              elements.append(
                wrap_with_current_hyperlink(stack, self.read_element(element)),
              )
            }
        }
      }
      XmlText(_) => ()
    }
  }
  elements
}

///|
fn collect_instruction_text(element : XmlElement) -> String raise DocxError {
  let builder = StringBuilder()
  if element.name == "w:instrText" {
    builder.write_string(element.text())
  }
  for child in element.children {
    match child {
      XmlElement(child_element) =>
        builder.write_string(collect_instruction_text(child_element))
      XmlText(_) => ()
    }
  }
  builder.to_string()
}

///|
fn read_field_char_type(element : XmlElement) -> String? {
  if element.name == "w:fldChar" {
    return element.attributes.get("w:fldCharType")
  }
  for child in element.children {
    match child {
      XmlElement(child_element) =>
        match read_field_char_type(child_element) {
          Some(value) => return Some(value)
          None => ()
        }
      XmlText(_) => ()
    }
  }
  None
}

///|
fn read_complex_field_checkbox_checked(element : XmlElement) -> Bool? {
  match first_descendant_or_self(element, "w:fldChar") {
    Some(field_char) =>
      match field_char.first_or_empty("w:ffData").first("w:checkBox") {
        Some(checkbox_element) =>
          match checkbox_element.first("w:checked") {
            Some(checked_element) =>
              Some(read_boolean_element(Some(checked_element)))
            None =>
              Some(read_boolean_element(checkbox_element.first("w:default")))
          }
        None => None
      }
    None => None
  }
}

///|
fn first_descendant_or_self(element : XmlElement, name : String) -> XmlElement? {
  if element.name == name {
    return Some(element)
  }
  for child in element.children {
    match child {
      XmlElement(child_element) =>
        match first_descendant_or_self(child_element, name) {
          Some(result) => return Some(result)
          None => ()
        }
      XmlText(_) => ()
    }
  }
  None
}

///|
fn parse_complex_field_instruction(
  instruction_text : String,
  checkbox_checked : Bool?,
) -> ComplexField {
  match parse_hyperlink_field_instruction(instruction_text) {
    Some(field) => field
    None =>
      parse_non_hyperlink_field_instruction(instruction_text, checkbox_checked)
  }
}

///|
fn parse_non_hyperlink_field_instruction(
  instruction_text : String,
  checkbox_checked : Bool?,
) -> ComplexField {
  if instruction_text.contains("FORMCHECKBOX") {
    ComplexCheckbox(checkbox_checked.unwrap_or(false))
  } else {
    ComplexFieldUnknown
  }
}

///|
fn parse_hyperlink_field_instruction(
  instruction_text : String,
) -> ComplexField? {
  let keyword = "HYPERLINK"
  let mut index = skip_field_whitespace(instruction_text, 0)
  if !field_starts_with_at(instruction_text, index, keyword) {
    None
  } else {
    index = index + keyword.length()
    if index >= instruction_text.length() ||
      !is_field_whitespace(instruction_text[index]) {
      return None
    }
    index = skip_field_whitespace(instruction_text, index)
    let mut is_internal_link = false
    if field_starts_with_at(instruction_text, index, "\\l") {
      let after_switch = index + "\\l".length()
      if after_switch < instruction_text.length() &&
        is_field_whitespace(instruction_text[after_switch]) {
        is_internal_link = true
        index = skip_field_whitespace(instruction_text, after_switch)
      }
    }
    match read_field_location(instruction_text, index) {
      Some(location) =>
        if is_internal_link {
          Some(ComplexHyperlink(href=None, anchor=Some(location)))
        } else {
          Some(ComplexHyperlink(href=Some(location), anchor=None))
        }
      None => None
    }
  }
}

///|
fn read_field_location(value : String, index : Int) -> String? {
  if index >= value.length() {
    None
  } else if value[index] == '"' {
    let tail = value[index + 1:]
    match tail.rev_find("\"") {
      Some(last_quote) =>
        Some(value[index + 1:index + 1 + last_quote].to_owned())
      None => read_unquoted_field_location(value, index)
    }
  } else {
    read_unquoted_field_location(value, index)
  }
}

///|
fn read_unquoted_field_location(value : String, index : Int) -> String? {
  if index >= value.length() || value[index] == '\\' {
    None
  } else {
    Some(read_until_field_whitespace(value, index))
  }
}

///|
fn read_until_field_whitespace(value : String, start : Int) -> String {
  for index in start.. Int {
  let mut index = start
  while index < value.length() && is_field_whitespace(value[index]) {
    index = index + 1
  }
  index
}

///|
fn is_field_whitespace(char : UInt16) -> Bool {
  char is (' ' | '\t' | '\n' | '\r')
}

///|
fn field_starts_with_at(value : String, index : Int, prefix : String) -> Bool {
  index + prefix.length() <= value.length() &&
  value[index:index + prefix.length()].to_owned() == prefix
}

///|
fn wrap_with_current_hyperlink(
  stack : Array[ComplexField],
  elements : Array[DocumentElement],
) -> Array[DocumentElement] {
  match current_hyperlink_field(stack) {
    Some((href, anchor)) => {
      let wrapped : Array[DocumentElement] = []
      for element in elements {
        wrapped.push(wrap_element_with_hyperlink(element, href, anchor))
      }
      wrapped
    }
    None => elements
  }
}

///|
fn current_hyperlink_field(stack : Array[ComplexField]) -> (String?, String?)? {
  for index = stack.length() - 1; index >= 0; index = index - 1 {
    match stack[index] {
      ComplexHyperlink(href~, anchor~) => return Some((href, anchor))
      _ => ()
    }
  }
  None
}

///|
fn wrap_element_with_hyperlink(
  element : DocumentElement,
  href : String?,
  anchor : String?,
) -> DocumentElement {
  match element {
    Run(children~, properties~) =>
      Run(
        children=[Hyperlink(children~, href~, anchor~, target_frame=None)],
        properties~,
      )
    _ => Hyperlink(children=[element], href~, anchor~, target_frame=None)
  }
}

///|
fn BodyReader::read_text_box_elements(
  self : BodyReader,
  element : XmlElement,
) -> Array[DocumentElement] raise DocxError {
  match element.name {
    "mc:AlternateContent" =>
      self.read_text_box_elements(element.first_or_empty("mc:Fallback"))
    "w:txbxContent" => self.read_children(element.children)
    _ => {
      let elements : Array[DocumentElement] = []
      for child in element.children {
        match child {
          XmlElement(child_element) =>
            elements.append(self.read_text_box_elements(child_element))
          XmlText(_) => ()
        }
      }
      elements
    }
  }
}

///|
fn BodyReader::read_paragraph_extra_elements(
  self : BodyReader,
  element : XmlElement,
) -> Array[DocumentElement] raise DocxError {
  let elements : Array[DocumentElement] = []
  for child in element.children {
    match child {
      XmlElement(child_element) =>
        match child_element.name {
          "w:pict" =>
            elements.append(self.read_pict_extra_elements(child_element))
          "mc:AlternateContent" =>
            elements.append(self.read_text_box_elements(child_element))
          "w:txbxContent" =>
            elements.append(self.read_text_box_elements(child_element))
          _ =>
            elements.append(self.read_paragraph_extra_elements(child_element))
        }
      XmlText(_) => ()
    }
  }
  elements
}

///|
fn BodyReader::read_pict_extra_elements(
  self : BodyReader,
  element : XmlElement,
) -> Array[DocumentElement] raise DocxError {
  let elements : Array[DocumentElement] = []
  for child in element.children {
    match child {
      XmlElement(child_element) =>
        match child_element.name {
          "mc:AlternateContent" =>
            elements.append(
              self.read_pict_extra_elements(
                child_element.first_or_empty("mc:Fallback"),
              ),
            )
          "w:txbxContent" =>
            elements.append(self.read_children(child_element.children))
          "w:pict"
          | "v:roundrect"
          | "v:shape"
          | "v:textbox"
          | "v:group"
          | "v:rect" =>
            elements.append(self.read_pict_extra_elements(child_element))
          _ => elements.append(self.read_element(child_element))
        }
      XmlText(_) => ()
    }
  }
  elements
}

///|
fn BodyReader::read_structured_document_tag(
  self : BodyReader,
  element : XmlElement,
) -> Array[DocumentElement] raise DocxError {
  let content = self.read_children(
    element.first_or_empty("w:sdtContent").children,
  )
  match read_structured_checkbox(element.first_or_empty("w:sdtPr")) {
    Some(checked) => {
      let (replaced, found) = replace_first_text_with_checkbox(content, checked)
      if found {
        replaced
      } else {
        [checkbox(checked)]
      }
    }
    None => content
  }
}

///|
fn read_structured_checkbox(element : XmlElement) -> Bool? {
  match element.first("w14:checkbox") {
    Some(checkbox_element) =>
      match checkbox_element.first("w14:checked") {
        Some(checked_element) =>
          Some(
            read_boolean_attribute_value(
              checked_element.attributes.get("w14:val"),
            ),
          )
        None => Some(false)
      }
    None => None
  }
}

///|
fn read_boolean_attribute_value(value : String?) -> Bool {
  value != Some("false") && value != Some("0")
}

///|
fn replace_first_text_with_checkbox(
  elements : Array[DocumentElement],
  checked : Bool,
) -> (Array[DocumentElement], Bool) {
  let replaced : Array[DocumentElement] = []
  let mut found = false
  for element in elements {
    if found {
      replaced.push(element)
    } else {
      let (next, next_found) = replace_first_text_in_element(element, checked)
      replaced.push(next)
      if next_found {
        found = true
      }
    }
  }
  (replaced, found)
}

///|
fn replace_first_text_in_element(
  element : DocumentElement,
  checked : Bool,
) -> (DocumentElement, Bool) {
  match element {
    Text(value) =>
      if value.length() > 0 {
        (checkbox(checked), true)
      } else {
        (element, false)
      }
    Document(children~, notes~, comments~) => {
      let (new_children, found) = replace_first_text_with_checkbox(
        children, checked,
      )
      (Document(children=new_children, notes~, comments~), found)
    }
    Paragraph(children~, properties~) => {
      let (new_children, found) = replace_first_text_with_checkbox(
        children, checked,
      )
      (Paragraph(children=new_children, properties~), found)
    }
    Run(children~, properties~) => {
      let (new_children, found) = replace_first_text_with_checkbox(
        children, checked,
      )
      (Run(children=new_children, properties~), found)
    }
    Hyperlink(children~, href~, anchor~, target_frame~) => {
      let (new_children, found) = replace_first_text_with_checkbox(
        children, checked,
      )
      (Hyperlink(children=new_children, href~, anchor~, target_frame~), found)
    }
    Table(children~, properties~) => {
      let (new_children, found) = replace_first_text_with_checkbox(
        children, checked,
      )
      (Table(children=new_children, properties~), found)
    }
    TableRow(children~, is_header~) => {
      let (new_children, found) = replace_first_text_with_checkbox(
        children, checked,
      )
      (TableRow(children=new_children, is_header~), found)
    }
    TableCell(children~, col_span~, row_span~) => {
      let (new_children, found) = replace_first_text_with_checkbox(
        children, checked,
      )
      (TableCell(children=new_children, col_span~, row_span~), found)
    }
    _ => (element, false)
  }
}

///|
fn BodyReader::read_paragraph_properties(
  self : BodyReader,
  element : XmlElement,
) -> ParagraphProperties {
  let style_id = non_empty_attribute(
    element.first_or_empty("w:pStyle"),
    "w:val",
  )
  let style_name = self.style_name(style_id, "paragraph")
  {
    style_id,
    style_name,
    numbering: self.read_numbering(style_id, element.first_or_empty("w:numPr")),
    alignment: non_empty_attribute(element.first_or_empty("w:jc"), "w:val"),
    indent: self.read_indent(element.first_or_empty("w:ind")),
  }
}

///|
fn BodyReader::read_indent(self : BodyReader, element : XmlElement) -> Indent {
  ignore(self)
  {
    start: first_some(
      non_empty_attribute(element, "w:start"),
      non_empty_attribute(element, "w:left"),
    ),
    end: first_some(
      non_empty_attribute(element, "w:end"),
      non_empty_attribute(element, "w:right"),
    ),
    first_line: non_empty_attribute(element, "w:firstLine"),
    hanging: non_empty_attribute(element, "w:hanging"),
  }
}

///|
fn BodyReader::read_numbering(
  self : BodyReader,
  style_id : String?,
  element : XmlElement,
) -> Numbering? {
  let level = element.first_or_empty("w:ilvl").attributes.get("w:val")
  let num_id = element.first_or_empty("w:numId").attributes.get("w:val")
  match (level, num_id) {
    (Some(level), Some(num_id)) => self.numbering.find_level(num_id, level)
    _ =>
      match style_id {
        Some(style_id) =>
          match self.numbering.find_level_by_paragraph_style_id(style_id) {
            Some(numbering) => Some(numbering)
            None =>
              match num_id {
                Some(num_id) => self.numbering.find_level(num_id, "0")
                None => None
              }
          }
        None =>
          match num_id {
            Some(num_id) => self.numbering.find_level(num_id, "0")
            None => None
          }
      }
  }
}

///|
fn BodyReader::read_run(
  self : BodyReader,
  element : XmlElement,
) -> DocumentElement raise DocxError {
  let properties = self.read_run_properties(element.first_or_empty("w:rPr"))
  run(self.read_children(element.children), properties~)
}

///|
fn BodyReader::read_run_properties(
  self : BodyReader,
  element : XmlElement,
) -> RunProperties {
  let style_id = non_empty_attribute(
    element.first_or_empty("w:rStyle"),
    "w:val",
  )
  let style_name = self.style_name(style_id, "character")
  {
    style_id,
    style_name,
    is_bold: read_boolean_element(element.first("w:b")),
    is_underline: read_underline(element.first("w:u")),
    is_italic: read_boolean_element(element.first("w:i")),
    is_strikethrough: read_boolean_element(element.first("w:strike")),
    is_all_caps: read_boolean_element(element.first("w:caps")),
    is_small_caps: read_boolean_element(element.first("w:smallCaps")),
    vertical_alignment: read_vertical_alignment(
      element.first_or_empty("w:vertAlign").attributes.get("w:val"),
    ),
    font: non_empty_attribute(element.first_or_empty("w:rFonts"), "w:ascii"),
    font_size: read_font_size(
      element.first_or_empty("w:sz").attributes.get("w:val"),
    ),
    highlight: read_highlight(
      element.first_or_empty("w:highlight").attributes.get("w:val"),
    ),
  }
}

///|
fn BodyReader::style_name(
  self : BodyReader,
  style_id : String?,
  style_type : String,
) -> String? {
  match style_id {
    Some(id) =>
      match self.styles.find_style(style_type, id) {
        Some(style) => if style.name == "" { None } else { Some(style.name) }
        None => {
          self.warn_undefined_style(style_type, id)
          None
        }
      }
    None => None
  }
}

///|
fn BodyReader::warn_undefined_style(
  self : BodyReader,
  style_type : String,
  style_id : String,
) -> Unit {
  self.warn_parts([
    style_warning_label(style_type),
    " style with ID ",
    style_id,
    " was referenced but not defined in the document",
  ])
}

///|
fn style_warning_label(style_type : String) -> String {
  match style_type {
    "paragraph" => "Paragraph"
    "character" => "Run"
    "table" => "Table"
    _ => style_type
  }
}

///|
fn read_boolean_element(element : XmlElement?) -> Bool {
  match element {
    Some(element) => {
      let value = element.attributes.get("w:val")
      value != Some("false") && value != Some("0")
    }
    None => false
  }
}

///|
fn read_underline(element : XmlElement?) -> Bool {
  match element {
    Some(element) => {
      let value = element.attributes.get("w:val")
      value != None &&
      value != Some("false") &&
      value != Some("0") &&
      value != Some("none")
    }
    None => false
  }
}

///|
fn read_vertical_alignment(value : String?) -> VerticalAlignment {
  match value {
    Some("superscript") => Superscript
    Some("subscript") => Subscript
    _ => Baseline
  }
}

///|
fn read_font_size(value : String?) -> Int? {
  match value {
    Some(text) =>
      if is_ascii_decimal_string(text) {
        try @string.parse_int(text) catch {
          _ => None
        } noraise {
          value => Some(value / 2)
        }
      } else {
        None
      }
    None => None
  }
}

///|
fn is_ascii_decimal_string(value : String) -> Bool {
  let mut is_decimal = value.length() > 0
  for index in 0.. 57 {
      is_decimal = false
    }
  }
  is_decimal
}

///|
fn[T] first_some(first : T?, second : T?) -> T? {
  match first {
    Some(_) => first
    None => second
  }
}

///|
fn read_highlight(value : String?) -> String? {
  match value {
    Some("") | Some("none") | None => None
    Some(value) => Some(value)
  }
}

///|
fn BodyReader::read_break(
  self : BodyReader,
  element : XmlElement,
) -> Array[DocumentElement] {
  match element.attributes.get("w:type") {
    None | Some("textWrapping") => [line_break()]
    Some("page") => [page_break()]
    Some("column") => [column_break()]
    Some(break_type) => {
      self.warn_parts(["Unsupported break type: ", break_type])
      []
    }
  }
}

///|
fn BodyReader::read_hyperlink(
  self : BodyReader,
  element : XmlElement,
) -> Array[DocumentElement] raise DocxError {
  let target_frame = non_empty_attribute(element, "w:tgtFrame")
  let anchor = non_empty_attribute(element, "w:anchor")
  match non_empty_attribute(element, "r:id") {
    Some(id) => {
      let href = match self.relationship_index.find_unique_target_by_id(id) {
        Some(target) =>
          match anchor {
            Some(anchor) => Some(replace_url_fragment(target, anchor))
            None => Some(target)
          }
        None => None
      }
      [
        Hyperlink(
          children=self.read_children(element.children),
          href~,
          anchor=None,
          target_frame~,
        ),
      ]
    }
    None =>
      match anchor {
        Some(anchor) =>
          [
            Hyperlink(
              children=self.read_children(element.children),
              href=None,
              anchor=Some(anchor),
              target_frame~,
            ),
          ]
        None => self.read_children(element.children)
      }
  }
}

///|
fn replace_url_fragment(href : String, fragment : String) -> String {
  let base = match href.find("#") {
    Some(index) => href[:index].to_owned()
    None => href
  }
  base + "#" + fragment
}

///|
fn non_empty_attribute(element : XmlElement, name : String) -> String? {
  match element.attributes.get(name) {
    Some(value) => if value == "" { None } else { Some(value) }
    None => None
  }
}

///|
fn BodyReader::read_drawing_element(
  self : BodyReader,
  element : XmlElement,
) -> Array[DocumentElement] {
  let results : Array[DocumentElement] = []
  for blip in element.drawing_blips() {
    match self.read_blip(element, blip) {
      Some(image) =>
        results.push(self.wrap_image_with_drawing_hyperlink(element, image))
      None => ()
    }
  }
  results
}

///|
fn XmlElement::drawing_blips(self : XmlElement) -> Array[XmlElement] {
  let blips : Array[XmlElement] = []
  for graphic in self.elements_by_tag_name("a:graphic") {
    for graphic_data in graphic.elements_by_tag_name("a:graphicData") {
      for picture in graphic_data.elements_by_tag_name("pic:pic") {
        for fill in picture.elements_by_tag_name("pic:blipFill") {
          blips.append(fill.elements_by_tag_name("a:blip"))
        }
      }
    }
  }
  blips
}

///|
fn BodyReader::wrap_image_with_drawing_hyperlink(
  self : BodyReader,
  drawing_element : XmlElement,
  image : Image,
) -> DocumentElement {
  match self.read_drawing_hyperlink(drawing_element) {
    Some(href) =>
      Hyperlink(
        children=[Image(image)],
        href=Some(href),
        anchor=None,
        target_frame=None,
      )
    None => Image(image)
  }
}

///|
fn BodyReader::read_drawing_hyperlink(
  self : BodyReader,
  drawing_element : XmlElement,
) -> String? {
  for
    link in drawing_element
    .first_or_empty("wp:docPr")
    .elements_by_tag_name("a:hlinkClick") {
    match non_empty_attribute(link, "r:id") {
      Some(id) =>
        match self.relationship_index.find_unique_target_by_id(id) {
          Some(target) => return Some(target)
          None => ()
        }
      None => ()
    }
  }
  None
}

///|
fn BodyReader::read_blip(
  self : BodyReader,
  drawing_element : XmlElement,
  blip : XmlElement,
) -> Image? {
  if !self.read_images {
    return None
  }
  let diagnostic_count = self.diagnostic_count()
  let image = match non_empty_attribute(blip, "r:embed") {
    Some(id) =>
      self.read_image_by_relationship(
        id,
        alt_text=read_drawing_alt_text(drawing_element),
      )
    None =>
      match non_empty_attribute(blip, "r:link") {
        Some(id) =>
          self.read_linked_image_by_relationship(
            id,
            alt_text=read_drawing_alt_text(drawing_element),
          )
        None => None
      }
  }
  match image {
    Some(image) => Some(image)
    None => {
      if self.diagnostic_count() == diagnostic_count {
        self.warn("Could not find image file for a:blip element")
      }
      None
    }
  }
}

///|
fn BodyReader::diagnostic_count(self : BodyReader) -> Int {
  let collected = match self.diagnostics {
    Some(value) => value.received()
    None => 0
  }
  self.messages.length() + collected
}

///|
fn BodyReader::read_vml_image_data(
  self : BodyReader,
  element : XmlElement,
) -> Array[DocumentElement] {
  if !self.read_images {
    return []
  }
  match non_empty_attribute(element, "r:id") {
    Some(id) =>
      match
        self.read_image_by_relationship(
          id,
          alt_text=element.attributes.get("o:title"),
        ) {
        Some(image) => [Image(image)]
        None => []
      }
    None => {
      self.warn("A v:imagedata element without a relationship ID was ignored")
      []
    }
  }
}

///|
fn BodyReader::read_image_by_relationship(
  self : BodyReader,
  relationship_id : String,
  alt_text~ : String?,
) -> Image? {
  if !self.read_images {
    return None
  }
  match self.relationship_index.find_unique_by_id(relationship_id) {
    Some(rel) =>
      match resolve_part_target(self.base_path, rel.target) {
        Some(path) =>
          match self.zip.read_bytes(path) {
            Some(data) => Some(self.make_image(path, data, alt_text~))
            None => None
          }
        None => None
      }
    None => None
  }
}

///|
fn BodyReader::read_linked_image_by_relationship(
  self : BodyReader,
  relationship_id : String,
  alt_text~ : String?,
) -> Image? {
  if !self.read_images {
    return None
  }
  match self.relationship_index.find_unique_by_id(relationship_id) {
    Some(rel) => self.read_external_image(rel.target, alt_text~)
    None => None
  }
}

///|
fn BodyReader::read_external_image(
  self : BodyReader,
  target : String,
  alt_text~ : String?,
) -> Image? {
  if !self.external_files.enabled {
    self.error_parts([
      "could not read external image '", target, "', external file access is disabled",
    ])
    return None
  }
  for path in external_file_candidate_paths(target) {
    match (self.external_files.read_file)(path) {
      Some(data) => return Some(self.make_image(path, data, alt_text~))
      None => ()
    }
  }
  self.error_parts([
    "could not find external image '", target, "', path of input document is unknown",
  ])
  None
}

///|
fn external_file_candidate_paths(target : String) -> Array[String] {
  let paths = [target]
  match external_uri_to_path(target) {
    Some(path) => if path != target { paths.push(path) }
    None => ()
  }
  paths
}

///|
fn external_uri_to_path(uri : String, platform? : String = "") -> String? {
  let raw_path = if uri.has_prefix("file://") {
    local_file_uri_path(uri)
  } else if is_relative_uri(uri) {
    Some(strip_uri_fragment(uri))
  } else {
    None
  }
  match raw_path {
    Some(path) =>
      match percent_decode_uri_path(path) {
        Some(decoded) =>
          if platform == "win32" && is_windows_file_uri_path(decoded) {
            Some(decoded[1:].to_owned())
          } else {
            Some(decoded)
          }
        None => None
      }
    None => None
  }
}

///|
fn local_file_uri_path(uri : String) -> String? {
  let rest = strip_uri_fragment(uri)["file://".length():]
  if rest.has_prefix("/") {
    Some(rest.to_owned())
  } else if rest.has_prefix("?") {
    Some(rest.to_owned())
  } else if rest.has_prefix("localhost?") {
    Some("/" + rest["localhost".length():].to_owned())
  } else {
    match rest.find("/") {
      Some(slash) => {
        let host = rest[:slash].to_owned()
        if host == "" || host == "localhost" {
          Some(rest[slash:].to_owned())
        } else {
          None
        }
      }
      None =>
        if rest == "" {
          Some("null")
        } else if rest == "localhost" {
          Some("/")
        } else {
          None
        }
    }
  }
}

///|
fn is_relative_uri(uri : String) -> Bool {
  find_uri_scheme_colon(uri) == None
}

///|
fn strip_uri_fragment(uri : String) -> String {
  match uri.find("#") {
    Some(index) => uri[:index].to_owned()
    None => uri
  }
}

///|
fn find_uri_scheme_colon(uri : String) -> Int? {
  if uri.length() == 0 || !is_uri_scheme_start(uri[0]) {
    return None
  }
  for index in 1.. return Some(index)
      '/' | '\\' | '?' | '#' => return None
      char => if !is_uri_scheme_char(char) { return None }
    }
  }
  None
}

///|
fn is_uri_scheme_start(char : UInt16) -> Bool {
  char is ('A'..='Z' | 'a'..='z')
}

///|
fn is_uri_scheme_char(char : UInt16) -> Bool {
  is_uri_scheme_start(char) || char is ('0'..='9' | '+' | '-' | '.')
}

///|
fn is_windows_file_uri_path(path : String) -> Bool {
  path.length() >= 3 &&
  path[0] == '/' &&
  is_uri_scheme_start(path[1]) &&
  path[2] == ':'
}

///|
fn percent_decode_uri_path(path : String) -> String? {
  let bytes : Array[Byte] = []
  let mut index = 0
  while index < path.length() {
    if index + 2 < path.length() && path[index] == '%' {
      match (hex_value(path[index + 1]), hex_value(path[index + 2])) {
        (Some(high), Some(low)) => {
          bytes.push((high * 16 + low).to_byte())
          index += 3
          continue
        }
        _ => return None
      }
    } else if path[index] == '%' {
      return None
    }
    bytes.push(path[index].to_int().to_byte())
    index += 1
  }
  try @utf8.decode(Bytes::from_array(bytes)) catch {
    _ => None
  } noraise {
    value => Some(value)
  }
}

///|
fn hex_value(char : UInt16) -> Int? {
  match char {
    '0'..='9' => Some(char.to_int() - '0'.to_int())
    'A'..='F' => Some(char.to_int() - 'A'.to_int() + 10)
    'a'..='f' => Some(char.to_int() - 'a'.to_int() + 10)
    _ => None
  }
}

///|
fn BodyReader::make_image(
  self : BodyReader,
  path : String,
  data : BytesView,
  alt_text~ : String?,
) -> Image {
  let content_type = self.content_types.find_content_type(path, zip=self.zip)
  if !is_supported_web_image_type(content_type) {
    self.warn_parts([
      "Image of type ", content_type, " is unlikely to display in web browsers",
    ])
  }
  { content_type, alt_text, data: data.to_owned() }
}

///|
fn is_supported_web_image_type(content_type : String) -> Bool {
  content_type == "image/png" ||
  content_type == "image/gif" ||
  content_type == "image/jpeg" ||
  content_type == "image/svg+xml" ||
  content_type == "image/tiff"
}

///|
fn read_drawing_alt_text(element : XmlElement) -> String? {
  let properties = element.first_or_empty("wp:docPr")
  match properties.attributes.get("descr") {
    Some(value) =>
      if value.trim().is_empty() {
        properties.attributes.get("title")
      } else {
        Some(value)
      }
    None => properties.attributes.get("title")
  }
}

///|
fn resolve_part_target(base_path : String, target : String) -> String? {
  @opc.resolve_part_target(base_path, target)
}

///|
fn BodyReader::read_table(
  self : BodyReader,
  element : XmlElement,
) -> DocumentElement raise DocxError {
  Table(
    children=self.calculate_table_row_spans(
      self.read_children(element.children),
    ),
    properties=self.read_table_properties(element.first_or_empty("w:tblPr")),
  )
}

///|
fn BodyReader::read_table_properties(
  self : BodyReader,
  element : XmlElement,
) -> TableProperties {
  let style_id = non_empty_attribute(
    element.first_or_empty("w:tblStyle"),
    "w:val",
  )
  { style_id, style_name: self.style_name(style_id, "table") }
}

///|
fn BodyReader::read_table_row(
  self : BodyReader,
  element : XmlElement,
) -> Array[DocumentElement] raise DocxError {
  let properties = element.first_or_empty("w:trPr")
  if properties.first("w:del") != None {
    []
  } else {
    let is_header = properties.first("w:tblHeader") != None
    [TableRow(children=self.read_children(element.children), is_header~)]
  }
}

///|
fn BodyReader::read_table_cell(
  self : BodyReader,
  element : XmlElement,
) -> DocumentElement raise DocxError {
  let properties = element.first_or_empty("w:tcPr")
  let col_span = parse_grid_span(
    properties.first_or_empty("w:gridSpan").attributes.get_or_default(
      "w:val", "1",
    ),
  )
  TableCell(
    children=self.read_children(element.children),
    col_span~,
    row_span=if read_vertical_merge_continue(properties) { 0 } else { 1 },
  )
}

///|
fn parse_grid_span(value : String) -> Int {
  let mut index = 0
  while index < value.length() && is_field_whitespace(value[index]) {
    index += 1
  }
  let mut sign = 1
  if index < value.length() && (value[index] == '+' || value[index] == '-') {
    if value[index] == '-' {
      sign = -1
    }
    index += 1
  }
  let start = index
  let mut result = 0
  while index < value.length() {
    match decimal_digit_value(value[index]) {
      Some(digit) => {
        result = result * 10 + digit
        index += 1
      }
      None => break
    }
  }
  if index == start {
    1
  } else {
    sign * result
  }
}

///|
fn decimal_digit_value(char : UInt16) -> Int? {
  match char {
    '0'..='9' => Some(char.to_int() - '0'.to_int())
    _ => None
  }
}

///|
fn read_vertical_merge_continue(properties : XmlElement) -> Bool {
  match properties.first("w:vMerge") {
    Some(element) =>
      match element.attributes.get("w:val") {
        None | Some("continue") => true
        _ => false
      }
    None => false
  }
}

///|
fn BodyReader::calculate_table_row_spans(
  self : BodyReader,
  elements : Array[DocumentElement],
) -> Array[DocumentElement] {
  let rows : Array[(Array[DocumentElement], Bool)] = []
  for element in elements {
    match element {
      TableRow(children~, is_header~) => rows.push((children, is_header))
      _ => {
        self.warn(
          "unexpected non-row element in table, cell merging may be incorrect",
        )
        return elements
      }
    }
  }
  let typed_rows : Array[TableRowForSpan] = []
  for row in rows {
    let (children, is_header) = row
    let cells : Array[TableCellForSpan] = []
    for child in children {
      match child {
        TableCell(children=cell_children, col_span~, row_span~) =>
          cells.push({ children: cell_children, col_span, row_span })
        _ => {
          self.warn(
            "unexpected non-cell element in table row, cell merging may be incorrect",
          )
          return elements
        }
      }
    }
    typed_rows.push({ cells, is_header })
  }
  self.calculate_typed_table_row_spans(typed_rows)
}

///|
fn BodyReader::calculate_typed_table_row_spans(
  _self : BodyReader,
  elements : Array[TableRowForSpan],
) -> Array[DocumentElement] {
  let row_spans : Array[Array[Int]] = []
  let skip_cells : Array[Array[Bool]] = []
  for row in elements {
    let spans : Array[Int] = []
    let skips : Array[Bool] = []
    for _ in row.cells {
      spans.push(1)
      skips.push(false)
    }
    row_spans.push(spans)
    skip_cells.push(skips)
  }
  let open_cells : Map[Int, (Int, Int)] = Map([])
  for row_index in 0.. {
            row_spans[origin_row][origin_cell] = row_spans[origin_row][origin_cell] +
              1
            skip_cells[row_index][cell_index] = true
          }
          None => open_cells[column_index] = (row_index, cell_index)
        }
      } else {
        open_cells[column_index] = (row_index, cell_index)
      }
      column_index = column_index + cell.col_span
    }
  }
  let rows : Array[DocumentElement] = []
  for row_index in 0..