feat!: clean up the public API for 6.0.0 - #627
Merged
Merged
Conversation
Breaking changes to `odr::` ahead of the 6.0.0 tag, plus two long-standing defects in the public element API. Fixed: * `ElementIterator::operator++(int)` was `const`, so it could not advance, and returned the successor rather than the pre-increment value — `it++` silently violated the forward-iterator contract the class advertises. * `Element` and `ElementIterator` equality compared only the element identifier, so element n of one document compared equal to element n of an unrelated one. Non-existent iterators still compare equal to each other so `ElementRange::end()` keeps working. Changed: * Shape geometry is typed: `Frame`, `Rect`, `Line`, `Circle` and `CustomShape` return `Measure` instead of `std::string`, and `Frame::z_index` returns `std::optional<std::int32_t>`. The OOXML and ppt engines already built a `Measure` internally and called `to_string()` only to satisfy the string interface, so for them this is a simplification. * `Measure::to_string` renders 7 significant digits in positional notation via the new `util::number::to_string_significant`. The iostream default rounded to 4 (a drawing coordinate of `-6734.61mm` came out `-6735mm`) and could fall back to scientific notation, which CSS and SVG lengths reject. 7 digits is also the ceiling a `float` carries, so an xlsx column width stored as float `68.55` does not come back as `68.550003`. * `TextStyle::font_name` is `std::optional<std::string_view>` rather than `const char *`, matching its sibling fields and keeping the zero-copy contract the oldms style registries are built around. * Every exception derives from `odr::Exception`. Decoders still throw plain `std::runtime_error` for malformed input with no dedicated type, so that remains the widest net. Mirrored in Python as `pyodr.Error`. * `Color` uses named factories `from_rgb` / `from_argb`, replacing the `Color(std::uint32_t, bool dummy)` disambiguator. * `html::edit` takes `std::string_view`; `Html::config()` is `const`. * `Measure` moved to `<odr/quantity.hpp>`, where AGENTS.md already documented it; `<odr/style.hpp>` still provides it. Removed: * `DecoderEngine` and the whole decoder-selection dimension. Since the external backends went in #622 the enum had one value and could not select anything. Drops `DecodePreference::with_engine` / `engine_priority`, `list_decoder_engines`, `decoder_engine_by_name` / `_to_string`, `DecodedFile::decoder_engine`, both decoder-engine exceptions, the `abstract::DecodedFile` hook and its twelve engine overrides, and the bindings' equivalents including `DecoderEngine.java`. Also drops a duplicate `table_position.cpp` from `ODR_SOURCE_FILES`, documents `word_perfect` / `rich_text_format` as detection-only, corrects the README (json is supported), and adds CHANGELOG.md with an API stability policy. Reference outputs regenerated: 232 files, numeric formatting only, no structural change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FoqZKnajAkjWg1tdX3EqBq
Every other entry cites its PR; the ones added here had no number until the PR existed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FoqZKnajAkjWg1tdX3EqBq
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7c6c841d68
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
A default-constructed `DynamicUnit` left `m_unit` null while `name()`, `to_string()` and `to_stream()` all dereferenced it unconditionally. The geometry work in this branch made that reachable: an ODF shape missing one of its `svg:*` attributes fell back to `Measure(0, DynamicUnit())`, and rendering it segfaulted instead of emitting a zero length. Reported by Codex on #627. Fixed at the invariant rather than the call sites: the default constructor now takes the registered empty unit, the same one `Measure("5")` parses out of a bare number. `m_unit` is therefore never null, and a fallback measure compares equal to the parsed equivalent instead of being a look-alike that isn't. Also replaces the remaining `const char *` in the public headers: * `DynamicUnit` and `Quantity` take `std::string_view`. `Quantity` copies into a buffer first, since `std::strtod` needs a terminator a view does not promise, and stops treating a negative `char` as UB in `std::isspace`. * `DocumentPath`'s `const char *` and `const std::string &` constructors collapse into one taking `std::string_view`. * `CfbError` takes `const std::string &`, like its forty siblings. `File::memory_data()` keeps `const char *`: it is a pointer to bytes paired with `size()`, not a string. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FoqZKnajAkjWg1tdX3EqBq
`File::memory_data()` returned a bare `const char *` that only meant anything alongside `size()`, and used null to signal "not in memory". It now returns `std::optional<std::string_view>`, mirroring the `disk_path()` next to it: the path if the file is on disk, the bytes if it is in memory. The view carries its own length, so the pointer can no longer be read without one. Also fixes `MemoryFile::location()`, which reported `FileLocation::disk`. Nothing branched on it internally — it is only passed through to the bindings — so this corrects what the API reports rather than changing any behaviour. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FoqZKnajAkjWg1tdX3EqBq
andiwand
enabled auto-merge (squash)
July 27, 2026 22:07
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
Breaking API cleanup ahead of the 6.0.0 tag, continuing #622–#626. Everything
here needs the major bump, so it is the last cheap moment for it.
Two defects in the public element API
ElementIterator::operator++(int)wasconst, so it could not advance, and itreturned the successor instead of the pre-increment value:
auto old = it++;therefore leftitunmoved and handed back the nextelement — silently violating the
forward_iterator_tagthe class advertises.Element::operator==compared onlym_identifier, so element n of onedocument compared equal to element n of an unrelated one. Identifiers are
only unique within a document, so the adapter now takes part. Iterators that
don't exist still compare equal to one another, which is what keeps
ElementRange::end()(a null-adapter iterator) usable as a sentinel.Geometry is typed
Frame,Rect,Line,CircleandCustomShapereturnMeasureinstead ofstd::string, andFrame::z_indexreturnsstd::optional<std::int32_t>instead of a string. Callers no longer have to re-parse what the engines
already parsed — the OOXML and
pptengines were building aMeasureandcalling
to_string()purely to satisfy the string interface.Measure formatting
Typing the geometry routed it through
Measure::to_string, which exposed twoproblems with the iostream default:
geometry values in the ODF corpus, 27% lost precision — a drawing coordinate
of
-6734.61mmwas emitted as-6735mm. This affected everyMeasure(margins, column widths), not just the newly-typed geometry.
13421095.26ptrenders as1.34211e+07pt, which CSS and SVG lengths reject. There were zeroscientific-notation lengths in the references beforehand.
Naively raising the precision fixes the first and worsens the second, and at 8+
digits it starts exposing binary representation: xlsx stores column widths as
32-bit floats, so
68.55comes back as68.550003.The new
util::number::to_string_significantrenders a fixed number ofsignificant digits, always positional.
Measureasks for 7 — enough fordocument geometry, and the ceiling a
floatcarries (FLT_DIGis 6). It is ageneral helper rather than a
Quantityprivate, since the "no scientificnotation" constraint applies to anything we emit into CSS or SVG.
QuantityBase(non-template, protected static) keeps the implementation out ofthe public header while adding no public name; empty-base optimisation leaves
sizeof(Measure)unchanged at 16 bytes.DecoderEngine removed
Since #622 dropped the external backends the enum had exactly one value and
could not select anything. This removes the enum,
DecodePreference'swith_engine/engine_priority, the four free functions,DecodedFile::decoder_engine, both decoder-engine exceptions, theabstract::DecodedFilehook with its twelve engine overrides, and the Pythonand Java equivalents including
DecoderEngine.java. The 220-line enginedispatch in
open_strategy.cppcollapses into a TU-localopen_file_as.Also
odr::Exception, socatch (const odr::Exception &)catches all typed errors. Decoders still throw plainstd::runtime_errorfor malformed input with no dedicated type, so thatstays the widest net. Mirrored in Python as
pyodr.Error; Java already hadOdrException.FileNotFounddeliberately keepsPyExc_FileNotFoundError.TextStyle::font_name→std::optional<std::string_view>, matching itssiblings and keeping the zero-copy contract the
oldmsregistries rely on.Color::from_rgb/from_argbreplace theColor(std::uint32_t, bool dummy)disambiguator.
html::edittakesstd::string_view;Html::config()gainsconst.Measuremoved to<odr/quantity.hpp>, whereAGENTS.mdalready documentedit.
<odr/style.hpp>still provides it.table_position.cppdropped fromODR_SOURCE_FILES;word_perfect/rich_text_formatdocumented as detection-only; READMEcorrected (json is supported).
Two crashes fixed during review
DynamicUnit's default constructor left the unit pointer null whilename(),to_string()andto_stream()all dereferenced it. Typing the geometry madethat reachable — an ODF shape missing one of its
svg:*attributes fell backto
Measure(0, DynamicUnit())and segfaulted on render. Caught by Codex.Fixed at the invariant rather than the 16 call sites: the default constructor
takes the registered empty unit, so the pointer is never null and a fallback
measure compares equal to the parsed equivalent.
MemoryFile::location()returnedFileLocation::disk. Nothing branches on itinternally, so this corrects what the API reports rather than changing
behaviour.
Remaining
const char *in the public headersDynamicUnitandQuantitytakestd::string_view.Quantitycopies intoa buffer first, since
std::strtodneeds a terminator a view does notpromise, and stops treating a negative
charas UB instd::isspace.DocumentPath'sconst char *andconst std::string &constructorscollapse into one
std::string_view.CfbErrortakesconst std::string &, like its forty siblings.File::memory_data()returnsstd::optional<std::string_view>, mirroringthe
disk_path()beside it — the bytes if in memory, the path if on disk.It previously returned a bare pointer that only meant something alongside
size(), with null doubling as "not in memory".Reference outputs
Regenerated and already on
mainin both output repos(
OpenDocument.test.output@6581dcd,OpenDocument.test-private.output@7ed25fa); the pointers move here.232 of 1402 files changed. Verified mechanically that the change is formatting
only: 0 numeric-token-count mismatches, insertions exactly equal deletions, and
all 1,011 distinct value changes preserve the unit and the value to within 0.1%
(
0.2188in→0.21875in,11.52ch→11.52041ch). The ODG drawing outputsare byte-identical, since ODF geometry already round-trips exactly at 7 digits.
Testing
718 tests pass (7 pre-existing skips: svm, wpd, encrypted doc). Full build
clean including CLI, Python bindings and JNI. A fresh run matches all 1402
reference files byte-for-byte.
number_util_test.cppcovers the float-noiseand no-scientific-notation cases directly, and there are regression tests for
both crashes above.
Not verified locally:
pytestandclang-tidyare not installed in thisenvironment, so the Python suite and the tidy job rely on CI.