<?xml version="1.0" encoding="utf-8"?><article xmlns="http://docbook.org/ns/docbook" xmlns:xlink="http://www.w3.org/1999/xlink" version="5.0-subset Balisage-1.5">
    <title>Beyond valid and invalid: implementing complex validation in XProc</title>
    <info>
    <confgroup>
      <conftitle>Balisage: The Markup Conference 2026</conftitle>
      <confdates>August 3-7, 2026</confdates>
   </confgroup>
        <abstract>
            <para>We describe the (re-)implementation at an academic publisher of a piece of enterprise middleware which acts as quality gatekeeper for our XML-first publishing workflow. The approach covers re-factoring equivalent legacy software as a single XProc pipeline; how we have tackled testing this pipeline; and how we use the post-processing of the validation reports it produces to both refine and re-define our validity criteria, based on the “size and shape” of the documents under scrutiny and the extent to which they violate our business rules.</para>
        </abstract>
        <author>
            <personname><firstname>Andrew</firstname>
              <surname>Sales</surname></personname>
            <personblurb>
                <para>Andrew has led the Content Architecture team at Bloomsbury since 2016. He has worked with and for publishers in the XML space since 2000. Since 2016 he has been Project Editor for the Schematron international standard (ISO/IEC 19757-3).</para>
            </personblurb>
        </author>
<legalnotice><para>Copyright © 2026, Andrew Sales</para></legalnotice>
    </info>
    <section>
        <title>Introduction</title>
        <para>This paper is part technical implementation, part incomplete case study. As it is now written, it covers the main benefits to us as maintainers of the implementation and owners of the system, since it is only now being rolled out to its main consumers, our supplier base. As such, any data or feedback arising in the interim cannot be included here, but may be presented at a later date.</para>
    </section>
    <section>
        <title>Background</title>
        <para><link xlink:href="https://www.bloomsbury.com/uk/" xlink:type="simple" xlink:show="new" xlink:actuate="onRequest">Bloomsbury Publishing Group plc</link> began as a trade publisher nearly forty years ago, adding an academic division some time later. We publish around 3,500 titles per year on the academic list. 
</para>        
        <para>Our digital products include <link xlink:href="https://www.bloomsburycollections.com/" xlink:type="simple" xlink:show="new" xlink:actuate="onRequest">Bloomsbury Collections</link> (a library of nearly 50,000 titles, in the main academic monographs), <link xlink:href="https://www.dramaonlinelibrary.com/" xlink:type="simple" xlink:show="new" xlink:actuate="onRequest">Drama Online</link> (home of the Arden Shakespeare and content from partners such as Faber and Faber) and the <link xlink:href="https://www.churchillarchive.com/" xlink:type="simple" xlink:show="new" xlink:actuate="onRequest">Churchill Archive</link>, providing online access to the complete (800,000+) digitized papers of Sir Winston Churchill.</para>
        <para>We work for this part of the business almost exclusively, in the Content Architecture (CA) team. Our team’s main responsibility is to define and maintain the content conversion documentation and supporting validation assets used by our supplier base in preparing the XML we use to publish this content online, and to apply quality assurance prior to publication: in short, we “spec and check”.</para>
        <para>The focus of the work this paper describes is the text-based content we commission, or
            acquire or license from content partners and other third parties. This comprises mostly
            books, but also plays and some screenplays. In common with other publishers, our
            business grows mainly by acquisition, which can have the effect that our backlist
            suddenly grows in some cases by tens of thousands of titles, all eventually requiring
            conversion to our standard markup formats, to acceptable levels of quality.</para>
        <section>
            <title>History</title>
            <para>Bloomsbury Academic began digital publishing in around 2013, later becoming Bloomsbury Digital Resources (BDR).</para>
            <para>There was no in-house validation expertise at this time, but the need for a robust set of business rules encapsulated in Schematron schemas was soon recognised.</para>
            <para>Likewise, the realisation that an in-house quality assurance team was needed, since not everything could be reliably or definitively validated by machine with the available technology and knowhow. The "QA team" sits within Content Architecture.</para>
            <para>A “validation portal” was also set up to carry out delivery package integrity checks, and schema and Schematron validation on receipt from suppliers.</para>
        </section>
        <section xml:id="portal10">
            <title>Validation portal 1.0</title>
            <para>The first iteration of the validation portal was a bespoke web application
                developed, hosted and supported by one of our typesetting vendors.</para>
            <figure>
                <title>Simplified validation portal workflow</title><mediaobject><imageobject>
                    <imagedata fileref="../../../vol31/graphics/Sales01/Sales01-001.png"/>
            </imageobject></mediaobject></figure>
            <para>It provided in-built issue tracking for suppliers to file issues for investigation by the CA team, and both documentation and schemas could be updated by us in a self-service manner.</para>
            <para>The non-schema validation portions were, however, opaque and not under our control: we neither owned nor had visibility of the source code.</para>
            <para>The gratis provision of this service meant that there was also a unreliable lead
                time for any changes. Support was variable in general, with downtime, ad hoc changes
                to infrastructure and functional regression all proving disruptive.</para>
            <para>Reports resulting from schema validation were purely text-based (i.e., no SVRL<footnote><para>Schematron Validation Report Language [<xref linkend="schematron2025"/>], Annex D (informative).</para></footnote> produced by Schematron validation) and a log of only the most recent 200 or so submissions was retained, meaning supplier performance was hard to track over time and analyse for areas for improvement in their work.</para>
        </section>
    </section>
    <section>
        <title>Changing business needs</title>
        <para>The growth in our digital publishing throughput over the last ten years has meant that the need for greater automated validation has only grown with it. It was feasible to have a team operative quality-assure fifty new titles per month prior to release on Bloomsbury Collections in 2016; with monthly releases lately exceeding one thousand titles; however, we have no realistic commercial alternative at this scale but to automate whatever we can.</para>
        <para>This scaling up dovetails neatly with the team’s objective to skill up: we need to
            hone our collective technical skills in identifying bad quality and spend less time on
            repetitious manual work (the more basic aspects of which we also have an objective to
            outsource).</para>
    </section>
    <section>
        <title>On the road</title>
        <para>Some team members were already enthusiasts for XProc and its conceptual promise, but
            had ultimately been baffled by version 1.0. Available time and headspace dictated
            pursuing other avenues with a shallower learning curve for pipeline processing, for
            example using XQuery, Python or batch scripts to orchestrate a series of
            operations.</para>
        <para>In light of the foregoing issues, the maturity of XProc 3.0 [<xref linkend="xproc3"/>] (and latterly 3.1 [<xref linkend="xproc31"/>]) and two well-supported implementations of that standard led us to reconsider how and where this validation service would be best managed.</para>
        <para>In short, XProc 3.0 was for us a game-changer. In particular, the availability of both <link xlink:href="https://www.xmlcalabash.com/" xlink:type="simple" xlink:show="new" xlink:actuate="onRequest">XML Calabash 3.x</link> and <link xlink:href="https://xml-project.com/morganaxproc-iii.html" xlink:type="simple" xlink:show="new" xlink:actuate="onRequest">MorganaXProc-III</link> meant we could proceed with greater confidence when recommending the adoption of a new technology and procurement of (commercial, potentially) software to support it.</para>
    </section>
    <section>
        <title>At the crossroads</title>
        <para>We realised that the functional requirements for a new implementation of the portal centred on XProc would be relatively quick and easy to write, as this was more of a migration, in effect. We wanted to emulate what the existing portal did, basically, but also seize on the chance to retire some redundant hard-coded logic there, most of it relating to legacy publishing platforms.</para>
        <para>An important part of making the successful business case (and an impactful design decision) was to bring the validation logic entirely under our internal control.</para> 
        <para>This meant the software vendor we chose would essentially only need to implement a web application whose principal service was to execute the validation XProc pipeline on a supplier’s delivery. This would also reduce reliance on a particular vendor, as we had experienced with the previous portal.</para>
        <para>As well as commissioning this “vessel” to house a pipeline we would maintain ourselves, we decided that the new implementation’s strategy for error handling should be to fail early. With <link linkend="portal10" xlink:type="simple" xlink:show="new" xlink:actuate="onRequest">portal 1.0</link>, even if errors occurred at any point during validation that would render the delivery unusable, all checks were still applied, from beginning to end.</para>
        <para>By contrast, version 2.0 terminates with an error on every such condition, saving
            supplier time and sparing system resources.<footnote>
                <para>Suppliers are selected on that basis that they have the tools and expertise to
                    provide good-quality capture, which we support with our schemas, documentation
                    and active dialogue of feedback on quality. Our ideal way of working and what we
                    are seeking to encourage under version 2.0 is that they prepare good-quality XML
                    using these offline resources <emphasis>before</emphasis> upload to the portal,
                    which should act for them as a final check before delivery.</para>
            </footnote></para>
    </section>
    <section>
        <title>Design choices</title>
        <para>We had learnt from our past experience that transparency and direct control were paramount concerns for us, along with the ability to track and monitor supplier performance over time.</para>
        <para>Architecturally, we determined that all the reports generated by the validation pipeline should be XML documents and stored in a native XML database.</para>
        <para>We also split out the (rather clunky) issue tracking portion, in favour of our standard issue tracking system (<link xlink:href="https://www.atlassian.com/software/jira" xlink:type="simple" xlink:show="new" xlink:actuate="onRequest">JIRA</link>), for greater visibility across the business.</para>
        <para>New skills in the team led towards using AWS lambdas [<xref linkend="awsLambdas"/>] for execution of the validation pipeline. A slight downside here is that some significant changes require the lambda to be re-built and re-deployed, which we currently do need our vendor’s assistance with, but this is projected to be a relatively rare occurrence.</para>
        <para>We chose XProc because we are invested users of, believers in and proponents of the XML technology stack; the previous portal was implemented in PHP.</para>
        <para>This gives us the means to encapsulate the validation logic in a more maintainable, testable and visible way. Not every member of our team is conversant with XProc, but they can definitely read and understand XML and are familiar with unit testing frameworks like XSpec [<xref linkend="xspec"/>].</para>
        <section>
            <title>Why MorganaXProc-III?</title>
            <para>We need to do some work with binaries, e.g., inspecting PDFs for corruption; images for colour space, resolution, etc. For these, we use Python scripts built as executables. We authored a prototype pipeline and put Morgana and Calabash head-to-head, with a Morgana evaluation licence (Calabash supports <link xlink:href="https://spec.xproc.org/3.1/os/#c.os-exec" xlink:type="simple" xlink:show="new" xlink:actuate="onRequest">the <code>p:os-exec</code> step</link>, which we’d need for running our executables, but Morgana’s commercially-licensed “Extended Edition” (MorganaXProc-IIIee) is required for that).</para>
            <para>We were impressed by the performance (including its startup time when compared to that of XML Calabash), prompt support, licensing model and pricing.</para>
            <para>Once we had established with Morgana’s developer and co-editor of the XProc standard Achim Berndzen that we would be operating within the terms of the EULA, the spend was approved and we were good to go.</para>
            <para>One notable difference encountered was that, where our validation logic threw deliberate errors (using <code>p:error</code>), Calabash required these to be explicitly handled (with <code>p:try</code>/<code>p:catch</code>), whereas Morgana did not.</para>
        </section>
    </section>
    <section>
        <title>System architecture</title>
        <para>The new portal has a bespoke .NET front end and API, both maintained by our vendors.
            An existing application for a different purpose had already been built for us by the
            same vendor, so this choice was based on cost and time, rather than an ideal
            technological fit, for example, with the other, Java-based applications involved.</para>
        <para>It is a very simple application. Beyond the login page, it serves only three main pages: one enabling the upload of a delivery (or “digital bundle”); one displaying a list of documentation and validation assets for download; and a dashboard of uploads.</para>
        <figure>
            <title>Upload, dashboard and documentation screens</title>
            <mediaobject>
                <imageobject>
                    <imagedata fileref="../../../vol31/graphics/Sales01/Sales01-002.png"/>
                </imageobject>
            </mediaobject>
            <mediaobject>
                <imageobject>
                    <imagedata fileref="../../../vol31/graphics/Sales01/Sales01-003.png"/>
                </imageobject>
            </mediaobject>
            <mediaobject>
                <imageobject>
                    <imagedata fileref="../../../vol31/graphics/Sales01/Sales01-004.png"/>
                </imageobject>
            </mediaobject>
        </figure>
        <para>The user model is also simple, consisting of admins (people who can change stuff) and standard users (who can upload submissions and can access schemas and documentation).</para>
        <section>
            <title>Simplified reports</title>
            <para>A <link xlink:href="https://basex.org/" xlink:type="simple" xlink:show="new" xlink:actuate="onRequest">BaseX</link> database in the back end stores a digested form of each validation report, fulfilling our desire to monitor supplier performance.</para>
            <para>SVRL is verbose, and can run very large indeed, especially for large documents with many failing tests. We recognised that we only need a count of the distinct issue IDs and an example of each, so for efficiency the final output is condensed to e.g.:</para>
<programlisting xml:space="preserve">
&lt;report
    uri="file:///C:/Users/asales/git/content-architecture/xproc/validation-portal/test/warning-scores/suppress-pdf-required/fail/9781350002876_txt_xml.zip"
    timestamp="2026-02-13T15:39:05.4687649Z" warnings="208" distinctIds="41"
    schematronVersion="[schematron-version-info] schematronVersion = 11"
    workTitle="[title-value] Book title = &amp;#34;Shakespeare and Fun&amp;#34;"
    valid="false"&gt;
    &lt;warning id="bib-title-end-quote" count="27"&gt;
        &lt;svrl:successful-report test="ends-with(., '’') or ends-with(., '”')"
            id="bib-title-end-quote" role="warning"
            location="/*:book[namespace-uri()='http://docbook.org/ns/docbook'][1]/*:part[namespace-uri()='http://docbook.org/ns/docbook'][8]/*:chapter[namespace-uri()='http://docbook.org/ns/docbook'][3]/*:bibliolist[namespace-uri()='http://docbook.org/ns/docbook'][1]/*:bibliomixed[namespace-uri()='http://docbook.org/ns/docbook'][32]/*:bibliomset[namespace-uri()='http://docbook.org/ns/docbook'][1]/*:title[namespace-uri()='http://docbook.org/ns/docbook'][1]"&gt;
            &lt;svrl:text&gt;[bib-title-end-quote] title in biblio item ends with single or double quote;
                should it be outside the title? check within "bibliomset" element with id
                "b-9781350002876-2101"&lt;/svrl:text&gt;
        &lt;/svrl:successful-report&gt;
    &lt;/warning&gt;
    &lt;warning id="bib-title-start-quote" count="25"&gt;
        &lt;svrl:successful-report test="starts-with(., '‘') or starts-with(., '“')"
            id="bib-title-start-quote" role="warning"
        [...]
</programlisting>
            <para>since we are mainly concerned with the overall breakdown of issues, rather than the detail of each. In most cases, this reduces the SVRL file size from megabytes to kilobytes. Note that the portal does also record who made the submission (not shown above).</para>
        </section>
        <section>
            <title>Documaintenance</title>
            <para>That list of documentation assets is just a filtered view of a local clone of our GitHub repository, so we decided for ease of maintenance to use an XProc pipeline to generate it (statically; as <code>c:directory</code>, etc.). Again, this means we have complete control over what is included and no software changes are required to effect a change:</para>
<programlisting language="xml" xml:space="preserve">
&lt;p:declare-step xmlns:p="http://www.w3.org/ns/xproc"
    xmlns:c="http://www.w3.org/ns/xproc-step" version="3.0"
    xmlns:xs="http://www.w3.org/2001/XMLSchema"&gt;
    &lt;p:input port="source" sequence="true"/&gt;    &lt;!-- because there is no input --&gt;
    &lt;p:output port="result"/&gt;
    
    &lt;p:option name="git-path" select="'C:/Users/asales/git/'"/&gt;
    
    &lt;p:variable name="schematron-base" select="'file:/' || $git-path || 'content-architecture/content-models/DocBook/Schematron/'"/&gt;
    
    &lt;p:variable name="schema-base" select="'file:/' || $git-path || 'content-architecture/content-models/DocBook/schema/'"/&gt;
    
    &lt;p:variable name="docs-base" select="'file:/' || $git-path || 'content-architecture/content-models/DocBook/doc/'"/&gt;
    
    &lt;p:variable name="schematron-tei-base" select="'file:/' || $git-path || 'content-architecture/content-models/TEI/Schematron/'"/&gt;
    
    &lt;p:variable name="schema-tei-base" select="'file:/' || $git-path || 'content-architecture/content-models/TEI/schema/'"/&gt;
    
    &lt;p:variable name="docs-tei-base" select="'file:/' || $git-path || 'content-architecture/content-models/TEI/doc/Data%20model%20XML/'"/&gt;
    
    &lt;p:directory-list path="{$docs-tei-base}"
        include-filter='^Bloomsbury%20TEI%20XML%20-%20plays%20and%20screenplays%20v.+\.pdf$' detailed='true'/&gt;
    
    &lt;!-- filter out all but the latest (by modification date): some XPath repetition here 
    because p:delete/@match can't contain variable references --&gt;
    &lt;p:delete match="(//c:file[xs:dateTime(@last-modified) lt max(//c:file/xs:dateTime(@last-modified))])"/&gt;
    
    &lt;p:variable name="docs-tei" select="."/&gt;
    
    &lt;p:directory-list path="{$schematron-tei-base}"
        include-filter='^bmyTEI\.sch$' detailed='true'/&gt;
    
    &lt;p:variable name="schematron-tei" select="."/&gt;
    
    &lt;p:directory-list path="{$schema-tei-base}" detailed='true'&gt;
        &lt;p:with-option name="include-filter" select="('^bmyTEI\.rnc$', '^bmyTEI_BASE\.rnc$')"/&gt;
    &lt;/p:directory-list&gt;
    
    &lt;p:variable name="schema-tei" select="."/&gt;
    
    &lt;p:directory-list path="{$docs-base}"
        include-filter='^Bloomsbury%20DocBook%20XML%20Data%20Specification%20v.+\.pdf$' detailed='true'/&gt;
    
    &lt;!-- filter out all but the latest (by modification date): some XPath repetition here 
    because p:delete can't contain variable references --&gt;
    &lt;p:delete match="(//c:file[xs:dateTime(@last-modified) lt max(//c:file/xs:dateTime(@last-modified))])"/&gt;
    
    &lt;p:variable name="docs" select="."/&gt;
    
    &lt;p:directory-list path="{$schematron-base}"
        include-filter='^docbook-mods\.sch$' detailed='true'/&gt;
    
    &lt;p:variable name="schematron" select="."/&gt;
    
    &lt;p:directory-list path="{$schema-base}" detailed='true'&gt;
        &lt;p:with-option name="include-filter" select="('^bloomsbury-mods\.rnc$', '^publishers-51cr\.rnc$')"/&gt;
    &lt;/p:directory-list&gt;
    
    &lt;p:wrap-sequence wrapper="repo"&gt;
        &lt;p:with-input select="., $schematron, $docs, $schema-tei, $schematron-tei, $docs-tei"/&gt;
    &lt;/p:wrap-sequence&gt;
    
&lt;/p:declare-step&gt;
</programlisting>
            <para>On upload to the portal, a lambda simply executes the validation pipeline on the zip containing XML and supporting assets (images, PDFs, etc.). This encapsulates the core validation logic, so where we’ll spend a bit of time.</para>
        </section>            
    </section>
    <section>
        <title>Turning the corner</title>
        <para>Much of the pipeline is concerned with unexciting checks on the integrity of the delivery: after extracting the contents of the archive and resolving any XInclude instructions, it checks that file and directory naming conventions have been followed and identifies any superfluous or missing assets. These are all quite straightforward to do with XProc, but inspecting the file system and archive structure made especially convenient.</para>
        <para>For example, once we have established the primary XML document in the zip (because there may be XIncludes) and resolved any inclusions and retained a trace of the fact with <code>xml:base</code>:</para>
<programlisting language="xml" xml:space="preserve">
&lt;!-- infer the primary XML document from its filename; alternatively we could search for an appropriate root element --&gt;
&lt;p:variable name="primary-xml"
    select="/c:directory[@name eq $zip-isbn]/c:file[matches(@name, '^' || $this-filename-pattern || '.xml$')]/@name"/&gt;

&lt;p:variable name='non-primary-xml' select="/c:directory/c:file[ends-with(@name, '.xml') and @name ne $primary-xml]"/&gt;

&lt;!-- resolve XIncludes: we explicitly fix up xml:base, so that we can check for any extraneous XML --&gt;
&lt;p:xinclude fixup-xml-base="true"&gt;
    &lt;p:with-input&gt;
        &lt;p:document href="{resolve-uri($primary-xml, /c:directory/@xml:base)}"/&gt;
    &lt;/p:with-input&gt;
&lt;/p:xinclude&gt;

&lt;!-- all xml:base attributes present after fixup --&gt;
&lt;p:variable name="xml-base-fixup" select="//@xml:base"/&gt;
</programlisting>
<para>the rest of the work of identifying superfluous XML can then be expressed by some fairly lucid and succinct XPath:</para>        
<programlisting language="xml" xml:space="preserve">
&lt;!-- superfluous XML: extracted files not appearing as @xml:base values after XInclude resolution --&gt;
&lt;p:variable name="superfluous-xml" select="$non-primary-xml[not(base-uri(.) = $xml-base-fixup)]"/&gt;

&lt;!-- check for superfluous XML in the zip --&gt;
&lt;p:if test="exists($superfluous-xml)"&gt;
    &lt;p:error code="superfluous-xml"&gt;
        &lt;p:with-input&gt;
            &lt;message isbn='{$zip-isbn}'&gt;XML documents supplied which are not part of this delivery: {for $x in $superfluous-xml return string-join($x/@name, '&amp;#xA;')}&lt;/message&gt;
        &lt;/p:with-input&gt;
    &lt;/p:error&gt;
&lt;/p:if&gt;
</programlisting>
        <section>
            <title>Schemalets (or <emphasis role="ital">epischemas</emphasis>)</title>
            <para>Although we have our main schema documentation, some content sets require special treatment which may be a one-off, so we typically avoid bloating the schema or documentation with those instructions and constraints.</para>
            <para>Instead, we issue additional “conversion instructions” in such cases and historically have applied any programmatic checks in-house, using small, standalone Schematron schemas particular to the case in hand. (We have christened these “schemalets”, as they tend to be small-scale offshoots of the main schema they supplement.<footnote>
                    <para>Multiple schemas applied in this way have been described as <emphasis role="ital">epischemas</emphasis> [<xref linkend="epischemas"/>], although our own preference is to use additional Schematron schemas for their flexibility in expressing complex business rules.
                    </para>
            </footnote>)</para>
            <para>We would prefer to have these applied at point of receipt, which was not realistically feasible using the legacy portal. So the method we are pursuing is to specify an appropriate <code>&lt;?xml-model?&gt;</code> and have the pipeline parse that to determine which schemalet(s) to use.</para>
            <!--[CODE]-->
            <para>This means we can now apply arbitrary schemalets as needed – provided of course that the XML supplier includes the correct <code>&lt;?xml-model?&gt;</code> in their delivery.</para> 
        </section>
        <section>
            <title>Scoring the warnings</title>
            <para>Our Schematron schemas distinguish between two basic levels of severity deserving attention: errors and warnings.<footnote>
                    <para>We note that the latest edition of the Schematron standard [<xref linkend="schematron2025"/>] gives more potential flexibility with the dedicated attribute <code>severity</code> (glossed at <link xlink:href="https://andrewsales.com/schematron4/ch04.html" xlink:type="simple" xlink:show="new" xlink:actuate="onRequest">What's New In Schematron 4</link>).</para>
            </footnote></para>
            <para>The acceptance criteria are currently that the presence of any errors means the XML is invalid, therefore rejected; but any number of warnings can be present.</para>
            <para>However – and this is probably the case more particularly with human-authored, discursive content - some warnings are more important than others. Divining this is a significant part of what the QA team is engaged in.</para>
            <para>With the new freedoms afforded to us by the portal 2.0, we resolved to apply a “scoring” algorithm to these warnings, to see which we could instead reject out of hand at submission-time.<footnote>
                    <para>We credit Dr Alex Brown with this principle.</para>
            </footnote></para>
            <para>This meant collating information from the team about what would constitute a request for re-work from the supplier, in effect the formal expression of these implicit tolerances.</para>
            <para>The approach is to post-process the Schematron validation report. For a given constraint ID, we can specify two parameters: a threshold, or absolute number we can accept of a certain warning; or a proportion, i.e., for what percentage of the occurrences of a particular structure the warning was reported.</para>
            <para>The pipeline logic is that a Schematron SVRL document containing warnings, but no errors, is filtered by an XSLT stylesheet, whose main processing template is:</para>
<programlisting language="xml" xml:space="preserve">
&lt;xsl:template match="/"&gt;
    &lt;xsl:variable name="warnings" select="(//svrl:failed-assert|//svrl:successful-report)[@role = ('warn', 'warning')]"/&gt;
    
    &lt;xsl:variable name="grouped-warnings"&gt;
        &lt;xsl:for-each-group select="$warnings" group-by="@id"&gt;
            &lt;warning id='{current-grouping-key()}' count='{count(current-group())}'&gt;
                &lt;xsl:copy-of select="current-group()[1]"/&gt;
            &lt;/warning&gt;
        &lt;/xsl:for-each-group&gt;
    &lt;/xsl:variable&gt;
    
    &lt;xsl:variable name="scored-warnings"&gt;
        &lt;xsl:apply-templates select="$grouped-warnings" mode="score"/&gt;
    &lt;/xsl:variable&gt;        
    
    &lt;report uri='{$zip-uri}' timestamp='{current-dateTime()}' warnings='{count($warnings)}' distinctIds='{count($grouped-warnings/warning)}'
        schematronVersion='{$schematron-version}' workTitle='{$work-title}'&gt;
        &lt;xsl:if test="exists($scored-warnings/warning/@invalid)"&gt;
            &lt;xsl:attribute name="valid" select="'false'"/&gt;
        &lt;/xsl:if&gt;
        &lt;xsl:apply-templates select="$scored-warnings/warning"&gt;
            &lt;xsl:sort select="@count" order="descending" data-type="number"/&gt;
        &lt;/xsl:apply-templates&gt;
    &lt;/report&gt; 
&lt;/xsl:template&gt;
</programlisting>
            <para>where the <code>warning</code>s are decorated with an <code>invalid</code> attribute if the threshold or proportion configured is reached:</para>
<programlisting xml:space="preserve">
&lt;xsl:template match="warning" mode="score"&gt;
    &lt;xsl:copy&gt;
        &lt;xsl:copy-of select="@*"/&gt;
        &lt;xsl:sequence select="(
            bmy:threshold-exceeded(@id, @count), 
            bmy:proportion-exceeded(@id, @count, $node-count-by-id?(@id))
        )[1]"/&gt;
        &lt;xsl:copy-of select="*"/&gt;
    &lt;/xsl:copy&gt;
&lt;/xsl:template&gt;
</programlisting>
            <para>The function <code>threshold-exceeded()</code> is simple enough:</para>
<programlisting xml:space="preserve">
&lt;xsl:function name="bmy:threshold-exceeded" as="attribute(invalid)?"&gt;
    &lt;xsl:param name="id" as="attribute(id)"/&gt;
    &lt;xsl:param name="count" as="attribute(count)"/&gt;
    &lt;xsl:if test="$count &gt; $warning-thresholds?($id)"&gt;
        &lt;xsl:attribute name="invalid" select="'thresholdExceeded'"/&gt;
    &lt;/xsl:if&gt;
&lt;/xsl:function&gt;
</programlisting>
            <para>while <code>proportion-exceeded()</code> calculates whether the percentage has been exceeded:</para>
<programlisting xml:space="preserve">
&lt;xsl:function name="bmy:proportion-exceeded" as="attribute(invalid)?"&gt;
    &lt;xsl:param name="id" as="attribute(id)"/&gt;
    &lt;xsl:param name="warningCount" as="attribute(count)"/&gt;
    &lt;xsl:param name="nodeCount" as="xs:integer?"/&gt;
    &lt;xsl:if test="($warningCount div $nodeCount) ge ($warning-percentages?($id) div 100)"&gt;
        &lt;xsl:attribute name="invalid" select="'proportionExceeded'"/&gt;
    &lt;/xsl:if&gt;
&lt;/xsl:function&gt;
</programlisting>
            <para>If any warning is marked as invalid in this way, <code>&lt;report valid='false'&gt;...&lt;/report&gt;</code> is output, with the upshot that the portal rejects the delivery.</para>
            <para>It will be apparent from the snippets above that the configuration of these value limits is currently done using maps. For extensibility and ease of maintenance, we plan to move this into an XML configuration document instead, e.g.:</para>
<programlisting xml:space="preserve">
&lt;config&gt;
    &lt;thresholds&gt;
        &lt;warning id='suppress-pdf-required'&gt;5&lt;/warning&gt;
    &lt;/thresholds&gt;
    &lt;proportions&gt;
        &lt;warning id='figure-in-label'&gt;100&lt;/warning&gt;
        &lt;warning id='table-in-label'&gt;100&lt;/warning&gt;
    &lt;/proportions&gt;
&lt;/config&gt;
</programlisting>
            <para>where each <code>id</code> corresponds to a Schematron warning ID.</para>
            <para>We fully appreciate that greater complexity of algorithms is possible and do plan to exploit this as needed. For now, we need this approach to prove its worth, then we can expand on it.</para>
            <para>The other major consideration for us is that having a portal whose validation criteria are stricter than necessary appeals in principle, but when faced with the reality of a fast-paced production environment, the risk to the business of potentially slowing down or blocking supply unnecessarily is often too great.</para>            
        </section>
        <section>
            <title>ONIX metadata cross-checks</title>
            <para>While we process a reasonable amount of licensed and acquired content, the majority on balance is indigenous – commissioned by our in-house editors.</para>
            <para>For this, our bibliographic metadata system (<link xlink:href="https://www.virtusales.com/" xlink:type="simple" xlink:show="new" xlink:actuate="onRequest">Biblio</link>) produces ONIX [<xref linkend="onix"/>], which is syndicated to various recipients, including content aggregators and discovery providers.</para>
            <para>An ongoing issue for us has been discrepancies between the metadata held in that system (i.e., as expressed in ONIX) and its equivalents contained in the XML representing the content.</para>
            <para>This can arise because of late-breaking changes while the title is in production
                are not subsequently reflected in what is recorded in Biblio. For example, a work’s
                subtitle might change after it has been typeset, perhaps because of space
                constraints on the page.</para>
            <para>The intention (not implemented as of this writing) is to take a regular feed of ONIX XML for everything in production. These are very large (sometimes multi-gigabyte) documents, but fortunately record-based XML, so the feed can easily be decomposed into small, individual ONIX <code>Product</code>s, then stored and queried efficiently.<footnote>
                    <para>Our experimentation has shown that performance-wise these are better ingested, indexed and queried as small, individual documents in BaseX.</para>
            </footnote></para>
                <para>This means for a given ISBN, we can have the validation pipeline query a database of ONIX <code>Product</code>s and compare key items of metadata against the XML as captured, flagging or rejecting any mismatch.</para>
            <para>This approach also leads the way to potential automated reconciliation: results of validation can be fed back into Biblio, most probably via a human-managed gateway interface.</para>
            </section>
            <section>
                <title>Testing the pipeline</title>
                <para>Testing XProc 3.0 pipelines appears to be a nascent area of the technology, so we were pleased to learn while preparing this paper that support in XSpec for this is being added [<xref linkend="xspec34highlights"/>].</para>
                <para>In the interim, we use a unit-testing approach supported by BaseX's <code>unit</code> module.</para>
                    <para>Each test is represented by a function with the BaseX-specific XQuery annotation <code>%unit:test</code>. The tests are organised to enable the entire suite to be run at once. There is one passing and one failing unit test for each of the conditions the pipeline can present. For example, this pair of tests relate to the same condition, where media assets not referenced by the XML are present in the delivered package:</para>
<programlisting language="xquery" xml:space="preserve">
declare %unit:test function _:extraneous-media-fail()
{
  unit:assert(
    _:run-pipeline('extraneous-media/fail/9781350996526_txt_xml.zip')/c:errors/c:error[@code eq 'extraneous-media']
  )
};

(:~ clean up extracted files :)
declare %unit:after('extraneous-media-fail') function _:extraneous-media-fail-cleanup()
{
  file:delete(resolve-uri('extraneous-media/fail/9781350996526/', static-base-uri()), true())
};

declare %unit:test function _:extraneous-media-pass()
{
  unit:assert-equals(
    _:run-pipeline('extraneous-media/pass/9781350996526_txt_xml.zip')/c:errors/c:error[@code eq 'extraneous-media'],
    ()
  )
};

(:~ clean up extracted files :)
declare %unit:after('extraneous-media-pass') function _:extraneous-media-pass-cleanup()
{
  file:delete(resolve-uri('extraneous-media/pass/9781350996526/', static-base-uri()), true())
};
</programlisting>
                <note><para>The <code>%unit:after</code> annotation in BaseX specifies that the body of the function is executed after the named <code>%unit:test</code> function has been executed: here, we clean up any extracted files.</para></note>
                <para>There is a performance overhead with this approach, in that we start a new instance of Morgana for every test, invoked via the BaseX function <code>proc:execute</code>, but an XML document representing the result of that invocation is at least returned for inspection.<footnote>
                        <para>Inverting this process, so that a single Morgana pipeline invokes the
                        tests instead, would likely improve performance, but Morgana does not
                        currently support invoking XQuery using BaseX.</para>
                </footnote></para>
                <para>We do have some test cases that address individual steps in isolation (enabled, for example, by the <link xlink:href="https://xml-project.com/manual/ch01.html#R_ch1_s1_3" xlink:type="simple" xlink:show="new" xlink:actuate="onRequest">appropriate Morgana command line options</link>), but this is less relevant for us as we mostly use built-in steps in the pipeline as currently written. This is where our fail-early philosophy presents a slight disadvantage, because if we want to test in a way that resembles real-world scenarios closely, we have the overhead of maintaining test cases that are valid up to a certain point in the pipeline.</para>
                <para>For most tests that is fine, as the criteria are quite stable; but our
                Schematron schemas do change relatively often, which can mean a pipeline test
                suddenly fails as a consequence. It would be onerous for us to maintain complete,
                shared test case documents, so the tactic we have employed here amounts to
                "mocking", whereby a step option accepts suitable input to produce the desired
                behaviour. In this unit test, setting the <code>$use-mocking</code> option to
                    <code>true()</code> has the effect of making the step output the input document,
                just as would be the case if validation succeeded:</para>
<programlisting xml:space="preserve">
declare %unit:test function _:schema-validation-pass()
{
  let $result := _:run-library-step(
    'schema-validation/pass/9780567706652_txt_xml.xml',
      $_:STEPS_LIBRARY_PATH, 
      'Q{http://www.bloomsbury.com/xproc}bmy:validate-rnc-sch',
      map{},
      map{
        'relax-ng-uri':'../../content-models/DocBook/schema/bloomsbury-mods.rnc',
        'schematron-uri':'../../content-models/DocBook/schematron/docbook-mods.sch',
        <emphasis role="bold">'use-mocking':true()</emphasis>
      }
  )
  let $output := $result/output=&gt;parse-xml()
  return
  unit:assert-equals($output/c:errors, ())
};
</programlisting>
                <para>At first this can feel wrong, since we effectively bypass the test, or at least produce an artificial result; but we have reasoned that this is on balance tolerable since a) the main purpose of this test suite is to test the <emphasis>pipeline's</emphasis> behaviour, rather than that of the schemas it references; and b) we already have robust test suites in place elsewhere to test that the schemas themselves are fit for purpose.</para>
            </section>
            <section>
                <title>Extensibility</title>
                <para>Not long after development of the new portal was completed, a new use case for
                a content type arose. We had handled this content before but on such a small scale
                that it didn’t warrant building into the portal. Now, however, the scale of the
                incoming content is ambitious, so we feel vindicated in that we can now easily
                incorporate a new content type by some relatively self-contained changes to the
                pipeline.</para>
                <para>The changes will amount to adding some minor decision logic to respond to the new markup to select the correct schema, and re-generating the filtered view of our cloned GitHub repository, to make the added validation assets available to users on the portal front end.</para>
            </section>
    </section>
    <section>
        <title>Conclusion</title>
        <para>The changes we have made to our validation infrastructure have benefited our digital production workflows, as follows:</para>
        <orderedlist>
            <listitem>
                <para>We have traded reliance on a vendor offering a "black box" service for free,
                    for a low-maintenance, low-cost validation service.</para>
            </listitem>
            <listitem>
                <para>We have brought the validation logic the service applies in house, in turn making it more adaptable and responsive to the delivery quality issues we face.</para>
            </listitem>
            <listitem>
                <para>We can collate and interpret data on our suppliers' performance
                    programmatically for the first time.</para>
            </listitem>
        </orderedlist>
        <para>In early 2017, our team was understandably challenged by senior leadership, questioning why QA work carried out by human beings was still needed if we had schemas and a validation portal to apply them automatically on receipt from the supplier. Our response contained the phrase, "we will always need to do some checking [...] because schemas alone are not enough in some cases - they have not yet reached a level of artificial intelligence." Perhaps this will be the next significant development in XML quality assurance of this nature.</para>
    </section>
    <bibliography>
        <title>Bibliography</title>
        <bibliomixed xml:id="xproc1" xreflabel="XProc 1.0">XProc: An XML Pipeline Language, W3C Recommendation, 11 May 2010, <link xlink:href="https://www.w3.org/TR/xproc/" xlink:type="simple" xlink:show="new" xlink:actuate="onRequest">https://www.w3.org/TR/xproc/</link></bibliomixed>
        <bibliomixed xml:id="xproc3" xreflabel="XProc 3.0">XProc 3.0: An XML Pipeline Language, 
            <link xlink:href="https://spec.xproc.org/3.0/xproc/" xlink:type="simple" xlink:show="new" xlink:actuate="onRequest">https://spec.xproc.org/3.0/xproc/</link></bibliomixed>
        <bibliomixed xml:id="xproc31" xreflabel="XProc 3.1">XProc 3.1: An XML Pipeline Language, 
            <link xlink:href="https://spec.xproc.org/3.1/xproc/" xlink:type="simple" xlink:show="new" xlink:actuate="onRequest">https://spec.xproc.org/3.1/xproc/</link></bibliomixed>
        <bibliomixed xml:id="awsLambdas" xreflabel="AWS Lambdas">What is AWS Lambda?, AWS Lambda Developer Guide, <link xlink:href="https://docs.aws.amazon.com/lambda/latest/dg/welcome.html" xlink:type="simple" xlink:show="new" xlink:actuate="onRequest">https://docs.aws.amazon.com/lambda/latest/dg/welcome.html</link> (retrieved 2026-04-04).</bibliomixed>
        <bibliomixed xml:id="xspec" xreflabel="XSpec">XSpec, <link xlink:href="https://github.com/xspec/xspec" xlink:type="simple" xlink:show="new" xlink:actuate="onRequest">https://github.com/xspec/xspec</link></bibliomixed>
        <bibliomixed xml:id="xspec34highlights" xreflabel="XSpec 3.4 highlights">XSpec 3.4 Highlights, <link xlink:href="https://medium.com/@xspectacles/xspec-v3-4-highlights-2a1a34ee153d" xlink:type="simple" xlink:show="new" xlink:actuate="onRequest">https://medium.com/@xspectacles/xspec-v3-4-highlights-2a1a34ee153d</link> (retrieved 2026-04-04).</bibliomixed>
        <bibliomixed xml:id="epischemas" xreflabel="Imsieke, 2017">Gerrit Imsieke, <quote>A Text Structure ‘Epischema’ for TEI,</quote> <emphasis>XML Prague 2017</emphasis>, <link xlink:href="https://archive.xmlprague.cz/2017/files/xmlprague-2017-proceedings.pdf" xlink:type="simple" xlink:show="new" xlink:actuate="onRequest">https://archive.xmlprague.cz/2017/files/xmlprague-2017-proceedings.pdf</link>, pp.195-209.</bibliomixed>
        <bibliomixed xml:id="schematron2025" xreflabel="Schematron 2025">ISO/IEC 19757-3:2025, <link xlink:href="https://www.iso.org/standard/85625.html" xlink:type="simple" xlink:show="new" xlink:actuate="onRequest">https://www.iso.org/standard/85625.html</link></bibliomixed>
        <bibliomixed xreflabel="ONIX" xml:id="onix">ONIX, <link xlink:href="https://www.editeur.org/8/ONIX/" xlink:type="simple" xlink:show="new" xlink:actuate="onRequest">https://www.editeur.org/8/ONIX/</link></bibliomixed>
    </bibliography>
</article>