<?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" xml:id="BalisageXX-SmartReplace-TrackChanges">

  <title>Track Changes Support for Automated Regex-Based Text Replacement in XML Documents</title>

  <info>
    <confgroup>
      <conftitle>Balisage: The Markup Conference 2026</conftitle>
      <confdates>August 3-7, 2026</confdates>
   </confgroup>
    <abstract>
      <para>Automated text replacement in structured XML documents presents a fundamental editorial
        challenge: how can a processing tool apply programmatic changes while preserving the
        human-readable audit trail that reviewers and editors rely on? This paper describes a
        three-phase pipeline—unwrap, replace, and rewrap—that separates existing change-tracking markup
        from document content prior to processing, applies regex-based transformation rules to the
        normalized text, and reconstructs a well-formed change-tracking structure that integrates
        both pre-existing editorial annotations and new programmatic changes.</para>
      <para>The paper addresses two key technical challenges. First, nested track changes, where
        programmatic edits overlap with or occur within existing editorial marks, require careful
        recursive reconstruction. Second, offset management across multiple sequential replacements
        within a single paragraph, where each modification shifts the character positions of
        subsequent matches, must be handled precisely—a simplified instance of operational
        transformation. We introduce the <code>ReplacementInfo</code> data structure, which
        maintains contextual and positional consistency throughout the pipeline. The approach is
        validated through a suite of integration tests covering overlapping replacements, protected
        inline elements, Unicode code-point indexing, and multi-group rule interactions.</para>
    </abstract>

    <author>
      <personname>
        <firstname>K.A.G.</firstname>
        <surname>Udeshani</surname>
      </personname>
      <personblurb>
        <para>K.A.G. Udeshani is an Associate Software Architect at Typefi Systems working on
          XML-based publishing automation tools.</para>
      </personblurb>
      <affiliation>
        <orgname>Typefi Systems</orgname>
      </affiliation>
      <email>gudeshani@typefi.com</email>
    </author>
<legalnotice><para>© 2026 Typefi Systems Pty Ltd and its licensors.  All rights reserved.  Typefi, the Typefi logo, Typefi Onion, and the Typefi Orion logo are either registered trademarks or trademarks of Typefi Systems Pty Ltd in the United States and/or other countries.  All other trademarks, logos and copyrights are the property of their respective owners.</para></legalnotice>
    <keywordset role="author">
      <keyword>track changes</keyword>
      <keyword>XML processing</keyword>
      <keyword>regex replacement</keyword>
      <keyword>change tracking</keyword>
      <keyword>offset management</keyword>
      <keyword>operational transformation</keyword>
      <keyword>publishing automation</keyword>
    </keywordset>
  </info>

  <!-- ============================================================ -->
  <section xml:id="s.introduction">
    <title>Introduction</title>

    <para>Automated search-and-replace is a routine operation in publishing workflows—style-guide
      enforcement, terminology standardization, and citation formatting all involve applying batches
      of regex rules to document text. When the target documents are in XML, the problem acquires
      two dimensions that plain-text replacement tools do not face.</para>

    <para>First, the replacement engine must not treat markup as content. A rule that replaces
      <quote>colour</quote> with <quote>color</quote> should not match the literal text
      <code>status="deleted"</code> that happens to appear inside a change-tracking element. Because
      the document is serialized XML, the engine must distinguish text content from tag syntax.</para>

    <para>Second, in many editorial workflows the documents already carry a body of tracked
      changes—insertions and deletions recorded by human editors—that must not be silently
      overwritten or lost. Moreover, every automated change the tool itself makes should appear as a
      tracked change, attributable to the tool and the specific rule that produced it, so that a
      downstream editor can accept or reject each change individually.</para>

    <para>This paper addresses both dimensions in combination. The implementation context is
      Orion Smart Replace, a Java plugin for Typefi's automated publishing platform that processes
      documents in the CXML element-wrapping change-tracking format (described in
      <xref linkend="s.markup-model"/>). The plugin reads dictionary files specifying regex-based
      find/replace rules and applies them to paragraph elements. While the specific XML vocabulary
      is defined by the CXML schema <xref linkend="bib-cxml"/>, the algorithmic
      problems—separating editorial markup from searchable text, maintaining character-position
      consistency across sequential replacements, and reconstructing nested change
      representations—are general to any system that combines automated text transformation with
      element-based change tracking.</para>

    <para>The contributions of this paper are:</para>

    <itemizedlist>
      <listitem>
        <para>A three-phase unwrap/replace/rewrap pipeline that isolates pre-existing track
          changes, applies regex replacements to clean text, and reconstructs a merged
          change-tracking representation.</para>
      </listitem>
      <listitem>
        <para>A treatment of the nested track changes problem: when a programmatic replacement
          targets text that is itself inside, or spans, a pre-existing editorial change, the
          resulting markup must accurately represent all layers of change.</para>
      </listitem>
      <listitem>
        <para>A treatment of the offset management problem: as multiple replacements are applied
          sequentially within a single paragraph, each replacement changes the length of the text,
          shifting the start and end positions of all subsequent candidates—a simplified instance
          of operational transformation
          <xref linkend="bib-ellis-gibbs"/>.</para>
      </listitem>
      <listitem>
        <para>A description of the <code>ReplacementInfo</code> record that carries positional and
          authorship metadata across all three phases.</para>
      </listitem>
    </itemizedlist>
  </section>

  <!-- ============================================================ -->
  <section xml:id="s.related-work">
    <title>Related Work</title>

    <section xml:id="s.tc-representations">
      <title>Change Tracking Representations in XML</title>

      <para>Change tracking in XML documents takes three principal forms, each with different
        implications for automated processing.</para>

      <para><emphasis role="bold">Element wrapping.</emphasis> The most common approach uses
        dedicated elements to delimit inserted and deleted content. OOXML (ISO/IEC 29500)
        <xref linkend="bib-ecma376"/> wraps insertions in <code>&lt;w:ins&gt;</code> and deletions
        in <code>&lt;w:del&gt;</code>, each carrying author and timestamp attributes. ODF
        <xref linkend="bib-odf"/> uses analogous <code>&lt;text:insertion&gt;</code> and
        <code>&lt;text:deletion&gt;</code> elements. The ContentXML (CXML) format used in our
        implementation employs <code>&lt;style&gt;</code> elements with a <code>status</code>
        attribute whose value is <code>"added"</code> or <code>"deleted"</code>, described in
        <xref linkend="s.markup-model"/>. In all these formats, the document contains both the
        original and proposed text simultaneously, and the change-tracking elements alter the
        document's tree structure by introducing additional nesting levels.</para>

      <para><emphasis role="bold">Processing instructions.</emphasis> An alternative approach uses
        processing instructions (PIs) to mark change boundaries without altering the element tree.
        The oXygen XML editor, for example, uses <code>&lt;?oxy_insert_start?&gt;</code> /
        <code>&lt;?oxy_insert_end?&gt;</code> and <code>&lt;?oxy_delete?&gt;</code> PIs
        <xref linkend="bib-oxygen"/>. Because PIs are not elements, they do not affect document
        nesting—a property that simplifies some processing scenarios but complicates others, since
        PIs cannot directly contain the deleted text.</para>

      <para><emphasis role="bold">Delta files.</emphasis> DeltaXML and similar tools represent
        changes as a separate delta document or as annotations merged into the original tree, rather
        than inline element pairs <xref linkend="bib-deltaxml"/>. La Fontaine
        <xref linkend="bib-lafontaine-2014"/> proposes a standard change-tracking vocabulary for
        XML. La Fontaine's earlier work <xref linkend="bib-lafontaine-2010"/>   
        provides a taxonomy of change-tracking models and discusses the design trade-offs among
        them.</para>

      <para>The element-wrapping model is defined by the CXML (ContentXML) schema, maintained by
        Typefi <xref linkend="bib-cxml"/>, and is a constraint of the platform rather than a design
        choice. However, the pipeline described in this paper would apply, with straightforward
        adaptations, to any element-wrapping change-tracking vocabulary.</para>
    </section>

    <section xml:id="s.tree-vs-string">
      <title>Tree-Based vs. String-Based Text Replacement</title>

      <para>There are two broad strategies for applying regex replacements to XML content.</para>

      <para><emphasis role="bold">Tree-native replacement</emphasis> operates on text nodes within
        the parsed tree. The XSLT instruction <code>xsl:analyze-string</code> (available since
        XSLT 2.0 <xref linkend="bib-xslt30"/>) and the XPath function
        <code>fn:analyze-string</code> (available since XPath 3.0 <xref linkend="bib-xpath31"/>)
        decompose a string into matching and non-matching substrings, enabling regex-driven
        transformations without leaving the tree model. Kalvesmaki's TAN library 
        <xref linkend="bib-kalvesmaki"/> extends this approach with <code>tan:batch-replace()</code>,
        which applies an ordered sequence of find/replace pairs to text nodes, handling
        back-references and case folding within XSLT. The advantage of tree-native replacement is
        that tag boundaries are never at risk: the processor sees only text-node content, not
        serialized markup.</para>

      <para><emphasis role="bold">String-based replacement</emphasis> serializes an XML subtree to
        a string, applies regex matches to the serialized form, and parses the result back into the
        tree. This is the approach taken in the present work. The choice is driven by compatibility
        with a large corpus of existing dictionaries whose regex patterns were authored against
        serialized paragraph text, including patterns that span inline element
        boundaries—for example, matching text that is partly in one inline element and partly in
        adjacent text. The cost is that the serialized string contains
        tag syntax that the regex engine must be taught to avoid
        (<xref linkend="s.tagcontext"/>), and each round-trip through serialization and parsing
        introduces potential for namespace or whitespace artifacts
        (<link linkend="s.tradeoffs" xlink:type="simple" xlink:show="new" xlink:actuate="onRequest">section <quote>Design Trade-offs</quote>, paragraph <quote>Serialization round-tripping</quote></link>).</para>
    </section>

    <section xml:id="s.ot-background">
      <title>Offset Management and Operational Transformation</title>

      <para>When multiple replacements are applied sequentially to a single text, each replacement
        may change the text's length, shifting the positions of all subsequent match candidates.
        This is a well-known problem in collaborative editing, where it is addressed by operational
        transformation (OT) <xref linkend="bib-ellis-gibbs"/>. In OT, each editing operation carries
        positional metadata that is transformed against preceding operations to account for
        insertions and deletions that have shifted the text.</para>

      <para>Our offset propagation strategy (<xref linkend="s.offsets"/>) is a simplified instance
        of OT: replacements are applied sequentially rather than concurrently, and the transformation
        function is a simple additive shift rather than the more complex inclusion/exclusion
        transforms required for concurrent edits. The connection is worth noting because it clarifies
        the correctness invariant: after all replacements have been applied, every positional record
        must refer to the same final text state—exactly the convergence property that OT
        guarantees.</para>
    </section>
  </section>

  <!-- ============================================================ -->
  <section xml:id="s.markup-model">
    <title>The Track Changes Markup Model</title>

    <para>The target format uses element wrapping for change tracking. A
      <code>&lt;style&gt;</code> element with a <code>status</code> attribute value of
      <code>"deleted"</code> or <code>"added"</code>, together with <code>author</code> and
      <code>dateTime</code> attributes, records a tracked change. A tracked deletion of the word
      <quote>colour</quote> followed by its replacement <quote>color</quote> is represented
      as:</para>

<programlisting xml:id="ex.cxml-tc" xml:space="preserve">
&lt;p type="Text para"&gt;The
  &lt;style author="Editor"
         dateTime="2025-09-24T11:02:58"
         status="deleted"&gt;colour&lt;/style&gt;
  &lt;style author="Editor"
         dateTime="2025-09-24T11:02:58"
         status="added"&gt;color&lt;/style&gt;
  was vivid.&lt;/p&gt;
</programlisting>

    <para>The deleted element carries the original text; the added element carries the replacement.
      A pure insertion (text added with nothing deleted) uses an empty deleted element. A pure
      deletion uses an empty added element.</para>

    <para>Two properties of this model are important for the pipeline design:</para>

    <orderedlist>
      <listitem>
        <para><emphasis role="bold">Change-tracking elements are inline.</emphasis> They appear as
          children of <code>&lt;p&gt;</code> elements alongside text nodes and other inline markup
          (bold, italic, superscript). This means that serializing a paragraph to a string will
          include the change-tracking elements as literal tag text.</para>
      </listitem>
      <listitem>
        <para><emphasis role="bold">Nesting is permitted.</emphasis> A
          <code>&lt;style status="added"&gt;</code> element may itself contain further
          <code>&lt;style status&gt;</code> elements, representing a subsequent change to text that
          was itself introduced by an earlier change. This nesting is the mechanism by which the
          format records layered editing history.</para>
      </listitem>
    </orderedlist>
  </section>

  <!-- ============================================================ -->
  <section xml:id="s.pipeline">
    <title>The Three-Phase Pipeline</title>

    <para>The processing method iterates over every <code>&lt;p&gt;</code> element in the
      document (in reverse DOM order to avoid index invalidation as nodes are replaced). For each
      paragraph, it executes three phases:</para>

<programlisting xml:space="preserve">
unwrapTrackChanges(pElement, ...)   ← Phase 1
smartReplace(...)                   ← Phase 2
processTrackChanges(...)            ← Phase 3
</programlisting>

    <para>A single list, <code>allReplacementsForPNode</code>, of <code>ReplacementInfo</code>
      records threads information through all three phases. After Phase 3, the reconstructed XML
      string is wrapped in a <code>&lt;p&gt;</code> element and replaces the original node in the
      DOM.</para>

    <para>The following diagram illustrates the data flow through all three phases:</para>

<programlisting xml:id="fig.pipeline-overview" xml:space="preserve">
┌─────────────────────────────────────────────────────────────┐
│  Input: &lt;p&gt; with existing &lt;style status&gt; track changes      │
└──────────────────────────┬──────────────────────────────────┘
                           │
                    Phase 1 │ unwrapTrackChanges()
                           │  • Remove &lt;style status&gt; wrappers
                           │  • Record positions → ReplacementInfo
                           ▼
┌─────────────────────────────────────────────────────────────┐
│  Clean text (no TC markup) + allReplacementsForPNode        │
└──────────────────────────┬──────────────────────────────────┘
                           │
                    Phase 2 │ smartReplace()
                           │  • Match regex rules per group
                           │  • Apply replacements + offset propagation
                           │  • Append new ReplacementInfo records
                           ▼
┌─────────────────────────────────────────────────────────────┐
│  Replaced text + allReplacementsForPNode (all records)      │
└──────────────────────────┬──────────────────────────────────┘
                           │
                    Phase 3 │ processTrackChanges()
                           │  • Walk records in order
                           │  • Emit &lt;style deleted&gt;/&lt;style added&gt; pairs
                           │  • Recurse for nested records
                           ▼
┌─────────────────────────────────────────────────────────────┐
│  Output: &lt;p&gt; with merged TC markup (old + new changes)      │
└─────────────────────────────────────────────────────────────┘
</programlisting>

    <section xml:id="s.phase1">
      <title>Phase 1: Unwrapping Pre-existing Track Changes</title>

      <para>The purpose of the first phase is to transform the paragraph from its mixed
        markup/track-changes form into clean text (still XML, but without
        <code>&lt;style status&gt;</code> wrapper elements), while recording the position and
        authorship of every pre-existing track change so it can be reinstated in Phase 3.</para>

      <para>The method traverses the child elements of the paragraph looking for
        <code>&lt;style&gt;</code> elements with a <code>status</code> attribute. For each
        <code>status="deleted"</code> element, it records the inner XML content as
        <code>deletedText</code>, finds the element's character offset within the serialized
        paragraph string, removes the element from the DOM, and creates a
        <code>ReplacementInfo</code> record. For each <code>status="added"</code> element, it moves
        the element's children directly into the parent paragraph (unwrapping the
        <code>&lt;style&gt;</code> wrapper), records the <code>addedText</code>, and creates a
        corresponding <code>ReplacementInfo</code> record. In both cases, the <code>author</code>
        and <code>dateTime</code> attributes from the original element are preserved in the
        <code>ReplacementInfo</code>.</para>

<programlisting xml:id="ex.phase1" language="java" xml:space="preserve">
// Phase 1 — simplified excerpt
if ("deleted".equalsIgnoreCase(status)) {
    deletedText = convertNodeToString(tc, transformer);
    start = pText.indexOf("&lt;style author=\"" + tc.getAttribute("author")
                          + "\" ... status=\"deleted\"&gt;" + deletedText + "&lt;/style&gt;");
    pElement.removeChild(tc);
}
if ("added".equalsIgnoreCase(status)) {
    addedText = convertNodeToString(tc, transformer);
    while (tc.hasChildNodes())
        pElement.insertBefore(tc.getFirstChild(), tc);
    pElement.removeChild(tc);
}
ReplacementInfo rInfo = new ReplacementInfo(
    "", 0, start, start + codePointCount(addedText),
    deletedText, addedText, true,
    tc.getAttribute("author"), tc.getAttribute("dateTime"));
allReplacementsForPNode.add(rInfo);
</programlisting>

      <para>After Phase 1, <code>pElement</code> contains the paragraph with track changes
        unwrapped—the <quote>added</quote> text is present inline and the <quote>deleted</quote>
        text is captured in <code>allReplacementsForPNode</code>. The paragraph is now ready for
        regex processing without any change-tracking markup interfering with pattern
        matching.</para>
    </section>

    <section xml:id="s.phase2">
      <title>Phase 2: Regex Replacement with Position Tracking</title>

      <para>Phase 2 processes the paragraph through each rule group in order. For each group it
        performs the following steps.</para>

      <para><emphasis role="bold">Serialization.</emphasis> The paragraph node is serialized to a
        string. Protected inline elements are replaced with fixed-width placeholder strings padded
        to the same code-point length as the serialized element. This allows the text positions of
        surrounding content to remain stable while preventing replacements from being applied
        inside protected markup.</para>

      <para><emphasis role="bold">Pattern matching.</emphasis> For each rule whose scope matches
        the paragraph's style, a <code>java.util.regex.Pattern</code> is compiled and applied to
        the serialized text. For each match, the code checks:</para>

      <orderedlist>
        <listitem>
          <para><emphasis role="bold">Overlap detection.</emphasis> If any previously found
            replacement <emphasis>within the same group</emphasis> overlaps the current match's
            [start, end) range, the match is skipped, preventing two rules from modifying the same
            character range within a single group. Overlaps across groups are permitted because each
            group operates on the output of the preceding group.</para>
        </listitem>
        <listitem>
          <para><emphasis role="bold">Tag context.</emphasis> A scan of the serialized text
            determines whether the match's span straddles or falls inside an XML tag boundary.
            Matches that overlap a tag are discarded; matches that encompass a complete tag are
            permitted (the match spans content <emphasis>around</emphasis> the tag, not inside
            it).</para>
        </listitem>
        <listitem>
          <para><emphasis role="bold">Case adjustment.</emphasis> For case-insensitive rules, the
            replacement's capitalization is adjusted to match the source: all-uppercase matches
            produce an uppercased replacement; initial-capital matches produce a capitalized
            replacement. This mirrors the case-preserving behavior of Microsoft Word's Find
            &amp; Replace when <quote>Match case</quote> is unchecked.</para>
        </listitem>
        <listitem>
          <para><emphasis role="bold">Group reference expansion.</emphasis> Back-references
            (<code>$0</code>, <code>$1</code>, <code>$2</code>, ...) in the replacement string are
            resolved using the groups captured by the original <code>Matcher</code>, preserving the
            surrounding context that lookahead and lookbehind assertions depend on.</para>
        </listitem>
      </orderedlist>

      <para>For each accepted match, a <code>ReplacementInfo</code> is added to the per-group
        local list.</para>

      <para><emphasis role="bold">Offset-aware application.</emphasis> After all rules in the group
        have been scanned, the per-group replacements are sorted by start position and applied
        sequentially to a <code>StringBuffer</code>:</para>

<programlisting xml:id="ex.phase2-offset" language="java" xml:space="preserve">
for (ReplacementInfo info : allReplacementsForGroup) {
    int offset = codePointCount(info.getReplacedText())
               - codePointCount(info.getOriginalText());
    buffer.replace(info.getStartIndex(), info.getEndIndex(),
                   info.getReplacedText());

    // Shift all subsequent replacements in the same group
    allReplacementsForGroup.stream()
        .filter(r -&gt; r.getStartIndex() &gt; info.getStartIndex())
        .forEach(r -&gt; {
            r.setStartIndex(r.getStartIndex() + offset);
            r.setEndIndex(r.getEndIndex() + offset);
        });

    // Shift TC records from earlier groups that fall after this position
    allReplacementsForPNode.stream()
        .filter(r -&gt; r.getStartIndex() &gt; info.getStartIndex()
                  &amp;&amp; r.getGroup() &lt; info.getGroup())
        .forEach(r -&gt; {
            r.setStartIndex(r.getStartIndex() + offset);
            r.setEndIndex(r.getEndIndex() + offset);
        });

    if (info.getEnableTc() &amp;&amp; !isSameText) {
        allReplacementsForPNode.add(new ReplacementInfo(
            info.getRegexId(), groupIndex,
            info.getStartIndex(), info.getEndIndex() + offset,
            info.getOriginalText(), info.getReplacedText(),
            true, "", ""));
    }
}
</programlisting>

      <para>The critical invariant is that <emphasis>every</emphasis> <code>ReplacementInfo</code>
        in <code>allReplacementsForPNode</code>—whether from Phase 1 (pre-existing track changes)
        or accumulated during Phase 2—always refers to positions in the
        <emphasis>current</emphasis> processed text, not in any earlier snapshot. The two-level
        filtering (same-group records vs. earlier-group records) maintains this invariant as groups
        are processed in sequence.</para>

      <para><emphasis role="bold">Placeholder restoration.</emphasis> After each group's
        replacements are applied, the placeholder strings for protected elements are expanded back
        to their full XML serializations.</para>
    </section>

    <section xml:id="s.phase3">
      <title>Phase 3: Reconstructing Track Changes Markup</title>

      <para>After Phase 2 completes, <code>currentProcessedText</code> is the fully replaced
        paragraph text as a flat string (still containing inline XML markup for bold, superscript,
        etc., but no <code>&lt;style status&gt;</code> wrappers), and
        <code>allReplacementsForPNode</code> is a sorted list of every change that should be
        recorded as a track change—both pre-existing editorial changes and new programmatic
        ones.</para>

      <para>The reconstruction method walks through <code>allReplacementsForPNode</code> in order
        and produces a new XML string:</para>

<programlisting xml:id="ex.phase3" language="java" xml:space="preserve">
public String processTrackChanges(String text,
        List&lt;ReplacementInfo&gt; replacements,
        int start, int end, ...) {

    StringBuilder result = new StringBuilder();
    int currentIndex = start;

    for (ReplacementInfo info : replacements) {
        // Skip records that are "shadows" of pre-existing TC changes
        boolean isSameText = replacements.stream().anyMatch(r -&gt;
            r.getStartIndex() == info.getStartIndex()
            &amp;&amp; r.getReplacedText().equals(info.getOriginalText()));

        if (info.getStartIndex() &gt; end
                || info.getEndIndex() &lt;= currentIndex
                || isSameText)
            continue;

        // Emit any text between the previous change and this one
        if (info.getStartIndex() &gt; currentIndex)
            result.append(text.substring(currentIndex, info.getStartIndex()));

        // Collect nested replacements
        List&lt;ReplacementInfo&gt; nested = replacements.stream()
            .filter(other -&gt; other != info
                &amp;&amp; (other.getStartIndex() &gt;= info.getStartIndex()
                    &amp;&amp; other.getEndIndex() &lt;= info.getEndIndex()
                    &amp;&amp; other.getGroup() &gt;= info.getGroup()
                    || other.getStartIndex() == info.getStartIndex()
                    &amp;&amp; other.getOriginalText().equals(info.getReplacedText())))
            .collect(toList());

        String addedContent = nested.isEmpty()
            ? info.getReplacedText()
            : processTrackChanges(text, nested,
                                  info.getStartIndex(), info.getEndIndex(), ...);

        String author = info.getAuthor().isEmpty()
            ? "Tool: " + info.getRegexId()
            : info.getAuthor();
        String timestamp = info.getDateTime().isEmpty()
            ? config.getTimeStamp()
            : info.getDateTime();

        result.append("&lt;style status=\"deleted\" author=\"" + author
                      + "\" dateTime=\"" + timestamp + "\"&gt;"
                      + info.getOriginalText() + "&lt;/style&gt;");
        result.append("&lt;style status=\"added\" author=\"" + author
                      + "\" dateTime=\"" + timestamp + "\"&gt;"
                      + addedContent + "&lt;/style&gt;");

        currentIndex = info.getEndIndex();
    }

    if (currentIndex &lt; end)
        result.append(text.substring(currentIndex, end));

    return result.toString();
}
</programlisting>

      <para>For programmatic changes, the author string is synthesized from the rule identifier and
        the timestamp is the current system time (or a fixed value for reproducible testing). For
        pre-existing editorial changes, the original author and timestamp are forwarded
        unchanged.</para>
    </section>
  </section>

  <!-- ============================================================ -->
  <section xml:id="s.nested-tc">
    <title>The Nested Track Changes Problem</title>

    <para>The most algorithmically interesting case arises when a programmatic replacement targets
      text that overlaps with a pre-existing editorial track change. Consider the following input
      paragraph:</para>

<programlisting xml:id="ex.nested-input" xml:space="preserve">
&lt;p type="Text para"&gt;Confirm that the
  &lt;style author="test user"
         dateTime="2025-09-24T11:02:58"
         status="deleted"&gt; One&lt;/style&gt;
  &lt;style author="test user"
         dateTime="2025-09-24T11:02:58"
         status="added"&gt;two&lt;/style&gt;
  is changed to a three&lt;/p&gt;
</programlisting>

    <para>An editor has replaced <quote> One</quote> with <quote>two</quote>. Now suppose a
      dictionary rule maps <code>two → three</code>. After Phase 1, the paragraph text is
      <code>"Confirm that the two is changed to a three"</code> (the deleted text 
      <quote> One</quote> is captured in <code>allReplacementsForPNode</code>, the added text
      <quote>two</quote> is unwrapped into the paragraph body). The rule matches <quote>two</quote>
      at its position in this text and adds a new <code>ReplacementInfo</code> to
      <code>allReplacementsForPNode</code>.</para>

    <para>After Phase 2, <code>allReplacementsForPNode</code> contains two records:</para>

    <table xml:id="t.nested-records">
      <caption><para>ReplacementInfo records after Phase 2 for the nested TC example
        (indices are illustrative)</para></caption>
      <thead>
        <tr>
          <th>#</th>
          <th>startIndex</th>
          <th>endIndex</th>
          <th>originalText</th>
          <th>replacedText</th>
          <th>author</th>
        </tr>
      </thead>
      <tbody>
        <tr>
          <td>1</td>
          <td>17</td>
          <td>20</td>
          <td><code> One</code></td>
          <td><code>two</code></td>
          <td>test user</td>
        </tr>
        <tr>
          <td>2</td>
          <td>17</td>
          <td>22</td>
          <td><code>two</code></td>
          <td><code>three</code></td>
          <td>Tool</td>
        </tr>
      </tbody>
    </table>

    <para>The relationship between these records—and the nesting that Phase 3 produces—is
      visualized below:</para>

<programlisting xml:id="fig.nested-record-flow" xml:space="preserve">
allReplacementsForPNode after Phase 2:

Record 1 (editorial):  |--- " One" → "two" ---|     author: test user
Record 2 (programmatic):    |-- "two" → "three" --|  author: Orion Smart Replace
                        17                       22
                             ↑
                     Record 2.originalText == Record 1.replacedText
                     → Record 2 is nested inside Record 1

Phase 3 output:

  &lt;style deleted&gt; One&lt;/style&gt;          ← Record 1 outer
  &lt;style added&gt;                        ← Record 1 outer
    &lt;style deleted&gt;two&lt;/style&gt;         ← Record 2 (nested)
    &lt;style added&gt;three&lt;/style&gt;         ← Record 2 (nested)
  &lt;/style&gt;
</programlisting>

    <para>Record 2's <code>originalText</code> equals Record 1's <code>replacedText</code>. This
      is the nested case: the programmatic replacement targets exactly the text that the editorial
      change introduced.</para>

    <para>In Phase 3, when processing Record 1, the algorithm finds that Record 2 satisfies the
      nesting condition:</para>

<programlisting xml:id="ex.nesting-condition" language="java" xml:space="preserve">
other.getStartIndex() == info.getStartIndex()
&amp;&amp; other.getOriginalText().equals(info.getReplacedText())
// → Record 2.startIndex == Record 1.startIndex
// → Record 2.originalText ("two") == Record 1.replacedText ("two")
</programlisting>

    <para>Record 2 is placed in the <code>nested</code> list, and
      <code>processTrackChanges()</code> is called recursively with <code>nested</code> as the
      replacement list. The recursive call produces:</para>

<programlisting xml:id="ex.nested-inner" xml:space="preserve">
&lt;style status="deleted" author="Orion Smart Replace: rule-id"
       dateTime="2024-01-09T12:00:00"&gt;two&lt;/style&gt;
&lt;style status="added" author="Orion Smart Replace: rule-id"
       dateTime="2024-01-09T12:00:00"&gt;three&lt;/style&gt;
</programlisting>

    <para>This becomes the body of the <code>status="added"</code> element for Record 1, yielding
      the final output:</para>

<programlisting xml:id="ex.nested-output" xml:space="preserve">
&lt;p type="Text para"&gt;Confirm that the
  &lt;style author="test user"
         dateTime="2025-09-24T11:02:58"
         status="deleted"&gt; One&lt;/style&gt;
  &lt;style author="test user"
         dateTime="2025-09-24T11:02:58"
         status="added"&gt;
    &lt;style author="Orion Smart Replace: rule-id"
           dateTime="2024-01-09T12:00:00"
           status="deleted"&gt;two&lt;/style&gt;
    &lt;style author="Orion Smart Replace: rule-id"
           dateTime="2024-01-09T12:00:00"
           status="added"&gt;three&lt;/style&gt;
  &lt;/style&gt;
  is changed to a three&lt;/p&gt;
</programlisting>

    <para>This representation accurately captures both layers of the editing history: a human
      editor changed <quote> One</quote> to <quote>two</quote>, and the automated tool subsequently
      changed <quote>two</quote> to <quote>three</quote>. A reviewer can accept or reject each
      change independently. The outer pair of <code>&lt;style&gt;</code> elements carries the
      editor's authorship; the inner pair carries the rule's identifier.</para>

    <para>This design requires a guard in Phase 2: when the regex engine matches
      <quote>two</quote> at position 17, Record 1 already exists in
      <code>allReplacementsForPNode</code> with the same <code>startIndex</code> and
      <code>replacedText</code> equal to the matched text. If Phase 2 were to add a second record
      unconditionally, the result would be a duplicate sibling rather than a nested child. An
      <code>isSameText</code> predicate detects this situation—it checks whether an existing record
      already covers the same position with a <code>replacedText</code> matching the current
      match's <code>originalText</code>—and suppresses the sibling record. Phase 3's recursive call
      then picks up the match via the semantic nesting condition, producing the correct nested
      structure shown above.</para>

    <section xml:id="s.crossgroup-nesting">
      <title>Cross-Group Positional Nesting</title>

      <para>The nesting condition in Phase 3 has two disjuncts. The second (semantic match:
        <code>other.originalText.equals(info.replacedText)</code>) detects genuine nesting as
        described above—a later rule modifies text that an earlier rule introduced. The first
        disjunct is purely positional: it checks whether one record's range falls inside another's.
        However, because offset propagation (<xref linkend="s.crossgroup"/>) retroactively shifts
        earlier-group records to account for length changes made by later groups, an earlier-group
        record's position may end up inside a later-group record's range as an artifact of the
        arithmetic, even though the two records operated on independent text in separate
        passes.</para>

      <para>To prevent the positional condition from misclassifying such cases as nested, the
        condition includes a group-ordering guard: positional containment is only recognized when
        the inner record belongs to the same or a later group than the outer record. Records from
        an earlier group can only be nested via the semantic condition. This separation ensures that
        the two disjuncts handle complementary cases: same-or-later-group nesting is detected by
        position, cross-group nesting by textual identity.</para>
    </section>
  </section>

  <!-- ============================================================ -->
  <section xml:id="s.offsets">
    <title>Offset Management</title>

    <para>A separate but closely related challenge is keeping all character positions in
      <code>allReplacementsForPNode</code> consistent as replacements of varying lengths are
      applied to the text. As noted in <xref linkend="s.ot-background"/>, this is a simplified
      instance of operational transformation: each replacement is an operation that may shift the
      positions of all subsequent operations.</para>

    <section xml:id="s.codepoints">
      <title>Code-Point Indexing</title>

      <para>The system uses Java's <code>String.codePointCount(0, length)</code> rather than
        <code>String.length()</code> for all position computations. This ensures correctness for
        Unicode content containing characters outside the Basic Multilingual Plane (represented as
        surrogate pairs in Java's UTF-16 <code>char</code> encoding), which appear in scientific
        and mathematical content—a common case in academic publishing.</para>
    </section>

    <section xml:id="s.intragroup">
      <title>Intra-Group Offset Propagation</title>

      <para>Within a single rule group, replacements are sorted by start position and applied
        left-to-right. After each application, the offset
        <code>Δ = codePointCount(replacedText) − codePointCount(originalText)</code> is propagated
        forward:</para>

      <itemizedlist>
        <listitem>
          <para>All remaining records in the same group with
            <code>startIndex &gt; currentStartIndex</code> have their <code>startIndex</code> and
            <code>endIndex</code> shifted by Δ.</para>
        </listitem>
        <listitem>
          <para>Records accumulated in <code>allReplacementsForPNode</code> from
            <emphasis>earlier</emphasis> groups (i.e., <code>group &lt; currentGroup</code>) that
            fall after the current replacement position are also shifted. This is necessary because
            those records were computed against earlier snapshots of the text.</para>
        </listitem>
      </itemizedlist>

      <para>The directional filter (<code>startIndex &gt; info.getStartIndex()</code>) ensures that
        only records positioned after the current replacement are shifted, since the replacement
        does not affect text before its start position.</para>

      <para>A concrete example illustrates how positions shift after a replacement:</para>

<programlisting xml:id="fig.offset-propagation" xml:space="preserve">
Before replacement:   "the  h  of onset"
                       0  4  6  8
                          ↑ match: " h " → " hour " (Δ = +3)

After replacement:    "the  hour  of onset"
                       0  4      9  11
                                 ↑  ↑
                          positions shifted by +3

Records after this position:  startIndex += 3, endIndex += 3
Records before this position: unchanged
</programlisting>
    </section>

    <section xml:id="s.crossgroup">
      <title>Cross-Group Consistency</title>

      <para>Rules in later groups see the text as modified by all earlier groups. When a later group
        produces a <code>ReplacementInfo</code>, its positions are already correct for the current
        text state. Earlier-group records in <code>allReplacementsForPNode</code> must be
        retroactively adjusted whenever a later-group replacement changes the text length. The
        condition <code>r.getGroup() &lt; info.getGroup()</code> in the offset propagation loop
        performs this retroactive adjustment.</para>

      <para>This approach means that after all groups are processed, every record in
        <code>allReplacementsForPNode</code>—regardless of which phase or which group produced
        it—is positioned relative to the same final text snapshot that Phase 3 operates on. A
        consequence is that an earlier-group record's position may be shifted into a later-group
        record's range, creating a positional coincidence that Phase 3 distinguishes from semantic
        nesting via the group-ordering guard (<xref linkend="s.crossgroup-nesting"/>).</para>
    </section>

    <section xml:id="s.tagcontext">
      <title>Tag Context Detection</title>

      <para>A particularly subtle offset concern involves XML tags embedded in the serialized
        paragraph text. A scanning method examines angle brackets to determine whether a regex
        match's span falls entirely within a tag's angle-bracket delimiters, partially straddles a
        tag boundary, or falls entirely between tags. Only matches classified as falling entirely
        outside of tags are accepted. A match that straddles a tag opening or closing is discarded
        without generating a <code>ReplacementInfo</code>, preventing the replacement engine from
        corrupting the XML markup.</para>
    </section>
  </section>

  <!-- ============================================================ -->
  <section xml:id="s.replacementinfo">
    <title>The ReplacementInfo Data Structure</title>

    <para>All information needed to reconstruct track changes flows through a single record,
      <code>ReplacementInfo</code>, whose fields encode both positional and authorship data:</para>

    <table xml:id="t.replacementinfo">
      <caption><para>Fields of the ReplacementInfo record</para></caption>
      <thead>
        <tr>
          <th>Field</th>
          <th>Type</th>
          <th>Purpose</th>
        </tr>
      </thead>
      <tbody>
        <tr>
          <td><code>startIndex</code></td>
          <td><code>int</code></td>
          <td>Code-point offset of the match start in the current text</td>
        </tr>
        <tr>
          <td><code>endIndex</code></td>
          <td><code>int</code></td>
          <td>Code-point offset one past the match end</td>
        </tr>
        <tr>
          <td><code>originalText</code></td>
          <td><code>String</code></td>
          <td>The matched (deleted) text</td>
        </tr>
        <tr>
          <td><code>replacedText</code></td>
          <td><code>String</code></td>
          <td>The replacement (added) text</td>
        </tr>
        <tr>
          <td><code>regexId</code></td>
          <td><code>String</code></td>
          <td>ID of the rule that produced this replacement (empty for Phase 1 records)</td>
        </tr>
        <tr>
          <td><code>group</code></td>
          <td><code>int</code></td>
          <td>Rule group number (used for offset filtering and nesting guard)</td>
        </tr>
        <tr>
          <td><code>enableTc</code></td>
          <td><code>boolean</code></td>
          <td>Whether this replacement should appear as a track change in the output</td>
        </tr>
        <tr>
          <td><code>author</code></td>
          <td><code>String</code></td>
          <td>Pre-existing track-changes author; empty for programmatic changes</td>
        </tr>
        <tr>
          <td><code>dateTime</code></td>
          <td><code>String</code></td>
          <td>Pre-existing track-changes timestamp; empty for programmatic changes</td>
        </tr>
      </tbody>
    </table>

    <para>The duality of <code>author</code> / <code>dateTime</code> being either populated
      (Phase 1, pre-existing track changes) or empty (Phase 2, programmatic) drives the authorship
      logic in Phase 3: a non-empty <code>author</code> means the original metadata is forwarded
      as-is; an empty <code>author</code> means the tool attribution and the current timestamp are
      synthesized.</para>

    <para>The <code>enableTc</code> field allows per-rule opt-out of track change generation. A
      rule with <code>enable-tc="false"</code> applies its replacement silently: the text changes,
      but no <code>&lt;style status&gt;</code> elements are emitted. This is useful for
      normalization passes (e.g., collapsing redundant whitespace) that should not clutter the
      reviewer's view. It is also necessary when the replacement text is not well-formed XML in
      isolation—for example, a rule that inserts an opening tag without a corresponding closing
      tag, or vice versa. Wrapping such a replacement in
      <code>&lt;style status="added"&gt;</code> would produce invalid XML; disabling track
      changes for the rule avoids this problem.</para>
  </section>

  <!-- ============================================================ -->
  <section xml:id="s.discussion">
    <title>Discussion</title>

    <section xml:id="s.tradeoffs">
      <title>Design Trade-offs</title>

      <para><emphasis role="bold">DOM vs. streaming.</emphasis> The implementation uses DOM
        (Document Object Model) <xref linkend="bib-dom"/> parsing throughout. For the document sizes
        encountered in publishing workflows—typically tens of thousands of words per document—DOM's
        memory model is acceptable. A streaming approach (SAX or StAX) would complicate the
        paragraph-reversal traversal and the node-replacement operations that are central to the
        pipeline.</para>

      <para><emphasis role="bold">Paragraph-level granularity.</emphasis> Track changes
        reconstruction operates at the paragraph (<code>&lt;p&gt;</code>) level. Each paragraph is
        processed independently: it is extracted as a DOM subtree, serialized to string, processed,
        and replaced in the document. This granularity matches the editorial workflow assumption that
        reviewers accept or reject changes paragraph by paragraph. It also provides error isolation:
        if a single paragraph produces malformed XML during reconstruction, only that paragraph
        fails; the remaining paragraphs are still processed.</para>

      <para><emphasis role="bold">Serialization round-tripping.</emphasis>
        The pipeline repeatedly converts between DOM nodes and strings. Each round-trip wraps the
        target string in a temporary element before parsing, then imports the resulting child nodes
        into the main document as a <code>DocumentFragment</code>. The implementation
        configures the serializer with <code>OMIT_XML_DECLARATION=yes</code> and
        <code>INDENT=no</code> to prevent namespace or whitespace artifacts.</para>
    </section>

    <section xml:id="s.limitations">
      <title>Limitations</title>

      <para><emphasis role="bold">Single-layer nesting.</emphasis> The current implementation
        handles the two most common scenarios: (a) programmatic changes applied to a clean document,
        and (b) programmatic changes applied to a document that already contains at most one layer of
        pre-existing track changes per position. Deeply nested track changes (three or more layers, arising from multiple sequential
        editing passes—human or programmatic—whose results have not yet been accepted) are
        not excluded by the data model but are not a tested path, and the recursive
        <code>processTrackChanges()</code> call would need to be evaluated carefully for correctness
        in that case.</para>

      <para><emphasis role="bold">Overlapping changes across positions.</emphasis> Consider the case
        where an editor has changed <quote>alpha</quote> to <quote>beta</quote> and an automated
        rule targets <quote>alphabet</quote> in the original text. The string
        <quote>alphabet</quote> spans the boundary of the editorial change:
        <quote>alpha</quote> has been replaced by <quote>beta</quote>, and <quote>bet</quote> is
        now followed by whatever came after. In the element-wrapping model, there is no clean
        representation of a change that straddles a pre-existing change boundary. The current
        implementation handles this conservatively: Phase 1 unwraps the editorial change, so the
        text seen by Phase 2 reflects the <emphasis>current</emphasis> state
        (<quote>beta...</quote>), not the original (<quote>alpha...</quote>). A rule targeting
        the original text <quote>alphabet</quote> would simply not match. This is correct
        behavior—the tool processes the document as it stands—but it means the tool cannot reason
        about the original pre-change text. Word processors with richer change-tracking models
        (such as OOXML's run-fragmentation and anchor-based approach) can represent finer-grained
        overlapping edits, at the cost of considerably more complex markup.</para>

      <para><emphasis role="bold">Rule ordering.</emphasis> Because replacements within a group are
        sorted by start position and applied left-to-right, the result is deterministic given a
        fixed rule order. However, the interaction between rule groups—where a later group may
        replace text introduced by an earlier group—is order-dependent. This is intentional:
        dictionary precedence gives authors control over the order in which transformations are
        applied. The nested track-changes representation faithfully records this layering so
        reviewers can see which automated step produced which change.</para>
    </section>

    <section xml:id="s.xslt">
      <title>Comparison with XSLT-Based Approaches</title>

      <para>An alternative implementation strategy would use XSLT to apply the replacement rules.
        The tree-native approach to regex replacement, via <code>xsl:analyze-string</code>
        (XSLT 2.0+) or <code>fn:analyze-string</code> (XPath/XQuery 3.0+), provides a solid
        foundation for this kind of work <xref linkend="bib-xslt30"/>,
        <xref linkend="bib-xpath31"/>. In a purely tree-native design, regex operations would
        target individual text nodes, avoiding serialization of the full paragraph markup. However,
        since the existing dictionaries contain patterns that match across inline element
        boundaries, an XSLT implementation would still need to serialize paragraph content to a
        string for matching and parse the result back into the tree—the same round-trip the current
        Java implementation performs.</para>

      <para>The regex capabilities of XPath 3.0 cover most of Java's
        <code>java.util.regex</code> features. The notable gaps are: lookbehind assertions (XPath
        regex supports lookahead <code>(?=...)</code> and <code>(?!...)</code> but not lookbehind
        <code>(?&lt;=...)</code> and <code>(?&lt;!...)</code>), possessive quantifiers, and certain
        Unicode property shortcuts. Saxon's <code>j</code> flag for <code>fn:matches</code> and
        <code>fn:replace</code> provides access to Java's full regex engine, bridging most of these
        gaps for deployments that use Saxon <xref linkend="bib-saxon"/>. XPath 4.0 (currently in
        draft) proposes adding lookbehind support to the standard
        <xref linkend="bib-xpath40"/>.</para>

      <para>Kalvesmaki's <code>tan:batch-replace()</code> <xref linkend="bib-kalvesmaki"/> could
        serve as the replacement engine for Phase 2, but it is opaque with respect to
        per-replacement positional metadata: it returns transformed text but not the replacement
        log—start position, end position, original text, replaced text, and group number for each
        match—that Phase 3's track-changes reconstruction requires.</para>

      <para>However, the core algorithm described in this paper is fundamentally stateful and
        imperative. <code>ReplacementInfo</code> records are accumulated, mutated in-place, filtered
        by group, sorted, and passed through recursive calls. The offset propagation requires
        in-place mutation of a shared list with different filtering criteria at each step. While XSLT
        3.0 <emphasis>can</emphasis> model this using maps, arrays, <code>xsl:iterate</code>, and
        accumulators <xref linkend="bib-xslt30"/>, the natural grain of XSLT is declarative tree
        transformation—not maintaining and mutating shared position records across sequential passes.
        XSLT accumulators, in particular, could be used for offset tracking: an accumulator that
        observes text nodes could maintain a running position counter. But the two-level offset
        adjustment (same-group vs. earlier-group) and the recursive nesting detection would require
        threading complex state through the accumulator or restructuring the algorithm into a more
        functional form.</para>

      <para>The decision to use Java was driven by three factors: integration requirements with
        Typefi's plugin API, the natural fit of the imperative algorithm with Java's mutable data
        structures, and compatibility with a large corpus of existing Orion dictionaries whose regex
        patterns were authored against serialized paragraph text. An XSLT-based reimplementation is
        feasible and might benefit from the tree-native approach for the replacement step, but would
        require either rewriting these dictionaries to operate on text nodes or replicating the
        serialization strategy, and the track-changes reconstruction logic would likely need to be
        restructured rather than transliterated.</para>
    </section>
  </section>

  <!-- ============================================================ -->
  <section xml:id="s.conclusion">
    <title>Conclusion</title>

    <para>This paper has described a three-phase pipeline for applying regex-based text replacements
      to XML documents while generating well-formed track changes markup that integrates both
      pre-existing editorial changes and new programmatic changes. The core technical challenge is
      maintaining correct character-position accounting across multiple sequential replacements with
      varying lengths—a simplified instance of operational transformation solved by the
      <code>ReplacementInfo</code> record and the two-level offset propagation strategy. The
      secondary challenge is correctly handling cases where a programmatic replacement targets text
      that was itself introduced by a prior editorial change; this is resolved by recursive track
      changes reconstruction in Phase 3. A related subtlety is distinguishing genuine semantic
      nesting from positional coincidence introduced by cross-group offset propagation; the nesting
      condition uses a group-ordering guard to separate these cases
      (<xref linkend="s.crossgroup-nesting"/>).</para>

    <para>The approach is implemented as Orion Smart Replace, a Java plugin for Typefi that
      processes documents in the CXML element-wrapping change-tracking format
      <xref linkend="bib-cxml"/>. The string-based replacement strategy is driven by compatibility
      with a large corpus of existing Orion dictionaries whose regex patterns were authored against
      serialized paragraph text. The implementation is validated by a suite of over 40 integration
      tests covering scenarios including overlapping replacements, protected inline elements,
      Unicode code-point indexing, nested track changes, and multi-group rule interactions.</para>

    <para>Future work includes extending the model to handle multiple layers of pre-existing
      track-changes nesting and investigating whether a hybrid approach—tree-native replacement
      (e.g., via <code>xsl:analyze-string</code>) combined with imperative track-changes
      reconstruction—could reduce the serialization round-trips while preserving both the
      algorithm's correctness properties and compatibility with existing dictionary
      patterns.</para>
  </section>

  <!-- ============================================================ -->
  <section xml:id="acknowledgements">
<title>Acknowledgements</title>
    <para>The author thanks the Typefi team for their contributions to this work. The architecture
      of the pipeline was developed in collaboration with the Typefi XSLT/XML team, particularly
      Max Zhaloba, whose
      insights into the interaction between string-based replacement and tree-based processing
      informed key architectural decisions. Ben Hauser inspired the initial decision to implement the solution
      in Java. Test user provided valuable feedback through his review of the
      manuscript.</para>
  </section>

  <!-- ============================================================ -->
  <appendix xml:id="appendix.test-case">
    <title>Validation Test Case — Nested Track Changes</title>

    <para>This appendix presents the key test case from the integration test suite that validates
      the nested track-changes scenario described in <xref linkend="s.nested-tc"/>.</para>

    <section xml:id="s.test-input">
      <title>Input</title>

      <para>The input document contains a paragraph with a pre-existing editorial change: an editor
        (test user) has replaced <quote> One</quote> with <quote>two</quote>.</para>

<programlisting xml:id="ex.test-input" xml:space="preserve">
&lt;p type="Text para"&gt;Confirm that the
  &lt;style author="test user"
         dateTime="2025-09-24T11:02:58"
         status="deleted"&gt; One&lt;/style&gt;
  &lt;style author="test user"
         dateTime="2025-09-24T11:02:58"
         status="added"&gt;two&lt;/style&gt;
  is changed to a three&lt;/p&gt;
</programlisting>
    </section>

    <section xml:id="s.test-rule">
      <title>Rule</title>

      <para>A replacement rule maps <quote>two</quote> → <quote>three</quote> with track changes
        enabled:</para>

<programlisting xml:id="ex.test-rule" xml:space="preserve">
&lt;regex case-sensitive="true" enable-tc="true"
       id="Test-58-1" whole-word="false" format="wildcard"&gt;
  &lt;find&gt;two&lt;/find&gt;
  &lt;replace&gt;three&lt;/replace&gt;
  &lt;scope&gt;&lt;include&gt;&lt;item&gt;Body&lt;/item&gt;&lt;/include&gt;&lt;/scope&gt;
&lt;/regex&gt;
</programlisting>
    </section>

    <section xml:id="s.test-output">
      <title>Expected Output</title>

      <para>The expected output nests the automated change inside the editorial change. Robin
        Dunford's outer change (<quote> One</quote> → ...) is preserved. Inside the
        <code>&lt;style status="added"&gt;</code> element for that change, the tool's change
        (<quote>two</quote> → <quote>three</quote>) appears as a nested pair:</para>

<programlisting xml:id="ex.test-output" xml:space="preserve">
&lt;p type="Text para"&gt;Confirm that the
  &lt;style author="Orion Smart Replace: Test-58-1"
         dateTime="2024-01-09T12:00:00"
         status="deleted"&gt;two&lt;/style&gt;
  &lt;style author="Orion Smart Replace: Test-58-1"
         dateTime="2024-01-09T12:00:00"
         status="added"&gt;three&lt;/style&gt;
  in the text
  &lt;style author="test user"
         dateTime="2025-09-24T11:02:58"
         status="deleted"&gt; One&lt;/style&gt;
  &lt;style author="test user"
         dateTime="2025-09-24T11:02:58"
         status="added"/&gt;
  &lt;style author="test user"
         dateTime="2025-09-24T11:02:58"
         status="added"&gt;
    &lt;style author="Orion Smart Replace: Test-58-1"
           dateTime="2024-01-09T12:00:00"
           status="deleted"&gt;two&lt;/style&gt;
    &lt;style author="Orion Smart Replace: Test-58-1"
           dateTime="2024-01-09T12:00:00"
           status="added"&gt;three&lt;/style&gt;
  &lt;/style&gt;
  is changed to a three&lt;/p&gt;
</programlisting>

      <para>The first occurrence of <quote>two</quote> → <quote>three</quote> (at the start of the
        paragraph text) is a direct replacement. The second occurrence, inside the
        <code>&lt;style status="added"&gt;</code> element, is the nested case: the rule has modified
        text that was itself introduced by the editor's earlier change. Both layers of editing
        history are preserved and independently reviewable.</para>
    </section>

    <section xml:id="s.test-annotations">
      <title>Annotations</title>

      <para>The test uses a fixed timestamp (<code>2024-01-09T12:00:00</code>) for all
        tool-generated changes, making the output deterministic and directly comparable to the
        expected file. The comparison is performed using XMLUnit
        <xref linkend="bib-xmlunit"/> with whitespace normalization, so that serialization
        formatting differences do not cause false failures.</para>
    </section>
  </appendix>

  <!-- ============================================================ -->
  <bibliography>
    <title>References</title>

    <bibliomixed xml:id="bib-ecma376" xreflabel="[1]">
      Bryan Schnabel, ed. <emphasis>Office Open XML File Formats — Part 1: Fundamentals and
        Markup Language Reference</emphasis>. ECMA-376, 5th edition. ECMA International, 2016.
    </bibliomixed>

    <bibliomixed xml:id="bib-odf" xreflabel="[2]">
      OASIS. <emphasis>Open Document Format for Office Applications (OpenDocument)
        Version 1.3</emphasis>. OASIS Standard, 2021.
    </bibliomixed>

    <bibliomixed xml:id="bib-dom" xreflabel="[3]">
      W3C. <emphasis>Document Object Model (DOM) Level 3 Core Specification</emphasis>. 
      W3C Recommendation, April 2004.
    </bibliomixed>

    <bibliomixed xml:id="bib-java-regex" xreflabel="[4]">
      Oracle. <emphasis>Java SE 21: java.util.regex — Pattern</emphasis>. 
      <link xlink:href="https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/regex/Pattern.html" xlink:type="simple" xlink:show="new" xlink:actuate="onRequest">
        https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/regex/Pattern.html</link>,
      2023.
    </bibliomixed>

    <bibliomixed xml:id="bib-xmlunit" xreflabel="[5]">
      XMLUnit Contributors. <emphasis>XMLUnit for Java, Version 2</emphasis>.
      GitHub, 2024.
    </bibliomixed>

    <bibliomixed xml:id="bib-cxml" xreflabel="[6]">
      Typefi Systems. <emphasis>ContentXML Schema Reference, Version 3.4</emphasis>. 
      Typefi documentation, 2025.
    </bibliomixed>

    <bibliomixed xml:id="bib-ellis-gibbs" xreflabel="[7]">
      C. A. Ellis and S. J. Gibbs. <quote>Concurrency Control in Groupware Systems</quote>.  
      <emphasis>Proceedings of the 1989 ACM SIGMOD International Conference on Management of
        Data</emphasis>, pp. 399–407, 1989. doi:<biblioid class="doi">10.1145/67544.6696</biblioid>.
    </bibliomixed>

    <bibliomixed xml:id="bib-oxygen" xreflabel="[8]">
      SyncRO Soft. <emphasis>oXygen XML Editor: Track Changes</emphasis>. 
      <link xlink:href="https://www.oxygenxml.com/doc/versions/26.0/ug-editor/topics/track-changes-format.html" xlink:type="simple" xlink:show="new" xlink:actuate="onRequest">
        https://www.oxygenxml.com/doc/versions/26.0/ug-editor/topics/track-changes-format.html</link>.
    </bibliomixed>

    <bibliomixed xml:id="bib-deltaxml" xreflabel="[9]">
      DeltaXML Ltd. <emphasis>DeltaXML: XML-Aware Differencing and Merging</emphasis>. 
      <link xlink:href="https://www.deltaxml.com/" xlink:type="simple" xlink:show="new" xlink:actuate="onRequest">https://www.deltaxml.com/</link>.
    </bibliomixed>

    <bibliomixed xml:id="bib-lafontaine-2014" xreflabel="[10]">
      Robin La Fontaine. <quote>Standard Change Tracking for XML</quote>. 
      <emphasis>Proceedings of Balisage: The Markup Conference 2014</emphasis>. Balisage Series on
      Markup Technologies, vol. 13, 2014. doi:<biblioid class="doi">10.4242/BalisageVol13.LaFontaine01</biblioid>.
    </bibliomixed>

    <bibliomixed xml:id="bib-lafontaine-2010" xreflabel="[11]">
      Robin La Fontaine. <quote>Approaches to Change Tracking in XML</quote>. 
      <emphasis>XML Prague 2010</emphasis>, March 2010.
    </bibliomixed>

    <bibliomixed xml:id="bib-xslt30" xreflabel="[12]">
      Michael Kay, ed. <emphasis>XSL Transformations (XSLT) Version 3.0</emphasis>. 
      W3C Recommendation, 8 June 2017.
      <link xlink:href="https://www.w3.org/TR/xslt-30/" xlink:type="simple" xlink:show="new" xlink:actuate="onRequest">https://www.w3.org/TR/xslt-30/</link>.
    </bibliomixed>

    <bibliomixed xml:id="bib-xpath31" xreflabel="[13]">
      Jonathan Robie et al., eds. <emphasis>XML Path Language (XPath) 3.1</emphasis>.
      W3C Recommendation, 21 March 2017.
      <link xlink:href="https://www.w3.org/TR/xpath-31/" xlink:type="simple" xlink:show="new" xlink:actuate="onRequest">https://www.w3.org/TR/xpath-31/</link>.
    </bibliomixed>

    <bibliomixed xml:id="bib-kalvesmaki" xreflabel="[14]">
      Joel Kalvesmaki. <quote>A New ⟨u⟩: Extending XPath Regular Expressions for Unicode</quote>. 
      <emphasis>Proceedings of Balisage: The Markup Conference 2020</emphasis>. Balisage Series on
      Markup Technologies, vol. 25, 2020. doi:<biblioid class="doi">10.4242/BalisageVol25.Kalvesmaki01</biblioid>.
    </bibliomixed>

    <bibliomixed xml:id="bib-saxon" xreflabel="[15]">
      Saxonica. <emphasis>Saxon XSLT and XQuery Processor: Regular Expressions</emphasis>. 
      <link xlink:href="https://www.saxonica.com/documentation12/index.html#!functions/fn/matches" xlink:type="simple" xlink:show="new" xlink:actuate="onRequest">
        https://www.saxonica.com/documentation12/index.html#!functions/fn/matches</link>.
    </bibliomixed>

    <bibliomixed xml:id="bib-xpath40" xreflabel="[16]">
      Michael Kay et al., eds. <emphasis>XML Path Language (XPath) 4.0</emphasis>. 
      W3C Community Group Draft.
      <link xlink:href="https://qt4cg.org/specifications/xquery-40/xpath-40.html" xlink:type="simple" xlink:show="new" xlink:actuate="onRequest">
        https://qt4cg.org/specifications/xquery-40/xpath-40.html</link>.
    </bibliomixed>
  </bibliography>



</article>