Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions crates/rofd-ffi/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
11 changes: 11 additions & 0 deletions crates/rofd-ffi/include/rofd.h
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
37 changes: 37 additions & 0 deletions crates/rofd-ffi/src/document.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Page sizes fail on unreadable content

When the page has a readable physical box but its XML is malformed, malformed within its content subtree, or exceeds the configured XML depth limit, Document::page_size parses or deserializes the page XML before extracting its area, so malformed content or a depth-limit violation returns an invalid-document error instead of the documented content-independent size result.

Read the page's physical box without parsing or deserializing its XML.

Also at crates/rofd-ffi/README.md:94-99, crates/rofd-ffi/include/rofd.h:343-349, crates/rofd-ffi/src/document.rs:137-140.

Prompt for AI agents
In `crates/rofd-ffi/src/document.rs` at line 160:

**Page sizes fail on unreadable content**

When the page has a readable physical box but its XML is malformed, malformed within its content subtree, or exceeds the configured XML depth limit, `Document::page_size` parses or deserializes the page XML before extracting its area, so malformed content or a depth-limit violation returns an invalid-document error instead of the documented content-independent size result.

Read the page's physical box without parsing or deserializing its XML.

Also at `crates/rofd-ffi/README.md:94-99`, `crates/rofd-ffi/include/rofd.h:343-349`, `crates/rofd-ffi/src/document.rs:137-140`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Malformed page sizes disagree

When a page's physical box has a nonpositive width or height, Document::page_size rejects nonpositive dimensions, while Document::page stores the parsed rectangle and Page::size returns it unchanged; this call therefore returns ROFD_STATUS_INVALID_DOCUMENT where rofd_page_get_size_mm returns a rectangle, breaking the documented parity.

Make both page-size APIs apply the same validity rules, or document and test the malformed-page exception to the parity guarantee.

Prompt for AI agents
In `crates/rofd-ffi/src/document.rs` at line 160:

**Malformed page sizes disagree**

When a page's physical box has a nonpositive width or height, `Document::page_size` rejects nonpositive dimensions, while `Document::page` stores the parsed rectangle and `Page::size` returns it unchanged; this call therefore returns `ROFD_STATUS_INVALID_DOCUMENT` where `rofd_page_get_size_mm` returns a rectangle, breaking the documented parity.

Make both page-size APIs apply the same validity rules, or document and test the malformed-page exception to the parity guarantee.

.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
Expand Down
5 changes: 3 additions & 2 deletions crates/rofd-ffi/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down
1 change: 1 addition & 0 deletions crates/rofd-ffi/tests/c/expected-symbols.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
266 changes: 262 additions & 4 deletions crates/rofd-ffi/tests/document.rs
Original file line number Diff line number Diff line change
@@ -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");
Expand Down Expand Up @@ -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",
"<OFD><DocBody><DocInfo/><DocRoot>Document.xml</DocRoot></DocBody></OFD>",
),
(
"Document.xml",
r#"<Document><CommonData><PageArea><PhysicalBox>0 0 210 297</PhysicalBox></PageArea><MaxUnitID>5</MaxUnitID></CommonData><Pages><Page ID="2" BaseLoc="Page_0.xml"/><Page ID="3" BaseLoc="Page_1.xml"/></Pages></Document>"#,
),
(
"Page_0.xml",
r#"<Page><Area><PhysicalBox>0 0 210 297</PhysicalBox></Area></Page>"#,
),
(
"Page_1.xml",
r#"<Page><Area><PhysicalBox>10 20 100 50</PhysicalBox></Area><Content><Layer ID="4"><TextObject ID="5" Boundary="0 0 20 10" Font="999" Size="4"><TextCode X="0" Y="4">x</TextCode></TextObject></Layer></Content></Page>"#,
),
] {
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::<rofd_page_t>::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);
}
}
5 changes: 5 additions & 0 deletions docs/superpowers/specs/2026-09-06-rofd-c-abi-v1-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading