Shared C-ABI plumbing and error-kind conventions for the un* document extraction
family — unpdf (PDF),
undoc (DOCX/XLSX/PPTX),
unhwp (HWP/HWPX).
[dependencies]
uncore = "0.2"Requires Rust 1.87. Has no dependencies of its own, and builds for every target its
consumers use, including wasm32-unknown-unknown.
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.
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.
kind— the error-kind values the family already shares, and the bands that keep future ones from colliding.ffi— the thread-local last-error slot, the panic guard, and the two boundary failure reasons.scaffold— macros that assemble a C entry point out of those primitives.
ffi hands out the materials; scaffold is the assembly. A string-returning entry point
is five steps and only one of them mentions the library:
uncore::export_string_getter!(
/// The document rendered as Markdown.
///
/// # Safety
///
/// - `doc` must be a valid handle.
/// - The returned string must be freed with `mylib_free_string`.
LAST_ERROR,
mylib_to_markdown(doc: MylibDocument, flags: c_int),
{ render(&(*doc).inner, flags).map_err(classify) }
);The macros are split by return type rather than taking a sentinel, because null and -1
are not interchangeable and a sentinel that can be passed in can be passed wrong.
Exported names are written in full at the call site: macro_rules! cannot build
identifiers, and a symbol should be greppable where it is declared.
The macros are assembled from these, and they stay public: an entry point whose shape no macro fits is written by hand out of the same pieces. Written out, one looks like this — which is what a macro spares you at every entry point but one:
use std::ffi::c_int;
use uncore::ffi::{self, FfiError, LastErrorSlot};
// The slot lives in your crate, not in uncore. That is what keeps it per-library —
// see below.
thread_local! {
static LAST_ERROR: LastErrorSlot = const { LastErrorSlot::new() };
}
uncore::export_last_error_abi!(LAST_ERROR, mylib_last_error, mylib_last_error_kind);
/// Returns the page count, or -1 on failure.
#[no_mangle]
pub extern "C" fn mylib_page_count(handle: *const u8) -> c_int {
let result: Result<c_int, FfiError> = ffi::catch(|| {
if handle.is_null() {
return Err(ffi::invalid_argument("handle was null"));
}
Ok(7)
});
match result {
Ok(count) => {
LAST_ERROR.with(|slot| slot.clear());
count
}
Err(error) => {
LAST_ERROR.with(|slot| slot.set_error(&error));
-1
}
}
}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().ffi::c_str_utf8/with_c_str!— read a C string argument, classifying null and non-UTF-8. Neither hides the unsafe read: the call site keeps the obligation, because a macro cannot check a pointer's provenance.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.
And the entry-point macros in scaffold, one per return shape — export_handle!,
export_string_getter!, export_optional_string_getter!, export_count_getter!,
export_bytes_getter!, export_free_string!, export_free_bytes!.
export_optional_string_getter! is the one worth reading the docs for: it keeps "there is
nothing" distinct from "we could not give it to you", which both return null and are told
apart only by the kind.
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.
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.
0.x. Breaking changes are a normal means of getting the shape right, and the consumers
are versioned independently.
MIT