Native files: open reads the PDF preference in the same pass as the document

Opening a native file read its JSON twice: once for the document and once
more for the saved pdfCompatible preference. The format crate now takes
the preference from craft-vectorcraft-format's
Contents::read_with_pdf_preference (craft-libs#562), so a file is read in
one pass.

That method refuses a pdfCompatible that is not a boolean, which main
opens by skipping the value and falling back to whether the file carries
a PDF. Such a file, or one with any other data error, is read again with
Contents::read and scanned for the preference as before, so the files
that open today still open with the same result.

The craft-vectorcraft-format pin moves to 1b34eba41e, the craft-libs
revision craft-update already uses.
This commit is contained in:
dotCooCoo
2026-11-10 19:42:29 -08:00
parent 858b63082f
commit 8002598d64
3 changed files with 55 additions and 9 deletions
Generated
+1 -1
View File
@@ -1096,7 +1096,7 @@ dependencies = [
[[package]]
name = "craft-vectorcraft-format"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb"
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
dependencies = [
"flate2",
"serde",
+1 -1
View File
@@ -21,7 +21,7 @@ craft-proof = { git = "https://github.com/storytold/craft-libs", rev = "9be9a637
# desktop. The `egui` feature adds `craft_window::decorate`. (craft-libs#217.)
craft-window = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb", features = ["egui"] }
# The `.vectorcraft` envelope: its members, version, embedded images and the data after the JSON.
craft-vectorcraft-format = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" }
craft-vectorcraft-format = { git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d" }
# craft-update: app-specific pretend versions apply to startup and manual checks.
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["egui"] }
# internal
+53 -7
View File
@@ -510,20 +510,32 @@ fn load_file_within(bytes: &[u8], max_nodes: usize, max_values: usize) -> Result
// at `MAX_READ_ANCHORS` anchors together, the document at `MAX_READ_NODES` objects, and the
// fields read before an object's type at `MAX_BUFFERED_VALUES` values. The preview and the
// PDF are skipped without being kept.
let read = || {
vectorcraft_doc::node::with_node_budget(max_nodes, || {
vectorcraft_doc::node::with_buffered_value_budget(max_values, || Contents::<Document>::read(file.json()))
fn within_budgets<T>(max_nodes: usize, max_values: usize, read: impl FnOnce() -> T) -> T {
vectorcraft_doc::geom::path::with_anchor_budget(vectorcraft_doc::geom::path::MAX_READ_ANCHORS, || {
vectorcraft_doc::node::with_node_budget(max_nodes, || vectorcraft_doc::node::with_buffered_value_budget(max_values, read))
})
};
let mut f = vectorcraft_doc::geom::path::with_anchor_budget(vectorcraft_doc::geom::path::MAX_READ_ANCHORS, read).map_err(|e| match e {
}
let read_error = |e| match e {
ReadError::Json(e) => {
let why = if e.to_string().contains("recursion limit") { format!("its objects are nested too deep ({e})") } else { e.to_string() };
FormatError::NotVectorcraft(why)
}
ReadError::Envelope(e) => e.into(),
e => FormatError::NotVectorcraft(e.to_string()),
})?;
let pdf_compatible = pdf_member(file.json()).map_err(|e| FormatError::NotVectorcraft(e.to_string()))?;
};
// The document and the saved PDF preference come from one pass over the JSON.
let (mut f, pdf_compatible) = match within_budgets(max_nodes, max_values, || Contents::<Document>::read_with_pdf_preference(file.json())) {
Ok(read) => read,
// That pass refuses a `pdfCompatible` that is not a boolean, and any other data error.
// The file is read again without the preference, and only then is its JSON scanned for
// the preference a second time, so such a file opens as before.
Err(ReadError::Json(e)) if e.is_data() => {
let f = within_budgets(max_nodes, max_values, || Contents::<Document>::read(file.json())).map_err(read_error)?;
let pdf_compatible = pdf_member(file.json()).map_err(|e| FormatError::NotVectorcraft(e.to_string()))?;
(f, pdf_compatible)
}
Err(e) => return Err(read_error(e)),
};
let info = FileInfo { version: f.version, legacy: f.format == LEGACY_EXTENSION, pdf_compatible };
// A version 1 document saved before VectorCraft 0.21.0, or for an app before it (an older file
// version): its Twist angles turned the other way. The app that wrote it is read from the JSON
@@ -615,9 +627,17 @@ fn load_file_within(bytes: &[u8], max_nodes: usize, max_values: usize) -> Result
Ok(NativeFile { doc, depth_notes, info, profiles })
}
#[cfg(test)]
thread_local! {
/// Tests: how many times a file's JSON was scanned again for its PDF preference on this thread.
static PDF_MEMBER_SCANS: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
}
/// The saved PDF preference, falling back to the presence of its payload without retaining or
/// decoding it. The array form has neither member. Repeated members use their last value.
fn pdf_member(json: &[u8]) -> Result<bool, serde_json::Error> {
#[cfg(test)]
PDF_MEMBER_SCANS.with(|n| n.set(n.get() + 1));
struct PdfMember;
impl<'de> Visitor<'de> for PdfMember {
type Value = bool;
@@ -801,6 +821,32 @@ mod tests {
}
}
#[test]
fn only_a_preference_that_is_not_a_boolean_scans_the_json_again() {
let scans_for = |bytes: &[u8]| {
PDF_MEMBER_SCANS.with(|n| n.set(0));
let compatible = load_info(bytes).unwrap().1.pdf_compatible;
(compatible, PDF_MEMBER_SCANS.with(|n| n.get()))
};
let d = Document::new(10.0, 10.0);
assert_eq!(scans_for(&save(&d, false)), (false, 0));
assert_eq!(scans_for(&save(&d, true)), (false, 0));
for compatible in [false, true] {
let bytes = save_with(&d, &SaveOptions { pdf_compatible: Some(compatible), ..SaveOptions::default() }).unwrap();
assert_eq!(scans_for(&bytes), (compatible, 0));
}
let with_pdf = save_with(&d, &SaveOptions { pdf: Some(vec![7; 1024]), ..SaveOptions::default() }).unwrap();
assert_eq!(scans_for(&with_pdf), (true, 0));
let mut value: serde_json::Value = serde_json::from_slice(&save(&d, false)).unwrap();
for odd in [serde_json::json!("yes"), serde_json::json!(1), serde_json::json!([true])] {
value["pdfCompatible"] = odd.clone();
assert_eq!(scans_for(&serde_json::to_vec(&value).unwrap()), (false, 1), "{odd}");
}
// A document that can't be read still fails, after the second read.
value["document"] = serde_json::json!("not a document");
assert!(load_info(&serde_json::to_vec(&value).unwrap()).is_err());
}
#[test]
fn file_info_keeps_the_pdf_preference_without_artboards() {
let mut d = Document::new(10.0, 10.0);