refactor(document): share the element registry and adapter eleven engines pasted - #823
Merged
Conversation
andiwand
force-pushed
the
refactor/shared-element-registry
branch
from
September 6, 2026 08:11
cccff0f to
daf3527
Compare
andiwand
changed the base branch from
main
to
fix/page-layout-direction-and-msvc-raw-string
September 6, 2026 08:11
Base automatically changed from
fix/page-layout-direction-and-msvc-raw-string
to
main
September 6, 2026 08:14
…xml pasted Every engine that builds an element tree writes the same flat store, the same tree links and the same adapter navigation. Two headers in `internal/common` now hold that shape, and odf and the three ooxml engines are ported onto it: - `internal::ElementRegistry` owns the element vector, the id/index convention, `element_at`, `append_child` and `link_child`, over an element the engine derives from `ElementNode` and a payload side table (hashed, or odf's sorted one) that carries its own bounds check. - `internal::ElementAdapter` answers every `*_adapter(id)` hook from the adapters it is given, and `RegistryElementAdapter` adds the six navigation methods. No behaviour change: the reference output is byte-identical. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VEsRyBu8o4TGJGn3thNEDc
…try and adapter rtf, markdown, iwork, the three oldms formats and csv follow odf and ooxml onto `internal::ElementRegistry` and `internal::RegistryElementAdapter`; csv, which packs its ids rather than keeping a registry, takes the hook base alone and keeps its own navigation. The element store is now a `std::deque` for every engine, which is what makes handing a parser an `Element &` safe. The registries lose the `clear()` nothing ever called and the per-payload `check_*_id`, and the three ooxml `append_child` regain the "child already has a parent" guard the others kept. The side tables reach their const and non-const accessors through one static that deduces the constness from its argument, rather than a `const_cast` back from the const overload. `AGENTS.md`, `odf/AGENTS.md`, `ooxml/AGENTS.md` and `rtf/AGENTS.md` taught the copy; they now point at the shared shape. No behaviour change: the reference output is byte-identical. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VEsRyBu8o4TGJGn3thNEDc
andiwand
force-pushed
the
refactor/shared-element-registry
branch
from
September 6, 2026 08:17
daf3527 to
6ac7626
Compare
The stray `private:` `rtf`'s adapter was left with, its registry pointer having moved to the base, and the `document_util` / `document_path` includes eleven adapters kept after the two path forwards moved with it. Dropping them turned up `element_adapter.hpp` returning `DocumentPath` by value on nothing but a forward declaration - it had been compiling on whichever include the including `.cpp` happened to write first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VEsRyBu8o4TGJGn3thNEDc
andiwand
added a commit
that referenced
this pull request
Sep 6, 2026
Forty-two accessors were the same body twice, once for each constness, with only the spelled-out return type telling the two apart. An explicit object parameter deduces that, so each pair is one function returning `T &` or `const T &` from a single body. The shared registry (#823) is where it pays most: `SideTable` and `SortedSideTable` had written the workaround out by hand — two public overloads delegating to a `static` helper templated on the object, under a comment naming the trick — and every engine then repeated the pair for each of its payload accessors. Both go, and a registry's accessor is [[nodiscard]] auto &text_element_at(this auto &self, const ElementIdentifier id) { return self.m_texts.at(id); } `pdf`'s `Array` and `Dictionary` lose eleven more. `ooxml_text_list`'s numbering walk would have dropped its Y-combinator with them, but NDK 28.1's clang 19 segfaults on a capturing lambda that recurses through an explicit object parameter, so it keeps passing itself along and says why. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QoTh7BEUSL2z9BBThgsEi7
andiwand
added a commit
that referenced
this pull request
Sep 6, 2026
Forty-two accessors were the same body twice, once for each constness, with only the spelled-out return type telling the two apart. An explicit object parameter deduces that, so each pair is one function returning `T &` or `const T &` from a single body. The shared registry (#823) is where it pays most: `SideTable` and `SortedSideTable` had written the workaround out by hand — two public overloads delegating to a `static` helper templated on the object, under a comment naming the trick — and every engine then repeated the pair for each of its payload accessors. Both go, and a registry's accessor is [[nodiscard]] auto &text_element_at(this auto &self, const ElementIdentifier id) { return self.m_texts.at(id); } `pdf`'s `Array` and `Dictionary` lose eleven more. `ooxml_text_list`'s numbering walk would have dropped its Y-combinator with them, but NDK 28.1's clang 19 segfaults on a capturing lambda that recurses through an explicit object parameter, so it keeps passing itself along and says why. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QoTh7BEUSL2z9BBThgsEi7
andiwand
added a commit
that referenced
this pull request
Sep 6, 2026
Forty-two accessors were the same body twice, once for each constness, with only the spelled-out return type telling the two apart. An explicit object parameter deduces that, so each pair is one function returning `T &` or `const T &` from a single body. The shared registry (#823) is where it pays most: `SideTable` and `SortedSideTable` had written the workaround out by hand — two public overloads delegating to a `static` helper templated on the object, under a comment naming the trick — and every engine then repeated the pair for each of its payload accessors. Both go, and a registry's accessor is [[nodiscard]] auto &text_element_at(this auto &self, const ElementIdentifier id) { return self.m_texts.at(id); } `pdf`'s `Array` and `Dictionary` lose eleven more. `ooxml_text_list`'s numbering walk would have dropped its Y-combinator with them, but NDK 28.1's clang 19 segfaults on a capturing lambda that recurses through an explicit object parameter, so it keeps passing itself along and says why. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QoTh7BEUSL2z9BBThgsEi7
andiwand
added a commit
that referenced
this pull request
Sep 6, 2026
Forty-two accessors were the same body twice, once for each constness, with only the spelled-out return type telling the two apart. An explicit object parameter deduces that, so each pair is one function returning `T &` or `const T &` from a single body. The shared registry (#823) is where it pays most: `SideTable` and `SortedSideTable` had written the workaround out by hand — two public overloads delegating to a `static` helper templated on the object, under a comment naming the trick — and every engine then repeated the pair for each of its payload accessors. Both go, and a registry's accessor is [[nodiscard]] auto &text_element_at(this auto &self, const ElementIdentifier id) { return self.m_texts.at(id); } `pdf`'s `Array` and `Dictionary` lose eleven more. `ooxml_text_list`'s numbering walk would have dropped its Y-combinator with them, but NDK 28.1's clang 19 segfaults on a capturing lambda that recurses through an explicit object parameter, so it keeps passing itself along and says why. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QoTh7BEUSL2z9BBThgsEi7
andiwand
added a commit
that referenced
this pull request
Sep 6, 2026
* build!: raise the standard to C++23 The whole matrix already supports it: clang 18, gcc 14, apple-clang, MSVC 19.40, NDK 28.1 and emsdk 3.1.73. It costs no source change at all — the two transitive includes libc++'s C++23 headers stopped handing out went in with #827, which is the whole of it. Three ceilings sit under the standard, and AGENTS.md records all three because none is discoverable from a local build. The library half is capped by emsdk 3.1.73's libc++ 18.1, the oldest here and the newest emsdk conan-center packages. `std::format` is unusable on any slice — the apple profiles deploy to macOS 12 / iOS 15, and libc++ marks the floating-point `to_chars` that `<format>` instantiates as macOS 13.3 / iOS 16.3, which is why #828 formats through `fmt`. And NDK 28.1's clang 19 segfaults on a capturing recursive lambda taking `this auto self`. The public headers stay C++20 — no `target_compile_features(odr PUBLIC …)` and no `cppstd` in `package_info`, so a consumer picks its own standard, and `check_min_cppstd` sits in `validate_build` where it constrains building `odr` rather than using it. On MSVC there is no `/std:c++23`; CMake maps `CXX_STANDARD 23` to `/std:c++latest`. * refactor: let deducing `this` write the const overload Forty-two accessors were the same body twice, once for each constness, with only the spelled-out return type telling the two apart. An explicit object parameter deduces that, so each pair is one function returning `T &` or `const T &` from a single body. The shared registry (#823) is where it pays most: `SideTable` and `SortedSideTable` had written the workaround out by hand — two public overloads delegating to a `static` helper templated on the object, under a comment naming the trick — and every engine then repeated the pair for each of its payload accessors. Both go, and a registry's accessor is [[nodiscard]] auto &text_element_at(this auto &self, const ElementIdentifier id) { return self.m_texts.at(id); } `pdf`'s `Array` and `Dictionary` lose eleven more. `ooxml_text_list`'s numbering walk would have dropped its Y-combinator with them, but NDK 28.1's clang 19 segfaults on a capturing lambda that recurses through an explicit object parameter, so it keeps passing itself along and says why. * perf: stop zeroing the buffer the next read overwrites anyway `resize` fills the new tail with zeros; every one of these then writes over all of it. `resize_and_overwrite` hands the chunk over unwritten instead — 12.3 GB/s to 16.7 GB/s on `read_u8s` against an in-memory stream, which is what a zip entry is. The callback must not throw, so a short read shrinks the string back to the offset it started from and the throw happens at the call site. `ppt`'s `read_raw_text_bytes` loses its second `resize` with it: the callback returns `gcount()` and the string is already the right length. `xls_io`'s string body is left alone — its `read_bytes` throws from inside, and a non-throwing path just for this is not worth the buffer it saves. * refactor: build the two derived vectors with `ranges::to` Both loops did nothing but map a range onto a vector, and both fed it straight into one call. `views::transform | ranges::to<std::vector<std::string>>()` says that in the expression that uses it, so neither needs a named variable any more. `type1_charstring` takes the iterator-pair `std::reverse` next to it with them, per the ranges convention. * test(build): hold the public headers to C++20 The bump made `odr` C++23 and deliberately did not pass that on — no `target_compile_features(odr PUBLIC …)`, no `cppstd` in the conan `package_info` — so a consumer keeps whatever standard it picked, as long as `src/odr/*.hpp` stays C++20. Nothing checked that, and a `this auto &self` in a public header would have broken someone else's build rather than ours. `odr_public_headers_cpp20` includes all seventeen of them and compiles at C++20. One object file, no test data, no link, no gtest — it either compiles or it does not. It lives in `test/CMakeLists.txt` and so builds under `ODR_TEST`, which is also what keeps it out of the conan package: `exports_sources` ships no `test/`, and an unconditional target naming a file that is not there fails the package build at generate time. * docs: cut the prose from the comments this branch adds The C++23-does-not-propagate rationale now lives in AGENTS.md alone; the test file and its cmake target state what they are and stop there. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
🤖 Generated with Claude Code
Closes #770 (parts 1–2 of the four it lists; the style registries and the
DecodedFileaccessors are still open).Every engine that builds an element tree shipped its own copy of the same flat
store, the same five tree links, the same
element_at/append_child, and thesame ~30 lines of adapter navigation followed by one four-line
*_adapter(id)hook per element type. This puts that machinery ininternal/common/and ports all eleven engines onto it.380 of those insertions are the two new headers. Registry code across the ten
registries: 3028 → 1726 lines. No engine writes a
*_adapter(id)hook or anavigation method any more.
What is shared
internal/common/element_registry.hppElementNode<Id>— the five links plus the type, storedIdwide.SideTable<T>(hashed) andSortedSideTable<T, Id>(odf's sorted deque,promoted) — a per-type payload keyed by element id. Both carry their own
bounds check, so an engine's accessor is
return m_texts.at(id);and theper-payload
check_*_idis gone.ElementRegistry<Element, Id>— the store (id = index + 1),element_at,append_child,link_child,check_element_id, and the id-overflow guard.The store is a
std::dequefor every engine now. That is what odf already did,for the reason it applies everywhere:
create_element_hands the parser back anElement &, and a vector both invalidates it and peaks holding two copies. Theooxml engines were on a vector; nothing there held a reference across a create,
so this is a hazard removed rather than a bug fixed.
internal/common/element_adapter.hppElementAdapter<Adapters…>inherits the per-type adapters named in its pack andanswers all 24
*_adapter(id)hooks from them:Taking the adapters as template arguments rather than using CRTP keeps the
condition off the derived class, which would not be complete when a compiler
that instantiates virtual member bodies eagerly gets there. It also checks out
against the tree: the hook ↔
ElementTypemapping was already 1:1 and identicalin all eleven engines.
It also carries the defaults every engine had verbatim —
element_is_uniqueandelement_is_self_locatabletrue,element_is_editablefalse, and the twoutil::documentpath forwards. Only odf and ooxml text overrideelement_is_editable, which is the only place the answer was ever different.RegistryElementAdapter<Registry, Adapters…>adds the six navigation methodsover
m_registry->element_at(id).What each engine keeps
Its payload structs, its
create_*_element, its secondary-chainappend_*, andits real per-type methods. rtf is now the whole pattern in 34 header lines:
csvpacks a kind and a coordinate into its id rather than keeping a registry,so it takes
ElementAdapteralone and keeps its own navigation.Drift swept up on the way
clear()was dead in all ten registries — nothing has ever called it.append_childhad lost the "child already has a parent" guardevery other engine kept;
link_childrestores it.DocumentElementRegistry::, a name no class has carriedfor a while.
Docs
AGENTS.mdtaught the copy — it namedppt_element_registry.*as the thing tomodel, and
rtf/AGENTS.mdsaid its registry was "copied fromoldms/text". Theelement-adapter pattern section now leads with the machinery is shared — do
not write it again, says what an engine actually writes, and points at rtf.
odf/AGENTS.mdandooxml/AGENTS.mdfollow.Verification
byte-identical, which is the real claim here — this changes no behaviour.
-Werrorbuild clean, clang-tidy clean on all 27 touched translation units.ElementNodeas a base costs nothing — and peak RSS on a 4.5 MB.odsisunchanged.
No
CHANGELOG.mdentry: refactoring is in the generated per-PR list already.Merge order
#821 collapses the four drawing element types into
frame, which touches fourentries in the shared hook list and in odf's adapter pack. It is the breaking
one, so this should go first and #821 rebase onto it — the conflict is a handful
of lines either way.