uncore
Shared C-ABI plumbing and error-kind conventions for the un* document extraction
family — unpdf (PDF),
undoc (DOCX/XLSX/PPTX),
unhwp (HWP/HWPX).
Why this exists
Those three libraries have the same shape — parse a container, build an intermediate representation, render Markdown — and expose it the same way: a Rust API, a C ABI, and C#, Python and WebAssembly bindings over that ABI.
The document parts differ entirely and belong in each library. The plumbing does not:
thread-local last-error storage, catching panics before they cross extern "C", and the
integer space the error classifications live in. That plumbing was written three times.
This crate is where it lives once.
Scope
Two rules keep it from becoming a dumping ground:
- No domain content. No document model, no rendering, no format knowledge. If a type would mention a page, a paragraph or a spreadsheet cell, it belongs upstream.
- No dependencies. Three published cdylibs link this statically, so a dependency here
is a dependency in all of them. Everything is
std.
std-only (a CString in a thread_local!), which suits every consumer including
wasm32-unknown-unknown.
What it gives you
use c_int;
use ;
// The slot lives in your crate, not in uncore. That is what keeps it per-library —
// see below.
thread_local!
export_last_error_abi!;
/// Returns the page count, or -1 on failure.
pub extern "C"
ffi::LastErrorSlot— message and kind, written together so a message is never paired with a stale reason.ffi::catch— turns a panic intokind::PANICinstead of undefined behaviour.ffi::FfiError—(kind, message), carried out of closures so the classification is not lost to an earlyto_string().export_last_error_abi!— declares<lib>_last_errorand<lib>_last_error_kind.assert_stable_kinds!— turns an accidental renumbering into a test failure.kind— the values the family already shares, and bands for future ones.
Why the slot is yours
Each library ships as its own cdylib, so today each has its own statics. But this crate is
linked into all of them, and a Rust binary depending on two would share one copy — at
which point a single shared static would make unpdf_last_error() return whatever the
preceding undoc call recorded. Isolation cannot rest on how consumers happen to link, so
the slot is declared in the consuming crate. That makes it structural.
Error-kind numbering
kind exports only values that are already identical across the family, and that
restraint is the design: a library adopting a constant whose value differs from what it
ships would be renumbering its public ABI. Above UNKNOWN_FORMAT the family already
disagrees — the low numbers were assigned independently, before anyone thought to align
them — so any value published here would be a renumbering request aimed at somebody.
For everything else it offers bands rather than values:
| Range | Meaning |
|---|---|
0 |
Success. Never a valid kind |
1..=17 |
Assigned before the convention existed. Frozen where shipped |
18..=99 |
New reasons that genuinely apply to more than one library |
100..=199 |
Failures of the ABI call itself (100 invalid argument, 101 panic, 102 output cannot cross the ABI) |
200.. |
One band per library, 100 wide — unpdf 200..=299, undoc 300..=399, unhwp 400..=499 |
The contract every library in the family promises its consumers:
- A new reason takes a new number. Existing numbers are never reused or renumbered.
- An unrecognised value is not an error — treat it as a generic failure and keep the number. A newer library stays usable by an older caller precisely because of this.
0means success, never a reason.- Reasons are
#[non_exhaustive]: match with a_ =>arm.
Versioning
0.x. Breaking changes are a normal means of getting the shape right, and the consumers
are versioned independently.
License
MIT