diff --git a/crates/rofd-ffi/README.md b/crates/rofd-ffi/README.md
index 648aab34..bc8528e1 100644
--- a/crates/rofd-ffi/README.md
+++ b/crates/rofd-ffi/README.md
@@ -91,6 +91,13 @@ can therefore be empty even when a later snapshot contains a page-area fallback
warning. Existing snapshots remain unchanged. These are parsing warnings;
rendering diagnostics remain available through the rendering report API.
+`rofd_document_get_page_size_mm` copies one page's physical box in millimetres
+without loading page content. It reads the page area only, so it performs no
+content validation, collects no content warnings, and also succeeds for a page
+whose content cannot be loaded. The value matches `rofd_page_get_size_mm` for the
+same index; querying every page this way costs far less than opening each page with
+`rofd_document_get_page`.
+
```c
rofd_metadata_t *metadata = NULL;
rofd_warning_list_t *warnings = NULL;
diff --git a/crates/rofd-ffi/include/rofd.h b/crates/rofd-ffi/include/rofd.h
index 4dbd3c44..c5830204 100644
--- a/crates/rofd-ffi/include/rofd.h
+++ b/crates/rofd-ffi/include/rofd.h
@@ -340,6 +340,17 @@ rofd_status_t rofd_document_open(const char *path,
rofd_status_t rofd_document_get_page_count(const rofd_document_t *document,
size_t *page_count,
rofd_error_t **error);
+/** Borrow a live document and copy one page's physical box in millimetres. This
+ * metadata-only query reads the page area without parsing or caching page content,
+ * so it performs no content validation, collects no content warnings, and also
+ * succeeds when that page's content cannot be loaded. The value matches
+ * rofd_page_get_size_mm for the same index. document, page_size_mm and error follow
+ * the rules above; an index outside [0, page_count) returns
+ * ROFD_STATUS_PAGE_OUT_OF_RANGE. */
+rofd_status_t rofd_document_get_page_size_mm(const rofd_document_t *document,
+ size_t page_index,
+ rofd_rect_t *page_size_mm,
+ rofd_error_t **error);
/** Borrow a live document and return an independently owned page. The document
* storage must be disjoint from page and error and remain live for the call. */
rofd_status_t rofd_document_get_page(const rofd_document_t *document,
diff --git a/crates/rofd-ffi/src/document.rs b/crates/rofd-ffi/src/document.rs
index 32ee555e..e0407cbf 100644
--- a/crates/rofd-ffi/src/document.rs
+++ b/crates/rofd-ffi/src/document.rs
@@ -132,6 +132,43 @@ pub unsafe extern "C" fn rofd_document_get_page_count(
}
}
+/// Copies the physical page box of a zero-based index as millimetres.
+///
+/// This metadata-only query reads the page area without parsing or caching page
+/// content: it performs no content validation, collects no content warnings, and
+/// also succeeds for a page whose content cannot be loaded. The value matches
+/// [`rofd_page_get_size_mm`] for the same index.
+///
+/// # Safety
+///
+/// A non-null `document` must be a live document handle returned by this library.
+/// Its storage must remain valid, must not be concurrently freed or modified, and
+/// must not overlap the writable, aligned `page_size_mm` or `error` output slots.
+/// Non-null output slots must also be mutually disjoint and valid for the call.
+#[no_mangle]
+pub unsafe extern "C" fn rofd_document_get_page_size_mm(
+ document: *const rofd_document_t,
+ page_index: usize,
+ page_size_mm: *mut rofd_rect_t,
+ error: *mut *mut rofd_error_t,
+) -> rofd_status_t {
+ // SAFETY: The shared boundary validates and transactionally writes the caller output slots.
+ unsafe {
+ boundary(error, ScalarOutput::required(page_size_mm), || {
+ let size = document_ref(document)?
+ .inner
+ .page_size(page_index)
+ .map_err(FfiError::from)?;
+ Ok(rofd_rect_t {
+ x_mm: size.x,
+ y_mm: size.y,
+ width_mm: size.width,
+ height_mm: size.height,
+ })
+ })
+ }
+}
+
/// Returns an independently owned page handle for a zero-based index.
///
/// The returned page retains its document data and remains valid after the
diff --git a/crates/rofd-ffi/src/lib.rs b/crates/rofd-ffi/src/lib.rs
index d53272f8..42908e7b 100644
--- a/crates/rofd-ffi/src/lib.rs
+++ b/crates/rofd-ffi/src/lib.rs
@@ -17,8 +17,9 @@ mod semantic;
pub use abi::*;
pub use document::{
- rofd_document_free, rofd_document_get_page, rofd_document_get_page_count, rofd_document_open,
- rofd_page_free, rofd_page_get_index, rofd_page_get_size_mm,
+ rofd_document_free, rofd_document_get_page, rofd_document_get_page_count,
+ rofd_document_get_page_size_mm, rofd_document_open, rofd_page_free, rofd_page_get_index,
+ rofd_page_get_size_mm,
};
pub use error::{rofd_error_free, rofd_error_get_message, rofd_error_get_status};
pub use handles::{
diff --git a/crates/rofd-ffi/tests/c/expected-symbols.txt b/crates/rofd-ffi/tests/c/expected-symbols.txt
index ad122850..fd7c53ff 100644
--- a/crates/rofd-ffi/tests/c/expected-symbols.txt
+++ b/crates/rofd-ffi/tests/c/expected-symbols.txt
@@ -4,6 +4,7 @@ rofd_document_get_metadata
rofd_document_get_outline
rofd_document_get_page
rofd_document_get_page_count
+rofd_document_get_page_size_mm
rofd_document_get_warnings
rofd_document_open
rofd_error_free
diff --git a/crates/rofd-ffi/tests/document.rs b/crates/rofd-ffi/tests/document.rs
index 539a982a..2e732844 100644
--- a/crates/rofd-ffi/tests/document.rs
+++ b/crates/rofd-ffi/tests/document.rs
@@ -1,15 +1,19 @@
use std::ffi::{CStr, CString};
+use std::io::{Cursor, Write};
use std::mem::size_of;
use std::path::PathBuf;
use std::ptr;
+use std::sync::atomic::{AtomicUsize, Ordering};
use rofd_ffi::{
- rofd_document_free, rofd_document_get_page, rofd_document_get_page_count, rofd_document_open,
- rofd_document_t, rofd_error_free, rofd_error_get_message, rofd_error_get_status, rofd_error_t,
- rofd_load_options_init, rofd_load_options_t, rofd_page_free, rofd_page_get_index,
- rofd_page_get_size_mm, rofd_page_t, rofd_rect_t, ROFD_STATUS_INVALID_ARGUMENT, ROFD_STATUS_IO,
+ rofd_document_free, rofd_document_get_page, rofd_document_get_page_count,
+ rofd_document_get_page_size_mm, rofd_document_open, rofd_document_t, rofd_error_free,
+ rofd_error_get_message, rofd_error_get_status, rofd_error_t, rofd_load_options_init,
+ rofd_load_options_t, rofd_page_free, rofd_page_get_index, rofd_page_get_size_mm, rofd_page_t,
+ rofd_rect_t, ROFD_STATUS_INVALID_ARGUMENT, ROFD_STATUS_INVALID_DOCUMENT, ROFD_STATUS_IO,
ROFD_STATUS_OK, ROFD_STATUS_PAGE_OUT_OF_RANGE, ROFD_STRICTNESS_LENIENT, ROFD_STRICTNESS_STRICT,
};
+use zip::{write::SimpleFileOptions, ZipWriter};
fn fixture_path() -> CString {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../learning/test.ofd");
@@ -332,3 +336,257 @@ fn null_handles_and_out_of_range_pages_leave_outputs_at_entry_defaults() {
rofd_document_free(document);
}
}
+
+/// Builds a two-page document whose second page declares a valid physical box but
+/// references a font resource that does not exist, so only its metadata is readable.
+fn open_two_page_fixture() -> *mut rofd_document_t {
+ static NEXT: AtomicUsize = AtomicUsize::new(0);
+
+ let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
+ for (path, xml) in [
+ (
+ "OFD.xml",
+ "Document.xml",
+ ),
+ (
+ "Document.xml",
+ r#"0 0 210 2975"#,
+ ),
+ (
+ "Page_0.xml",
+ r#"0 0 210 297"#,
+ ),
+ (
+ "Page_1.xml",
+ r#"10 20 100 50x"#,
+ ),
+ ] {
+ zip.start_file(path, SimpleFileOptions::default()).unwrap();
+ zip.write_all(xml.as_bytes()).unwrap();
+ }
+
+ let path = std::env::temp_dir().join(format!(
+ "rofd-document-page-size-{}-{}.ofd",
+ std::process::id(),
+ NEXT.fetch_add(1, Ordering::Relaxed)
+ ));
+ std::fs::write(&path, zip.finish().unwrap().into_inner()).unwrap();
+ let path_c = CString::new(path.to_str().unwrap()).unwrap();
+ let mut document = ptr::null_mut();
+ // SAFETY: The path and the handle output are live and disjoint; the caller frees the handle.
+ unsafe {
+ assert_eq!(
+ rofd_document_open(path_c.as_ptr(), ptr::null(), &mut document, ptr::null_mut()),
+ ROFD_STATUS_OK
+ );
+ }
+ std::fs::remove_file(path).unwrap();
+ document
+}
+
+#[test]
+fn document_page_size_matches_the_page_handle_for_the_same_index() {
+ // SAFETY: Both handles stay live and each output slot is distinct for its call.
+ unsafe {
+ let (document, mut error) = open_fixture();
+ let mut document_size = rofd_rect_t {
+ x_mm: -1.0,
+ y_mm: -1.0,
+ width_mm: -1.0,
+ height_mm: -1.0,
+ };
+ assert_eq!(
+ rofd_document_get_page_size_mm(document, 0, &mut document_size, &mut error),
+ ROFD_STATUS_OK
+ );
+ assert!(error.is_null());
+ assert_eq!(
+ (
+ document_size.x_mm,
+ document_size.y_mm,
+ document_size.width_mm,
+ document_size.height_mm,
+ ),
+ (0.0, 0.0, 211.5, 140.0)
+ );
+
+ let mut page = ptr::null_mut();
+ assert_eq!(
+ rofd_document_get_page(document, 0, &mut page, &mut error),
+ ROFD_STATUS_OK
+ );
+ let mut page_size = rofd_rect_t {
+ x_mm: -1.0,
+ y_mm: -1.0,
+ width_mm: -1.0,
+ height_mm: -1.0,
+ };
+ assert_eq!(
+ rofd_page_get_size_mm(page, &mut page_size, &mut error),
+ ROFD_STATUS_OK
+ );
+ assert!(error.is_null());
+ assert_eq!(
+ (
+ document_size.x_mm,
+ document_size.y_mm,
+ document_size.width_mm,
+ document_size.height_mm,
+ ),
+ (
+ page_size.x_mm,
+ page_size.y_mm,
+ page_size.width_mm,
+ page_size.height_mm,
+ )
+ );
+
+ rofd_page_free(page);
+ rofd_document_free(document);
+ }
+}
+
+#[test]
+fn document_page_size_is_metadata_only_for_pages_with_unloadable_content() {
+ // SAFETY: The handle stays live; every output slot is distinct for its call.
+ unsafe {
+ let document = open_two_page_fixture();
+ let mut error = ptr::null_mut();
+
+ let mut first_size = rofd_rect_t {
+ x_mm: -1.0,
+ y_mm: -1.0,
+ width_mm: -1.0,
+ height_mm: -1.0,
+ };
+ assert_eq!(
+ rofd_document_get_page_size_mm(document, 0, &mut first_size, &mut error),
+ ROFD_STATUS_OK
+ );
+ assert_eq!(
+ (
+ first_size.x_mm,
+ first_size.y_mm,
+ first_size.width_mm,
+ first_size.height_mm,
+ ),
+ (0.0, 0.0, 210.0, 297.0)
+ );
+
+ let mut second_size = rofd_rect_t {
+ x_mm: -1.0,
+ y_mm: -1.0,
+ width_mm: -1.0,
+ height_mm: -1.0,
+ };
+ assert_eq!(
+ rofd_document_get_page_size_mm(document, 1, &mut second_size, &mut error),
+ ROFD_STATUS_OK
+ );
+ assert!(error.is_null());
+ assert_eq!(
+ (
+ second_size.x_mm,
+ second_size.y_mm,
+ second_size.width_mm,
+ second_size.height_mm,
+ ),
+ (10.0, 20.0, 100.0, 50.0)
+ );
+
+ let mut page = ptr::NonNull::::dangling().as_ptr();
+ assert_eq!(
+ rofd_document_get_page(document, 1, &mut page, &mut error),
+ ROFD_STATUS_INVALID_DOCUMENT
+ );
+ assert!(page.is_null());
+ assert_error(error, ROFD_STATUS_INVALID_DOCUMENT);
+ free_error(&mut error);
+
+ rofd_document_free(document);
+ }
+}
+
+#[test]
+fn document_page_size_rejects_invalid_arguments_indices_and_aliased_outputs() {
+ // SAFETY: The handle stays live; malformed layouts are rejected before any write.
+ unsafe {
+ let (document, mut error) = open_fixture();
+
+ let mut size = rofd_rect_t {
+ x_mm: 1.0,
+ y_mm: 2.0,
+ width_mm: 3.0,
+ height_mm: 4.0,
+ };
+ assert_eq!(
+ rofd_document_get_page_size_mm(ptr::null(), 0, &mut size, &mut error),
+ ROFD_STATUS_INVALID_ARGUMENT
+ );
+ assert_eq!(
+ (size.x_mm, size.y_mm, size.width_mm, size.height_mm),
+ (0.0, 0.0, 0.0, 0.0)
+ );
+ free_error(&mut error);
+
+ let size = rofd_rect_t {
+ x_mm: 1.0,
+ y_mm: 2.0,
+ width_mm: 3.0,
+ height_mm: 4.0,
+ };
+ assert_eq!(
+ rofd_document_get_page_size_mm(document, 0, ptr::null_mut(), &mut error),
+ ROFD_STATUS_INVALID_ARGUMENT
+ );
+ assert_eq!(
+ (size.x_mm, size.y_mm, size.width_mm, size.height_mm),
+ (1.0, 2.0, 3.0, 4.0)
+ );
+ free_error(&mut error);
+
+ let mut size = rofd_rect_t {
+ x_mm: 1.0,
+ y_mm: 2.0,
+ width_mm: 3.0,
+ height_mm: 4.0,
+ };
+ assert_eq!(
+ rofd_document_get_page_size_mm(document, 7, &mut size, &mut error),
+ ROFD_STATUS_PAGE_OUT_OF_RANGE
+ );
+ assert_eq!(
+ (size.x_mm, size.y_mm, size.width_mm, size.height_mm),
+ (0.0, 0.0, 0.0, 0.0)
+ );
+ free_error(&mut error);
+
+ let mut aliased = rofd_rect_t {
+ x_mm: 1.0,
+ y_mm: 2.0,
+ width_mm: 3.0,
+ height_mm: 4.0,
+ };
+ let aliased_output = ptr::addr_of_mut!(aliased);
+ assert_eq!(
+ rofd_document_get_page_size_mm(
+ document,
+ 0,
+ aliased_output,
+ aliased_output.cast::<*mut rofd_error_t>(),
+ ),
+ ROFD_STATUS_INVALID_ARGUMENT
+ );
+ assert_eq!(
+ (
+ aliased.x_mm,
+ aliased.y_mm,
+ aliased.width_mm,
+ aliased.height_mm
+ ),
+ (1.0, 2.0, 3.0, 4.0)
+ );
+
+ rofd_document_free(document);
+ }
+}
diff --git a/docs/superpowers/specs/2026-09-06-rofd-c-abi-v1-design.md b/docs/superpowers/specs/2026-09-06-rofd-c-abi-v1-design.md
index 7f1f57f1..90c41e4e 100644
--- a/docs/superpowers/specs/2026-09-06-rofd-c-abi-v1-design.md
+++ b/docs/superpowers/specs/2026-09-06-rofd-c-abi-v1-design.md
@@ -172,6 +172,11 @@ rofd_status_t rofd_document_get_page_count(
const rofd_document_t *document,
size_t *page_count,
rofd_error_t **error);
+rofd_status_t rofd_document_get_page_size_mm(
+ const rofd_document_t *document,
+ size_t page_index,
+ rofd_rect_t *page_size_mm,
+ rofd_error_t **error);
rofd_status_t rofd_document_get_page(
const rofd_document_t *document,
size_t index,