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,