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@idtokens 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@idon 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@ridtargeting a single@id.



