Skip to content

Replace text inside content controls at every level - #195

Open
hadim wants to merge 10 commits into
tensorbee:mainfrom
hadim:fix/replace-reaches-content-controls
Open

hadim wants to merge 10 commits into
tensorbee:mainfrom
hadim:fix/replace-reaches-content-controls

Conversation

@hadim

@hadim hadim commented Sep 27, 2026

Copy link
Copy Markdown
Contributor

Stacked on #184 (fix/compare-producer-noise) and #180
(fix/text-box-rewrite-keeps-all-children). The first five commits of this
branch are #184, and the sixth is the code commit of #180, cherry-picked.
They are reviewed there. Please review the last four commits only, or the
diff against 3b9d4ccd. I will rebase once #184 and #180 land.

Summary

  • Keep paragraph anchors in place when replacement removes a run. A
    match across runs removes the runs it empties. It used a plain retain
    over the direct runs and then clamped the hyperlink spans, so every
    bookmark, comment range, inline content control, raw child, revision,
    equation and ruby span after a removed run slid one run later. The removal
    now goes through the remapping that comment removal already used, moved
    into a shared CT_P::remove_runs (rdocx-oxml text.rs). That remapping
    left the ruby spans out, so it now moves them too, which also fixes comment
    removal, and drops an annotation left without base runs. Only a run the
    replacement emptied is removed. Before, a match removed every run of the
    paragraph without typed content, including a run whose only child is a
    drawing in mc:AlternateContent (the adjacent defect noted in Keep every child of a text box that replacement rewrites #180).
  • Replace text inside content controls at every level. The body walkers
    of try_replace_text, replace_all, replace_regex and
    replace_all_regex go through the visitor that already reaches tables,
    cells and controls. replace_in_paragraph and replace_regex_in_paragraph
    read the runs of inline controls, nested ones included, in the order
    CT_P::runs reads them. A match that straddles a content-control boundary
    is not replaced, and the search goes on after it. The text box walker edits
    its tables and block controls through the typed parsers and keeps the
    namespace declarations of the elements it rewrites. render_template reads
    its tags the same way.
  • Replace inside header and footer tables and keep their place.
    CT_HdrFtr records where each raw child (table, content control) sat
    between the paragraphs, and the namespace bindings of its root. A rewrite
    puts each child back in place instead of after the last paragraph, and
    replacement parses a raw table or block control with the typed parsers and
    writes back only one it changed. CT_HdrFtr::from_xml no longer trims the
    text events a raw child is captured with, so Page before a page number
    keeps its space.
  • Re-record the archive measurements of rdocx, rdocx-oxml and
    rdocx-cli on top of the stacked tree.

Part of #160. This closes the replace side of section 1: try_replace_text,
rdocx replace and the regex replacement now reach content controls in the
body, inline, in table cells, nested, in text boxes, and in headers and
footers. The read side of section 1 (rdocx text, images, word count,
headings, links) is #177. The Markdown, HTML and EPUB exporters and the other
sections of #160 are outside this PR.

Why

Google Docs exports wrap runs, and whole paragraphs, in goog_rdk_N content
controls, and Word keeps them. Word writes its page numbers in a footer as a
content control too. On main:

  • the body walkers matched BodyContent::ContentControl(_) to nothing, so a
    paragraph wrapped at body level was never read.
  • replace_in_paragraph built its text from the direct runs only. A run in
    an inline control was missed at every level, and since Python: story_items and hyperlinks take quadratic time; Paragraph.text and Paragraph.runs skip tracked insertions and inline content controls #118 the read view
    (Paragraph.text, Paragraph.runs) shows that run, so the two views
    disagreed on the same run. replace_in_sdt returned 0 for
    SdtContent::Run.
  • The direct runs on both sides of an inline control were read as one text,
    so al + control + pha matched alpha, a word the reader does not see.
  • A header or footer keeps its tables and controls as raw XML without a
    position. Replacement never read them, and when it rewrote the part for a
    paragraph hit, it wrote them all after the last paragraph, so a footer's
    page-number control moved below its text. The raw copy of each of them had
    also lost the edge spaces of its text, since the part was read with trimmed
    text events.
  • The text box walker copied its tables and block controls without reading
    them (and deleted them before Keep every child of a text box that replacement rewrites #180).

Without --expect, the CLI and the Python call succeeded with nothing
replaced. With it, rdocx replace failed with found 0.

Notes

Decisions taken, as agreed for this issue:

  • A match that straddles a content-control boundary is no match. It is
    counted as no match and left as it is, never half replaced. A match must
    lie within one stretch of runs: the direct runs between two inline
    controls, or the runs of one control between its nested controls. The
    search runs over the whole paragraph text, so ^, $ and \b keep their
    paragraph meaning, and matches are found left to right without overlap, as
    str::replace and Regex::replace_all find them. A straddling match is
    skipped whole and the search goes on after it, so no later match starts
    inside it. Without that, \d+ over 12 and a control holding 34 would
    replace the 34 alone. For a literal, the only difference from searching
    each stretch on its own is a literal that overlaps itself, such as aa
    over a and a control holding aa, which is left as it is.
  • Headers get positional raw blocks with targeted typed replacement, not a
    full typed body model. CT_HdrFtr keeps paragraphs and extra_xml as
    they are. A raw block is parsed only when replacement visits it, and
    re-serialised only when it holds a match, so every other block keeps its
    bytes.
  • A rewritten raw block keeps its namespaces or is left alone. The typed
    writers leave out namespace declarations. For a raw table or block control
    in a header, footer or text box, the declarations of its start tag are
    written again on the rewritten element. A block that declares a namespace
    deeper down, on a cell for example, with a namespace the part does not bind
    that prefix to, keeps its bytes and counts 0, since its rewrite would leave
    the prefix unbound or bound to another namespace. A redundant declaration,
    same namespace as the part root, does not block the rewrite.

Behaviour changes:

  • Replacement counts now include text in content controls, in the Rust API,
    the Python binding (same native counts), the CLI and the WASM binding.
    Before, --expect failed on such files and the unguarded calls silently
    replaced nothing.
  • Word's table of contents is now searched. Word writes it as a
    body-level control (docPartGallery "Table of Contents"), and each entry
    holds the text of its heading. A heading word therefore counts once more
    for its entry: try_replace_text("Introduction", "Overview") returns 2 on a
    document with that heading and its entry, where it returned 1, so
    rdocx replace --expect 1 on such a file now fails with found 2, and
    render_template renders a tag in a cached entry. The same holds for other
    docPart controls such as a cover page or a page-number control. The
    regression test a_table_of_contents_entry_is_replaced_with_its_heading
    pins it.
  • A run the replacement empties is removed with every anchor remapped. A run
    it did not empty is kept, an empty run or a run holding only raw XML
    included. A run that keeps only its start-tag attributes (w:rsidR) counts
    as emptied. A hyperlink left without runs is dropped, as comment removal
    already does, and so is a ruby annotation left without base runs, which the
    writer skipped anyway.
  • Inside an inline control, an emptied run is removed too, and the later runs
    keep the source bytes they were read with (CT_Sdt::remove_content keeps
    the content-index bookkeeping aligned).
  • render_template: a tag inside an inline control, or in a table or control
    of a text box or a header, is rendered. Before it was left in the output.
    A tag that a control boundary splits is reported as an invalid template.
    Before, it was an error too when one half sat inside the control, and it
    was rendered across the control when both halves sat in direct runs around
    it. A {% %} marker that fills an inline control alone in its paragraph is
    now recognised.
  • A header or footer rewritten by replacement keeps its tables and controls
    between the paragraphs where they were, with the edge spaces of their text.
    This applies to every typed rewrite of a loaded header, but replacement is
    the only one on main. Reading a header without trimming changes nothing
    else: the typed paragraphs already kept their text, and the text between
    the children of the root is still skipped. The hash harness is unchanged.

API changes, none breaking:

  • rdocx-oxml placeholder: the public replacement functions keep their
    signatures and change behaviour as above. replace_in_header_footer,
    replace_regex_in_header_footer and the three *_xml_part functions also
    reach raw tables and block controls. Three #[doc(hidden)] functions give
    the template what a replacement reads: replaceable_texts,
    header_footer_replaceable_texts and xml_part_replaceable_texts.
  • CT_HdrFtr gains two fields that are not public. It already had a private
    field (watermarks), so no caller can build it with a struct literal, and
    this does not break the published type.
  • The literal and regex replacement now share one table, row, cell and
    control recursion taking a paragraph callback, instead of two copies.
    Replacement addresses a run with the existing AcceptedRunPath.

Decisions left to the maintainer:

  • The table of contents. Recommendation: keep searching it. Editing the
    cached entries keeps them consistent with their headings until the next
    TOC update, it follows the acceptance of Producer traits: a matrix over every operation, and what still fails in it and around it #160 section 1 (every level), and
    skipping docPart controls would bring back a mismatch between the read and
    replace views, since the read side reaches those controls too (Make the body read walkers reach content controls #177).
  • A block whose namespaces the rewrite cannot keep counts 0 and keeps its
    bytes, where the body path fails the whole call (cannot identify retained ... nested namespace owner). Recommendation: keep it, since the header and
    text box walkers return counts, the saved part stays well formed, and
    --expect reports the missing replacement.
  • Tracked insertions. Replacement does not enter a w:ins or w:moveTo
    wrapper, as on main, and such a wrapper does not split a stretch.
    CT_P::text does not read them either, while ParagraphRef::text does.
    Recommendation: keep it out of replacement, since an edit there changes a
    revision, and decide it with the revision work.
  • Controls the typed parser refuses stay opaque, as elsewhere. Their text
    is neither read nor replaced, and they do not split a stretch.
    Recommendation: leave it until the parser admits them.
  • Public helpers. The three text functions are hidden because only the
    template uses them. Recommendation: keep them hidden.

Interactions with other PRs:

Other notes:

  • The hash harness matches 49 of 49. No sample has a content control, a
    header table or a cross-run placeholder that empties a run.
  • The archive rows keep the date column the script writes. The rdocx row is
    re-measured by other PRs of this batch too, so it needs one fresh
    measurement at integration.

Tests

Added:

  • rdocx-oxml placeholder.rs unit tests:
    • removing_emptied_runs_keeps_later_anchors_in_place: {{na + me}},
      then a bookmark start, a comment range start, an inline control, a
      w:proofErr and a ruby over BASE before x. After the literal and the
      regex replacement, every anchor still precedes x and the ruby still
      wraps BASE.
    • replace_keeps_the_runs_it_did_not_empty: an empty run and a run holding
      only mc:AlternateContent survive a match elsewhere in the paragraph.
    • a_control_run_after_emptied_ones_keeps_its_source_bytes: a match across
      two runs of an inline control removes the emptied one, and the third run
      is written with the exact bytes it was read with.
    • a_match_stays_within_one_stretch_of_runs: replaceable_texts splits at
      every control boundary, nested ones included, five straddling literals
      replace nothing, and a regex with ^ and $ anchors at the paragraph
      edges.
    • no_match_starts_inside_a_straddling_one: \d+ and \d{2,} over 12,
      a control holding 34 end and 56 replace only 56.
    • replace_in_textbox_reaches_its_tables_and_controls: the text box
      fixture of Keep every child of a text box that replacement rewrites #180 with placeholders in its table and control. Four hits, in
      order, and the untouched table of the second text box keeps its bytes.
    • replace_in_textbox_keeps_the_namespaces_its_tables_declare and
      replace_in_header_footer_keeps_the_namespaces_its_tables_declare: a
      table declaring w14 on its start tag is replaced and every prefix of the
      part resolves. One declaring it on a cell keeps its bytes, unless the
      header root binds w14 the same way, where it is replaced too.
    • replace_in_header_footer_reaches_its_tables_and_controls: a q:
      prefixed header with a table, a paragraph, a control and an untouched
      control. Three hits in order, and the untouched control keeps its bytes.
  • rdocx-oxml header_footer.rs:
    raw_children_keep_their_place_between_paragraphs and
    a_raw_child_keeps_the_edge_spaces_of_its_text (a pretty-printed footer
    whose page-number control holds Page ).
  • rdocx regression_test.rs: mod replacement_reaches_content_controls,
    placed next to the text box replacement module of Keep every child of a text box that replacement rewrites #180:
    • every_walker_replaces_the_tag_of_every_location_once: the matrix. Ten
      locations (body block control, inline control, cell control, four nested
      controls, a block control and an inline control in a text box, a header
      table, a header control, the footer page-number control, an inline control
      in a footer paragraph) against try_replace_text, replace_regex and
      replace_all. Each cell checks the count of 1, the saved part, every
      other tag, every w:tag, the order and bytes of the header and footer
      blocks, and the trailing space of the run before each tag in those
      blocks.
    • a_template_renders_the_tag_of_every_location: one render_template
      over the ten locations.
    • a_match_that_straddles_a_control_boundary_is_not_replaced: al +
      control pha one, al + control X + pha two, and al + control
      alpha three, with both walkers, checked through Paragraph::text.
    • a_quantified_match_that_straddles_a_control_boundary_is_not_replaced:
      \d+ and \d{2,} over Order 12, a control holding 34 end and 56
      give Order 1234 end N.
    • a_table_of_contents_entry_is_replaced_with_its_heading: a Word-shaped
      TOC control and its heading give a count of 2.
    • a_rewritten_table_keeps_the_namespaces_it_declares: the namespace case
      through Document::try_replace_text and a save, in a header and in a VML
      text box. Every prefix of the saved part resolves.
    • a_template_tag_that_straddles_a_control_boundary_is_an_error.
    • the_reported_google_docs_controls_are_replaced: the issue's three shapes
      (no control, run wrapped, paragraph wrapped), with the read view checked
      before and after.
  • rdocx-cli integration.rs:
    replace_with_expect_counts_the_text_of_content_controls_everywhere, next
    to the other replace tests. rdocx replace --expect 1 over eight
    locations.

On the stacked base, the matrix, template, straddle and reproduction
regression tests and the CLI test fail. The straddle test shows the old false
match (ALPHAX two), and the CLI test stops at
expected 1 replacement(s) of "{{inline}}", found 0. The straddling template
test passes there too, for another reason (half of the tag was invisible), and
pins the error. The unit tests use new functions, so I checked the key ones by
mutation: restoring the plain retain fails the anchor test, leaving the ruby
spans out of remove_runs fails it too, dropping the content-index shift fails
the source-bytes test, resuming one character after a straddling match fails
both quantified tests (count 2, 12N end N), skipping the namespace step fails
the three namespace tests (count 2), and trimming the header
reader again fails the edge-space tests.

The issue's section 1 script with this branch (debug build):

content control: none   | rdocx text shows the word: True | try_replace_text -> 1 | rdocx replace --expect 1 -> Replaced 1 occurrence(s) of "alpha" -> "ALPHA"
content control: inline | rdocx text shows the word: True | try_replace_text -> 1 | rdocx replace --expect 1 -> Replaced 1 occurrence(s) of "alpha" -> "ALPHA"
content control: block  | rdocx text shows the word: False | try_replace_text -> 1 | rdocx replace --expect 1 -> Replaced 1 occurrence(s) of "alpha" -> "ALPHA"

The False of the block row is the read side, fixed by #177.

matrix_producer_traits.py with this branch: the replace column is ok on
all 11 rows. The inline content control on first runs row, n=0 in the
issue, now reads replace=ok and self=2. The only FAIL cells left are
cmpfld and self of the empty comments part row, which #185 addresses.
matrix_identity_attributes.py has replace=ok on all 18 rows.

Run on macOS arm64, debug build, at the head of the branch:

  • cargo fmt --all --check: clean.
  • cargo clippy -p rdocx-oxml -p rdocx -p rdocx-cli -p rdocx-layout -p rdocx-html -p rdocx-wasm -p rdocx-py --all-targets --all-features -- -D warnings: clean.
  • cargo test -p rdocx-oxml: 558 passed, plus 1 doctest.
  • cargo test -p rdocx --no-fail-fast: regression 577 passed (7 ignored),
    doctests passed. Lib and integration have only environment failures
    (below).
  • cargo test -p rdocx-cli: 2 and 18 passed. rdocx-layout: 292 passed.
    rdocx-html: 20 passed. rdocx-wasm: passed.
  • cargo check --target wasm32-unknown-unknown -p rdocx-wasm: clean.
  • python3 scripts/hash_harness.py --check: 49 entries match.
  • python3 scripts/prose_check.py: 0 violations.
  • python3 scripts/readme_doctests.py: passed. After the last
    re-measurement, readme_doctests.validate_inventory() validated 27 READMEs
    and 22 package inventories.
  • No Python binding or stub changed. I still ran the rdocx-py suite against a
    maturin develop build: 51 passed (without test_python_docx_parity.py
    and test_rendering_threads.py, which fail here for environment reasons).
    mypy and stubtest were not run.

Environment failures seen here, all known on this machine:

  • rdocx lib: large_word_and_presentation_pdfs_preserve_logical_reading_order
    and word_and_powerpoint_chart_pixels_are_identical.
  • rdocx integration: odt_reader_matches_pinned_libreoffice_structure,
    public_authored_theme_and_fonts_match_pinned_word_resolution,
    sanitized_public_authoring_fixture_passes_every_conformance_stage,
    section_page_semantics_match_pinned_libreoffice_render and
    every_conditional_table_region_matches_word.

compare() refused any pair whose content controls differed only by
w:sdtPr/w:id, with "comparison cannot revise content-control
properties". Word and Google Docs renumber that id when they save, so
two versions of a document with a table-of-contents control could not
be compared at all, although nothing in the control had changed.

The id was part of control_property_signature, the one tuple that drives
control alignment, the refusal and the accept and reject postconditions.
Leaving it out of that tuple keeps the three consistent. Controls that
differ only by id now align as unchanged and the redline keeps the
original w:sdtPr bytes, and a change inside such a control is revised
like any other. A w:sdtPr that holds none of the compared properties
reads like no w:sdtPr, so a control that gains or loses an id-only
w:sdtPr compares too. A differing w:tag, alias, control type or data
binding still refuses the pair. HLD 03 now says the id is not part of
the control shell.

GitHub issue tensorbee#159.
A run rewritten by try_replace_text lost xml:space="preserve" on its
w:t, because replace_in_single_run and replace_across_runs recomputed
the flag from the new text alone. Google Docs writes the flag on every
w:t, so a no-op replacement, common in a scripted pass that normalizes
wording, turned an unchanged run into a deletion and an insertion once
the edited copy was compared against its source.

The three rewrite sites, which regex replacement shares, now keep a flag
the producer wrote and still add one when the new text starts or ends
with a space.

compare() also read the flag as content wherever it appeared, because
the run signature formatted CT_Text whole. The flag only changes how
text reads when whitespace sits at an edge, so the text signature now
counts it only there. The same signature serves alignment and the
accept and reject postconditions, so they stay consistent, and a run
that matches keeps the original bytes.

Word and character granularity and the ignore options compare text
units, which the redline writes back as their own w:t. Each unit copied
the flag of its text, so a space split out of a flagged text differed
from the same space in an unflagged one. A unit with whitespace at an
edge now always carries the flag, so on that path whitespace reads as
whitespace whatever the source flag was, and the flag is never content.
Files from two producers that place the flag differently then compare
the same way at every granularity, and a space split out of a text
keeps the flag in the redline, where Word used to drop it.

On that path, the shortcut that keeps runs whole when every run of a
paragraph matches reads the flag the same way. Otherwise a run that
differed only by the flag at an edge was rewritten one unit per run,
which moved a bookmark end indexed by run and refused the pair.

GitHub issue tensorbee#160.
Comparing a file whose w:pgSz carries w:orient="portrait" against its
rdocx-edited copy reported a section_property_change next to the real
edit. The pgSz writer emits w:orient only for landscape, so a modelled
edit drops the default value, and section_properties_xml compared the
two sections as whole CT_SectPr values, Some(Portrait) against None.

Portrait is the schema default, so both modelled sections now read it
as absent before the equality test. A paragraph that holds a bookmark,
hyperlink, comment range or control compares its whole paragraph
properties, section break included, so the same rule applies there.
Without it such a section-break paragraph kept a formatting
diagnostic, or refused the pair when its text changed. A real change to
landscape is still a section property revision. The serializer is left
alone, because the typed defaults of every generated document carry
Some(Portrait) and writing it would move the document.xml hash
baselines.

GitHub issue tensorbee#160.
compare() failed with "complex field source has no end boundary" on a
file against its own copy once update_page_fields had refreshed a field
packed in one run, as Google Docs writes footer fields. The refreshed
result lands inside that run and changes w:dirty, so compare_field_xml
extracts the result on both sides, and complex_field_result looked for
the end character only after the end of the run holding separate. In a
packed run the end sits inside that same run.

complex_field_result now splits the run so that separate ends a run and
end starts one before it reads the result. The new runs repeat the
original start tag and run properties, and a run with nothing on the
far side of the character is left alone, so a field whose separate and
end each have a run of their own yields the same bytes as before. The
postconditions read a field as its owner only, so the split does not
reach them.

The same split fixes a quieter loss. update_page_fields writes the
result of an uncached one-run-per-part field into the run of end, and
the redline then carried an empty insertion without the new page
number. It now inserts the result.

GitHub issue tensorbee#160.
The comparison fixes, the kept xml:space flag and their tests grow the
rdocx and rdocx-oxml packages, so the crates.io archive rows in
README.md and crates/rdocx-oxml/README.md and their
ARCHIVE_MEASUREMENTS entries are re-measured.

GitHub issues tensorbee#159 and tensorbee#160.
Replacement in text boxes walks the raw XML of the document part and
of each header and footer, parses the w:p children of every
w:txbxContent and writes those paragraphs back. Every other child was
skipped on read and never written. The document layer stores the
rewritten part as soon as one text box in it has a hit, so a single
hit deleted the tables, block content controls, bookmarks and empty
paragraphs of every text box in that part. try_replace_text,
replace_all, replace_regex and render_template all go through this
walker, for the DrawingML and the VML copy of a text box alike.

The walker now edits each paragraph in place and copies every other
child through verbatim, so each text box keeps its content in its
order. Only a paragraph that the edit changed is re-serialised. The
others are copied as read, so they also keep the start-tag attributes
that CT_P does not model, such as w:rsidR and w14:paraId. Two defects
of the same loop are fixed with it. The closing tag is the one read
from the part instead of a fixed w:txbxContent, which did not match a
start tag under another prefix. A part that ends inside a text box is
now an error instead of an endless read.

GitHub issue tensorbee#160.
A replacement that spans runs puts its text in the first run and
removes the runs it leaves empty. It removed them with a plain retain
over the direct runs and then only clamped the hyperlink spans. The
bookmarks, comment ranges, inline content controls, raw children,
revisions, equations and ruby spans of a paragraph are kept by run
index, so each one after a removed run slid one run later, past the
text it preceded, and a hyperlink could lose a run or gain the next
one. The retain also removed every other run without typed content in
the paragraph: an empty run, and a run holding only raw XML such as a
drawing Word writes in mc:AlternateContent, which a single match
anywhere in the paragraph deleted.

The removal now goes through the remapping that comment removal
already used, moved into a shared CT_P::remove_runs, so every anchor
stays on the boundary that remains and a hyperlink left without runs
is dropped. That remapping left the ruby spans out, so comment removal
slid them too. It now moves them, and drops an annotation left without
base runs, which the writer skips anyway. Only a run the replacement
emptied is removed. A run keeps its start-tag attributes as a raw
record, which does not count as content.

GitHub issue tensorbee#160.
try_replace_text, replace_all, the regex replacement, render_template
and rdocx replace skipped the text of content controls. The body
walkers matched a body-level control to nothing, so a paragraph that a
Google Docs export wraps at body level was never read. The paragraph
replacement read only the direct runs, so a run wrapped in an inline
control was missed at every level, in the body, in table cells, in
text boxes and in header and footer paragraphs. Paragraph.text reads
those runs since tensorbee#118, so the read and replace views disagreed on the
same run. The text box walker copied its tables and block controls
without reading them.

The body walkers now go through the visitor that already reaches
tables, cells and controls at every level. The paragraph replacement
reads its direct runs and the runs of its inline controls, nested ones
included, in the order CT_P::runs reads them. A match must lie within
one stretch of runs: the direct runs between two controls, or the runs
of one control between its nested ones. A match that straddles a
control boundary is left as it is, never half replaced, and the search
goes on after it, so a shorter match that a pattern such as \d+ finds
inside it cannot replace part of it either. The direct runs on both
sides of a control no longer read as one text, which matched words a
reader does not see. A run the replacement empties inside a control is
removed with its content index bookkeeping. The literal and the regex
replacement share one table, row, cell and control recursion instead
of two copies of it.

The text box walker edits its tables and block controls through the
typed parsers and writes back only those it changed. The typed writers
leave out namespace declarations, so a rewritten element declares
those of its start tag again. One that declares a namespace deeper
down, other than the part binds it, keeps its bytes and counts
nothing, since its rewrite would leave the prefix unbound or bound to
another namespace.

render_template reads its tags in the same stretches, so a tag inside
an inline control, or in a table or control of a text box, is
rendered, where it was left in the output before. A tag that straddles
a control boundary is reported as an invalid template.

GitHub issue tensorbee#160.
The header and footer model types only its paragraphs. Every other
child of the part, such as a table or a content control like the
page-number control Word writes in a footer, is kept as raw XML
without its position, and a rewrite wrote all of them after the last
paragraph. Replacement rewrote the part as soon as one paragraph
matched, so a footer whose page-number control preceded its text came
back with the control moved below it. Replacement never read those raw
children either, so text in a header table or in a header or footer
control was not replaced. The part was also read with trimmed text
events, which a raw child is captured with, so the page-number control
holding "Page " came back as "Page" once the part was rewritten.

CT_HdrFtr now records how many paragraphs precede each raw child, and
the namespace bindings of its root, and writes each child back where
it was. A child a caller adds is still written after the last
paragraph. It reads the part without trimming, as CT_Document does,
and still skips the text between the children of the root.
Replacement parses a raw table or block control with the typed parsers
under those bindings, as the text box walker does, and writes back
only one it changed, so the others keep their bytes. render_template
reads the tags of those blocks too. The model stays a list of
paragraphs rather than a full typed body, so the published type only
gains two fields that are not public.

GitHub issue tensorbee#160.
The replacement changes, the text box fix this branch is stacked on and
their tests grow the rdocx-oxml, rdocx and rdocx-cli packages, so the
crates.io archive rows in README.md, crates/rdocx-oxml/README.md and
crates/rdocx-cli/README.md and their ARCHIVE_MEASUREMENTS entries are
re-measured on top of the stacked tree.

GitHub issue tensorbee#160.
@hadim hadim closed this Sep 27, 2026
@hadim hadim reopened this Sep 27, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant