How to Validate JATS Contrib Affiliations and Persistent IDs?

How to Validate JATS Contrib Affiliations and Persistent IDs?

In automated XML-first journal publishing pipelines, ingestion gateways frequently fail downstream validation during Crossref deposit or PubMed Central (PMC) ingestion due to malformed metadata within <contrib-group>. The structural breakdown manifests in two primary failure modes:

  • Referential Integrity Loss: Broken bidirectional relationships between <contrib contrib-type="author"> elements and institutional <aff> nodes. Mismatches between @id tokens and <xref ref-type="aff" rid="..."> pointer values generate orphan affiliations, dangling cross-references, and incorrect author-institution indexing.
  • Identifier Specification Non-Compliance: Malformed persistent identifiers (PIDs). Contributors’ ORCID identifiers and institutional Research Organization Registry (ROR) IDs are frequently encoded as legacy bare identifiers, unresolvable HTTP URIs, or non-canonical strings, violating ANSI/NISO Z39.96-2021 (JATS 1.3) and JATS4R (JATS for Reuse) persistent identifier recommendations.

According to JATS4R guidelines for Authors and Affiliations:

  • ORCIDs must be declared inside <contrib-id contrib-id-type="orcid"> using the canonical HTTPS URI syntax: [https://orcid.org/0000-0000-0000-0000](https://orcid.org/0000-0000-0000-0000).
  • Institutional ROR identifiers must reside within <institution-id institution-id-type="ror"> inside <institution-wrap>, formatted as an HTTPS URI: [https://ror.org/0xxxxxxNN](https://ror.org/0xxxxxxNN).
  • Every <xref ref-type="aff"> must resolve to an extant @id on an <aff> node within the same <article-meta> block. Conversely, every <aff> node (excepting single-affiliation unlinked institutional structures) must be referenced by at least one author to avoid orphan metadata.

Pipeline Architecture

The preflight and remediation engine functions in three sequential stages:

[Raw Ingestion JATS XML]
           │
           ▼
[Stage 1: Python CLI Attribute Normalizer]
  ├── Regex extraction of bare/malformed ORCIDs & RORs
  ├── URI canonicalization to https://
  └── Structural wrapping (<institution-wrap>)
           │
           ▼
[Stage 2: ISO Schematron Rule Engine]
  ├── Assert: Every xref[@ref-type='aff'] resolves to an aff[@id]
  ├── Assert: Every aff[@id] is targeted by at least one xref
  └── Assert: Regex validation on ORCID and ROR URI structures
           │
           ▼
[Stage 3: xmllint / DTD Preflight Validation]
  └── Schema-valid JATS 1.3 Production Artifact

Executable Implementation

The following production script, normalize_jats_contrib.py, standardizes malformed persistent identifiers and wraps raw institutional strings without breaking JATS 1.3 DTD validity.

#!/usr/bin/env python3
"""
normalize_jats_contrib.py
Standardize ORCID/ROR persistent identifiers and repair affiliations in JATS 1.3 XML.
"""

import re
import sys
from lxml import etree

ORCID_REGEX = re.compile(r'(?:https?://orcid\.org/)?([0-9]{4}-[0-9]{4}-[0-9]{4}-[0-9]{3}[0-9X])')
ROR_REGEX = re.compile(r'(?:https?://ror\.org/)?(0[a-hj-km-np-tv-z0-9]{6}[0-9]{2})')

def canonicalize_orcid(uri_or_id: str) -> str:
    match = ORCID_REGEX.search(uri_or_id.strip())
    if not match:
        raise ValueError(f"Invalid ORCID checksum string: {uri_or_id}")
    return f"https://orcid.org/{match.group(1)}"

def canonicalize_ror(uri_or_id: str) -> str:
    match = ROR_REGEX.search(uri_or_id.strip())
    if not match:
        raise ValueError(f"Invalid ROR checksum string: {uri_or_id}")
    return f"https://ror.org/{match.group(1)}"

def process_jats(input_path: str, output_path: str) -> None:
    parser = etree.XMLParser(remove_blank_text=False, resolve_entities=False)
    tree = etree.parse(input_path, parser)
    root = tree.getroot()

    # 1. Normalize Contributor ORCIDs
    for contrib_id in root.xpath("//contrib-group/contrib/contrib-id[@contrib-id-type='orcid']"):
        if contrib_id.text:
            try:
                contrib_id.text = canonicalize_orcid(contrib_id.text)
                contrib_id.attrib.pop('{http://www.w3.org/1999/xlink}href', None)
            except ValueError as err:
                sys.stderr.write(f"[WARN] {err}\n")

    # 2. Normalize and Wrap Institutional ROR IDs
    for aff in root.xpath("//article-meta//aff"):
        ror_ids = aff.xpath(".//institution-id[@institution-id-type='ror']")
        for ror in ror_ids:
            if ror.text:
                try:
                    ror.text = canonicalize_ror(ror.text)
                except ValueError as err:
                    sys.stderr.write(f"[WARN] {err}\n")
            
            # Enforce <institution-wrap> enclosure under JATS 1.3
            parent = ror.getparent()
            if parent.tag != "institution-wrap":
                wrap = etree.Element("institution-wrap")
                parent.insert(parent.index(ror), wrap)
                wrap.append(ror)
                # Reparent adjacent institution text nodes if matched
                inst_nodes = parent.xpath("./institution")
                for inst in inst_nodes:
                    wrap.append(inst)

    tree.write(
        output_path,
        encoding="utf-8",
        xml_declaration=True,
        pretty_print=False
    )

if __name__ == "__main__":
    if len(sys.argv) != 3:
        sys.exit(f"Usage: {sys.argv[0]} <input.xml> <output.xml>")
    process_jats(sys.argv[1], sys.argv[2])

To enforce structural and referential integrity prior to publication, the following ISO Schematron file (jats4r-contrib-rules.sch) compiles against the XML payload:

<?xml version="1.0" encoding="UTF-8"?>
<schema xmlns="http://purl.oclc.org/dsdl/schematron"
        queryBinding="xslt2">
  <title>JATS4R Contributor and Affiliation Validation</title>
  
  <ns prefix="xlink" uri="http://www.w3.org/1999/xlink"/>

  <pattern id="aff-referential-integrity">
    <!-- Rule 1: Every author xref pointing to an aff must find a target aff -->
    <rule context="contrib-group/contrib/xref[@ref-type='aff']">
      <assert test="@rid and (//article-meta/aff[@id = current()/@rid] or //contrib-group/aff[@id = current()/@rid])">
        FATAL: xref rid="<value-of select="@rid"/>" has no matching aff[@id] node in metadata.
      </assert>
    </rule>

    <!-- Rule 2: Every aff node must be linked by an xref, preventing orphan institutions -->
    <rule context="article-meta//aff[@id]">
      <let name="current_id" value="@id"/>
      <assert test="//contrib-group/contrib/xref[@ref-type='aff' and @rid = $current_id]">
        FATAL: Orphan affiliation found. aff id="<value-of select="$current_id"/>" is never referenced by an author xref.
      </assert>
    </rule>
  </pattern>

  <pattern id="persistent-identifiers">
    <!-- Rule 3: ORCID syntax must match canonical HTTPS format strictly -->
    <rule context="contrib-id[@contrib-id-type='orcid']">
      <assert test="matches(., '^https://orcid\.org/[0-9]{4}-[0-9]{4}-[0-9]{4}-[0-9]{3}[0-9X]$')">
        ERROR: ORCID (<value-of select="."/>) must match the canonical URI scheme: https://orcid.org/XXXX-XXXX-XXXX-XXXX.
      </assert>
    </rule>

    <!-- Rule 4: ROR syntax must match canonical HTTPS format strictly -->
    <rule context="institution-id[@institution-id-type='ror']">
      <assert test="matches(., '^https://ror\.org/0[a-hj-km-np-tv-z0-9]{6}[0-9]{2}$')">
        ERROR: ROR ID (<value-of select="."/>) must follow the HTTPS format: https://ror.org/0xxxxxxNN.
      </assert>
    </rule>
  </pattern>
</schema>

Here is a validated, canonical JATS 1.3 XML snippet passing all constraints:

<article xmlns:xlink="http://www.w3.org/1999/xlink" article-type="research-article" dtd-version="1.3">
  <front>
    <article-meta>
      <contrib-group>
        <contrib contrib-type="author" corresp="yes">
          <contrib-id contrib-id-type="orcid">https://orcid.org/0000-0002-1825-0097</contrib-id>
          <name>
            <surname>Vance</surname>
            <given-names>Elena</given-names>
          </name>
          <xref ref-type="aff" rid="aff1">1</xref>
        </contrib>
        <contrib contrib-type="author">
          <name>
            <surname>Chen</surname>
            <given-names>Marcus</given-names>
          </name>
          <xref ref-type="aff" rid="aff1">1</xref>
          <xref ref-type="aff" rid="aff2">2</xref>
        </contrib>
      </contrib-group>

      <aff id="aff1">
        <label>1</label>
        <institution-wrap>
          <institution-id institution-id-type="ror">https://ror.org/03vek6s52</institution-id>
          <institution>Department of Computer Science, University of Oxford</institution>
        </institution-wrap>
        <city>Oxford</city>
        <country country="GB">United Kingdom</country>
      </aff>

      <aff id="aff2">
        <label>2</label>
        <institution-wrap>
          <institution-id institution-id-type="ror">https://ror.org/013meh722</institution-id>
          <institution>Department of Applied Mathematics, University of Cambridge</institution>
        </institution-wrap>
        <city>Cambridge</city>
        <country country="GB">United Kingdom</country>
      </aff>
    </article-meta>
  </front>
</article>

Automated Validation

Execution and verification run via a shell harness chaining our Python cleaner, Jing (or an XSLT-based Schematron pipeline), and xmllint:

#!/usr/bin/env bash
set -euo pipefail

INPUT_XML="manuscript_raw.xml"
CLEAN_XML="manuscript_clean.xml"
SCHEMATRON_RULES="jats4r-contrib-rules.sch"
JATS_DTD="JATS-journalpublishing1-3-mathml3.dtd"

# 1. Run ID canonicalization pipeline
python3 normalize_jats_contrib.py "${INPUT_XML}" "${CLEAN_XML}"

# 2. Compile and assert Schematron constraints via Jing
echo "==> Running Schematron Assertion Engine..."
jing -c "${SCHEMATRON_RULES}" "${CLEAN_XML}"

# 3. Assert DTD compliance
echo "==> Running DTD Validation via xmllint..."
xmllint --noout --dtdvalid "${JATS_DTD}" "${CLEAN_XML}"

echo "STATUS: Artifact passes JATS 1.3 and JATS4R validation."

Production Gotchas

  • JATS DTD <institution-wrap> Ordering Trap: In JATS 1.2 and 1.3, <institution-id> must not sit bare alongside free text inside <aff> when mixed content mode is evaluated strictly by certain rendering parsers. Omitting <institution-wrap> causes parsing failure in picky ingest endpoints (e.g., PMC automated loaders).
  • Entity Encoding on ORCID Resolvers: Upstream author submission systems often convert [https://orcid.org/](https://orcid.org/) into URL-encoded equivalents or append query parameters (such as ?lang=en). Stripping parameters before writing to JATS is mandatory, as Crossref rejects any deposit containing non-standard ORCID URLs.
  • Multi-Affiliation Label Squashing: When authors share multiple affiliations, naive typesetters often output comma-delimited xref nodes (<xref ref-type="aff" rid="aff1,aff2">1,2</xref>). This completely breaks XML IDREF mechanics. Each reference MUST have its own individual <xref> element with an isolated @rid targeting a single @id.

You may also like

See All Posts →