openpencil/crates/op-pen-loader/src/payload.rs

801 lines
30 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! `.pen` / `.op` payload DTO.
//!
//! [`DocPayload`] is the layout-resolved intermediate the canonical
//! loader produces: `adapter::pen_document_to_payload` runs each
//! page-root through jian-core's `LayoutEngine` and bakes the resolved
//! AABBs + paint fields into this DTO. The `LayoutScene` builder
//! (`layout_scene.rs`) then re-shapes it into a paint-only render
//! scene.
//!
//! Carved out of `openpencil-desktop/src/persistence.rs` so library
//! crates can reuse the canonical `.op` loader; the desktop binary's
//! `rfd` Save/Open dialogs + error dialogs stay desktop-side.
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
mod compatibility;
mod fast_load;
mod legacy_normalize;
#[cfg(test)]
static THUMBNAIL_REGISTRY_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[cfg(test)]
fn lock_thumbnail_registry_for_test() -> std::sync::MutexGuard<'static, ()> {
THUMBNAIL_REGISTRY_TEST_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
pub use compatibility::{
write_normalized_source_with_current_schema, CanonicalLoad, CompatibilityReport,
NormalizedWriteError,
};
use legacy_normalize::normalize_legacy_value;
#[derive(Debug, Serialize, Deserialize)]
pub struct DocPayload {
pub version: u32,
pub active_page_index: usize,
pub pages: Vec<PagePayload>,
/// Design-token table. `#[serde(default)]` so a payload built
/// without variables still deserializes (empty table).
#[serde(default)]
pub var_table: crate::variables::VarTablePayload,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct PagePayload {
pub id: String,
pub name: String,
pub children: Vec<NodePayload>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct NodePayload {
pub id: String,
/// Original schema id (the string `id` in the `.op` file). Used
/// to look up layout rects after jian-core's `LayoutEngine`
/// computes them.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub schema_id: String,
pub kind: String,
pub name: String,
pub x: f32,
pub y: f32,
pub w: f32,
pub h: f32,
#[serde(default)]
pub fill: Option<[f32; 4]>,
/// Canonical fill stack in paint order (first entry is topmost).
/// The legacy single-fill fields below remain populated so older
/// payload readers keep working; an absent stack means the payload
/// predates multi-fill support and must use those legacy fields.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub fill_layers: Vec<jian_ops_schema::style::PenFill>,
#[serde(default)]
pub stroke: Option<StrokePayload>,
#[serde(default)]
pub text: Option<String>,
/// CSS font-family stack. Text-only.
#[serde(default)]
pub font_family: String,
#[serde(default)]
pub rotation: f32,
#[serde(default)]
pub flip_x: bool,
#[serde(default)]
pub flip_y: bool,
/// Node-level opacity (0.0..=1.0). Folded into resolved fill /
/// stroke / text / gradient-stop alpha at scene-build time (see
/// `layout_scene`), cumulative down the subtree. Defaults to 1.0.
#[serde(default = "default_opacity")]
pub opacity: f32,
/// Node-level blend operation. Unlike image/fill blend this composites the
/// complete node output, including descendants.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub blend_mode: Option<jian_ops_schema::style::BlendMode>,
#[serde(default)]
pub corner_radius: f32,
/// Per-corner radii in top-left, top-right, bottom-right,
/// bottom-left order. `corner_radius` remains the legacy maximum.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub corner_radii: Option<[f32; 4]>,
/// Container clips its children to its bounds (canonical
/// `clipContent`). The page builders also force this on for root
/// frames, which clip like artboards (TS flattener parity).
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub clip_content: bool,
/// Ellipse arc start angle in degrees (`None` = full ellipse).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub arc_start_angle: Option<f32>,
/// Ellipse arc sweep angle in degrees (`None` = full ellipse).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub arc_sweep_angle: Option<f32>,
/// Ellipse donut-hole radius, 0.0..=1.0 fraction of the radius.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub arc_inner_radius: Option<f32>,
/// Polygon side count. Defaults to the triangle parity value.
#[serde(
default = "default_polygon_sides",
skip_serializing_if = "is_default_polygon_sides"
)]
pub polygon_sides: u32,
#[serde(default)]
pub hidden: bool,
#[serde(default)]
pub locked: bool,
#[serde(default)]
pub collapsed: bool,
#[serde(default)]
pub fill_type: String,
/// Resolved gradient body for the first fill when it is a
/// `LinearGradient` / `RadialGradient`. `None` for solid /
/// image fills; `fill` still carries the first-stop colour as
/// a fallback for paint paths that don't grok gradients.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gradient: Option<GradientPayload>,
/// Resolved native SkSL shader body for the first fill when it is a
/// `Shader`. `None` for every other fill type. `fill` still carries
/// the shader's fallback colour for paint paths that can't run the
/// program.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shader: Option<ShaderPayload>,
#[serde(default)]
pub points: Vec<[f32; 2]>,
/// Path bezier anchors (absolute doc coords, handles resolved).
/// Parallel to `points` for `Path` nodes; empty otherwise.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub path_anchors: Vec<AnchorPayload>,
/// Whether a `Path` node's outline is closed (last anchor links
/// back to the first).
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub path_closed: bool,
/// Legacy mask marker retained for payload compatibility. New payloads
/// carry the exact operation in `mask_type`.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub is_mask: bool,
/// Pixel operation used to mask front-layer siblings. Unlike the legacy
/// marker this is valid on every node kind, including container subtrees.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mask_type: Option<jian_ops_schema::node::MaskType>,
/// Whether SVG path fills use the even-odd winding rule.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub even_odd_fill: bool,
/// Preserved SVG path data for imported path nodes. Coordinates
/// are local doc-px relative to the node origin.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub svg_path: Option<String>,
/// Text size in doc-px. 0 = use the renderer's default 13 px.
/// Text-only.
#[serde(default)]
pub font_size: f32,
/// CSS-style font weight (100-900). 0 = default 400. Text-only.
#[serde(default)]
pub font_weight: u16,
/// Node-level `fontStyle: italic`. Text-only.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub italic: bool,
/// Node-level underline decoration. Text-only.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub underline: bool,
/// Node-level strikethrough decoration. Text-only.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub strikethrough: bool,
/// Per-segment style runs when the canonical text content is
/// `TextContent::Styled`. Each run covers `text.len()` bytes of
/// the flattened `text` in order; empty for plain text.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub text_runs: Vec<TextRunPayload>,
/// Line-height multiplier. 0 = renderer default. Text-only.
#[serde(default)]
pub line_height: f32,
/// Extra letter spacing in doc-px. Text-only.
#[serde(default)]
pub letter_spacing: f32,
/// Horizontal alignment keyword. Text-only.
#[serde(default)]
pub text_align: String,
/// Vertical alignment keyword. Text-only.
#[serde(default)]
pub text_vertical_align: String,
#[serde(default)]
pub text_wrap: bool,
/// Drop-shadow effects.
#[serde(default)]
pub effects: Vec<crate::effects::ShadowPayload>,
/// Gaussian layer-blur radius (doc px) when the node has a Figma
/// "Layer blur" effect. Kept separate from `effects` (which is
/// shadow-only) so the save format stays additive.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub layer_blur: Option<f32>,
/// Gaussian backdrop-blur radius (doc px).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub background_blur: Option<f32>,
/// Source URL (`data:image/...;base64,...` or file path) when the
/// node is an `Image` — the canvas painter decodes the inline
/// bytes and draws them with `RenderBackend::draw_image`. `None`
/// for non-image nodes. Shared (`ImageSrc` = `Arc<str>`) with the
/// canonical document so building this payload never copies the
/// multi-MB data-URL bytes (serde still reads/writes a plain
/// string).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image_src: Option<jian_ops_schema::node::ImageSrc>,
/// Image placement mode for `image_src` (`fill`, `fit`, `crop`,
/// `tile`, `stretch`). `None` defaults to `fill`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image_fit: Option<String>,
/// Compositing mode for a canonical `ImageNode`. `None` is normal
/// source-over painting and preserves older payloads.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image_blend_mode: Option<jian_ops_schema::style::BlendMode>,
/// Figma image-fill affine transform in normalized UV coordinates.
/// `[m00, m01, m02, m10, m11, m12]` maps a node-local unit point
/// `(x, y)` to image UV as `(m00*x + m01*y + m02,
/// m10*x + m11*y + m12)`. `None` keeps the placement-mode default.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image_transform: Option<[f32; 6]>,
/// Authored source bitmap dimensions for Figma TILE paints. The renderer
/// uses these instead of a memory-saving downsampled raster's dimensions.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image_original_size: Option<[f32; 2]>,
/// Positive Figma TILE paint scale. `None` preserves the historical 1.0
/// default and non-tile image modes ignore this value.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image_tile_scale: Option<f32>,
/// Per-image adjustment values from image fills / image nodes.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image_adjustments: Option<ImageAdjustmentPayload>,
/// Composite-widget descriptor for the interactive node family
/// (switch / checkbox / slider / progress / select / radio_group /
/// text_input / text_area / number_input / tabs). The geometry +
/// container style still land on the base `NodePayload` fields (the
/// leaf widgets degrade to a `rect` / `text` `kind`), but this
/// carries the extra props the design-surface painter needs to draw
/// the recognizable static visual (track + knob, box + check, …).
/// `None` for ordinary shapes.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub widget: Option<WidgetPayload>,
#[serde(default)]
pub children: Vec<NodePayload>,
}
/// Static props for a composite widget node, harvested from the
/// canonical schema by `adapter.rs`. Sentinels: `None` numeric fields
/// fall back to the runtime defaults (`min=0`, `max=100`, `step=1`)
/// the painter mirrors from jian-core's `emit_widget_visual`.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct WidgetPayload {
/// Widget family tag (`switch` / `checkbox` / `slider` / `progress`
/// / `select` / `radio_group` / `text_input` / `text_area` /
/// `number_input` / `tabs`).
pub kind: String,
/// On/off state for switch / checkbox.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub checked: Option<bool>,
/// Numeric value for slider / progress / number_input.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub value_num: Option<f32>,
/// String value for select / radio_group / tabs / text inputs.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub value_str: Option<String>,
/// Placeholder text for inputs / select.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub placeholder: Option<String>,
/// Leading / trailing lucide glyph for input widgets (Phase 1).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub leading_icon: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub trailing_icon: Option<String>,
/// Adjacent label (checkbox).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
/// Range minimum (slider / number_input). `None` = 0.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub min: Option<f32>,
/// Range maximum (slider / progress / number_input). `None` = 100.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max: Option<f32>,
/// Range step. `None` = 1. Carried for completeness; the static
/// visual doesn't quantize.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub step: Option<f32>,
/// Progress uses a deterministic unknown-progress segment.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub indeterminate: bool,
/// True when `cornerRadius` was authored, including explicit zero.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub corner_radius_authored: bool,
/// `(value, label)` option rows for select / radio_group / tabs.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub options: Vec<WidgetOption>,
}
/// One `value` + `label` option row for select / radio_group / tabs.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct WidgetOption {
pub value: String,
pub label: String,
}
fn default_polygon_sides() -> u32 {
3
}
fn default_opacity() -> f32 {
1.0
}
fn is_default_polygon_sides(value: &u32) -> bool {
*value == 3
}
#[derive(Debug, Serialize, Deserialize)]
pub struct StrokePayload {
pub color: [f32; 4],
pub width: f32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sides: Option<[f32; 4]>,
/// Stroke placement: -1 inside, 0 center (default), +1 outside.
#[serde(default, skip_serializing_if = "is_zero_i8")]
pub align: i8,
}
fn is_zero_i8(v: &i8) -> bool {
*v == 0
}
/// One styled text segment, flattened in document order. Sentinels
/// mirror the node-level fields: `0.0` font size / `0` weight / `None`
/// fill = inherit the node's value. `italic` / `underline` /
/// `strikethrough` are RESOLVED against the node level already (a
/// segment without an override inherits the node's flag).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TextRunPayload {
pub text: String,
#[serde(default)]
pub font_size: f32,
#[serde(default)]
pub font_weight: u16,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fill: Option<[f32; 4]>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub italic: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub underline: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub strikethrough: bool,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
pub struct ImageAdjustmentPayload {
#[serde(default)]
pub exposure: f32,
#[serde(default)]
pub contrast: f32,
#[serde(default)]
pub saturation: f32,
#[serde(default)]
pub temperature: f32,
#[serde(default)]
pub tint: f32,
#[serde(default)]
pub highlights: f32,
#[serde(default)]
pub shadows: f32,
}
/// One resolved gradient stop — offset 0.0..=1.0 + RGBA colour.
/// Mirrors `jian_ops_schema::GradientStop` but with the colour
/// hex pre-parsed into the same `[r,g,b,a]` array `NodePayload.fill`
/// uses.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GradientStopPayload {
pub offset: f32,
pub color: [f32; 4],
}
/// Layout-resolved gradient body for `NodePayload.gradient`.
/// Pre-parsed (colour hex → RGBA, opacity baked into the variant)
/// so the scene builder + canvas painter never re-walk the
/// canonical schema.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum GradientPayload {
Linear {
/// Gradient angle in degrees, canonical `.op` convention:
/// 0° = bottom→top, 90° = left→right, 180° = top→bottom
/// (matches CSS `to-top`). The renderer subtracts 90° before
/// projecting endpoints, so storing as authored keeps the
/// scene wire-format equal to the file's `angle`.
angle_deg: f32,
opacity: f32,
stops: Vec<GradientStopPayload>,
},
Radial {
/// Centre x as a 0.0..=1.0 fraction of bounds width.
cx: f32,
/// Centre y as a 0.0..=1.0 fraction of bounds height.
cy: f32,
/// Outer radius as a 0.0..=1.0 fraction of `max(w, h)` —
/// matches the TS renderer, so the same `.op` file paints at
/// the same radial size on native + web + export.
radius: f32,
opacity: f32,
stops: Vec<GradientStopPayload>,
},
/// Uniform-grid mesh gradient (v1). `colors` is a row-major
/// `rows`×`cols` lattice of pre-resolved RGBA values (length ==
/// `rows * cols`); vertex `(r, c)` lives at `colors[r * cols + c]`.
/// Opacity is carried separately and folded by the painter (parity
/// with how the Linear / Radial variants thread `opacity`).
Mesh {
rows: u32,
cols: u32,
colors: Vec<[f32; 4]>,
opacity: f32,
},
}
/// One resolved SkSL shader uniform — name plus a concrete float vector
/// (length 1 = float, 2/3/4 = vec*). A `color` uniform is pre-expanded
/// into a 4-float premultiplied-RGBA `vec4` here so the scene builder +
/// painter never re-walk the canonical schema.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ShaderUniformPayload {
pub name: String,
pub values: Vec<f32>,
}
/// Layout-resolved native SkSL shader body for `NodePayload.shader`.
/// `sksl` is the RAW (untrusted) source; uniforms are pre-resolved.
/// `fallback` is the `[r,g,b,a]` solid colour painted when a host can't
/// compile the program (first `color` uniform, else mid-gray) — kept
/// alongside `NodePayload.fill` so the degradation path always has a
/// visible colour.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ShaderPayload {
pub sksl: String,
#[serde(default)]
pub uniforms: Vec<ShaderUniformPayload>,
pub opacity: f32,
pub fallback: [f32; 4],
}
/// One path bezier anchor in absolute doc coords. `handle_in` /
/// `handle_out` are absolute control-point positions (already
/// resolved from the schema's anchor-relative deltas); `point_type`
/// is `0` corner / `1` mirrored / `2` independent.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AnchorPayload {
pub x: f32,
pub y: f32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub handle_in: Option<[f32; 2]>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub handle_out: Option<[f32; 2]>,
#[serde(default)]
pub point_type: u8,
}
/// Wrapper around `jian_ops_schema::load_str` that loads an unrecognised
/// legacy major (for example the TS app's `version: "2.8"`) as v1.0. The
/// schema stays strict for direct callers; this product loader deliberately
/// opts into best-effort compatibility and patches the schema-owned JSON DOM
/// before its version check, so a large legacy file is not parsed twice.
pub fn load_canonical(
src: &str,
) -> Result<
jian_ops_schema::LoadResult<jian_ops_schema::PenDocument>,
jian_ops_schema::OpsSchemaError,
> {
// jian-ops-schema owns the one full JSON Value required for warnings,
// image-table extraction, and typed conversion. Repair legacy fields in
// that same tree: the old Value -> String -> Value path doubled parsing
// and made large Figma conversions retain far more allocator pages.
load_canonical_with_compatibility(src).map(|result| result.loaded)
}
/// Load a canonical document and report only explicit compatibility repairs
/// that make a user-approved current-schema rewrite safe.
pub fn load_canonical_with_compatibility(
src: &str,
) -> Result<CanonicalLoad, jian_ops_schema::OpsSchemaError> {
if let Some(loaded) = fast_load::try_load_preserve(src) {
return Ok(CanonicalLoad {
loaded,
compatibility: CompatibilityReport::default(),
});
}
if looks_deeply_nested(src) {
return load_canonical_deep(src);
}
let mut best_effort_version = None;
let mut normalized_legacy = false;
let loaded = load_str_with_preprocess(src, |value| {
normalized_legacy = normalize_legacy_value(value);
best_effort_version = patch_unsupported_version(value);
});
if let Some(found) = best_effort_version.as_deref() {
log_best_effort_version(found);
}
match loaded {
Ok(loaded) => Ok(CanonicalLoad {
loaded,
compatibility: CompatibilityReport {
normalized_legacy,
patched_legacy_version: best_effort_version,
},
}),
Err(jian_ops_schema::OpsSchemaError::Json(err)) if is_recursion_limit_error(&err) => {
load_canonical_deep(src)
}
Err(other) => Err(other),
}
}
fn load_str_with_preprocess(
src: &str,
preprocess: impl FnOnce(&mut serde_json::Value),
) -> Result<
jian_ops_schema::LoadResult<jian_ops_schema::PenDocument>,
jian_ops_schema::OpsSchemaError,
> {
jian_ops_schema::compat::load_str_with_preprocess(
src,
jian_ops_schema::compat::LoadOptions::default(),
preprocess,
)
}
/// Patch an unsupported legacy major in the already-parsed DOM. The public
/// product loader has always retried these documents as v1.0; doing it before
/// the schema check preserves that behavior without parsing a large file twice.
fn patch_unsupported_version(value: &mut serde_json::Value) -> Option<String> {
// `formatVersion` is the authoritative schema version. A future major
// must remain unsupported: rewriting it through today's typed model could
// silently discard fields. The historical best-effort rule applies only
// to files that have no `formatVersion` and used the old product version
// (for example `version: "2.8"`) as their sole version marker.
if value
.get("formatVersion")
.and_then(serde_json::Value::as_str)
.is_some()
{
return None;
}
let found = value.get("version").and_then(serde_json::Value::as_str);
if jian_ops_schema::version::supports(found) {
return None;
}
let found = found.unwrap_or("<missing>").to_owned();
if let Some(obj) = value.as_object_mut() {
obj.insert(
"version".to_string(),
serde_json::Value::String("1.0".to_string()),
);
obj.remove("formatVersion");
}
Some(found)
}
fn log_best_effort_version(found: &str) {
eprintln!(
"[open] file version {} is outside the canonical schema's supported range; \
loading as v1.0 (best-effort)",
found
);
}
fn looks_deeply_nested(src: &str) -> bool {
let mut depth = 0usize;
let mut in_string = false;
let mut escaped = false;
for byte in src.bytes() {
if in_string {
if escaped {
escaped = false;
} else if byte == b'\\' {
escaped = true;
} else if byte == b'"' {
in_string = false;
}
continue;
}
match byte {
b'"' => in_string = true,
b'{' | b'[' => {
depth = depth.saturating_add(1);
if depth > 120 {
return true;
}
}
b'}' | b']' => depth = depth.saturating_sub(1),
_ => {}
}
}
false
}
fn is_recursion_limit_error(err: &serde_json::Error) -> bool {
err.to_string().contains("recursion limit")
}
fn deserialize_deep<T: DeserializeOwned>(src: &str) -> Result<T, serde_json::Error> {
let mut de = serde_json::Deserializer::from_str(src);
de.disable_recursion_limit();
let value = T::deserialize(serde_stacker::Deserializer::new(&mut de))?;
de.end()?;
Ok(value)
}
/// `serde_json::from_value` with the same stack protection as
/// [`deserialize_deep`] — the deep-document path parses to a `Value`
/// first (to inline the image table), and the typed parse of that
/// `Value` must survive the same nesting depth that routed us here.
fn deserialize_deep_value<T: DeserializeOwned>(
value: serde_json::Value,
) -> Result<T, serde_json::Error> {
use serde::de::IntoDeserializer;
let de = serde_stacker::Deserializer::new(value.into_deserializer());
T::deserialize(de)
}
fn deep_load_warnings(raw: &serde_json::Value) -> Vec<jian_ops_schema::LoadWarning> {
use jian_ops_schema::LoadWarning;
use serde_json::Value;
let mut warnings = Vec::new();
if let Value::Object(map) = raw {
for field in map.keys() {
if !is_known_top_level_field(field) {
warnings.push(LoadWarning::UnknownField {
path: "$".to_owned(),
field: field.to_owned(),
});
}
}
}
let format_version = raw.get("formatVersion").and_then(Value::as_str);
let legacy_version = raw.get("version").and_then(Value::as_str);
if let Some(found) = format_version {
let current =
jian_ops_schema::version::parse(Some(jian_ops_schema::version::FORMAT_VERSION_CURRENT));
let declared = jian_ops_schema::version::parse(Some(found));
if declared > current {
warnings.push(LoadWarning::FutureFormatVersion {
found: found.to_owned(),
supported_max: jian_ops_schema::version::FORMAT_VERSION_CURRENT,
});
}
}
if raw.get("responsive").and_then(Value::as_bool) == Some(true) {
let declared = format_version.or(legacy_version);
if jian_ops_schema::version::parse(declared) < (1, 2) {
warnings.push(LoadWarning::ResponsiveBelowMinor {
declared: declared.unwrap_or_default().to_owned(),
});
}
collect_viewport_writes_deep(raw, &mut warnings);
}
if raw.get("logicModules").is_some() {
warnings.push(LoadWarning::LogicModulesSkipped {
reason: "Tier 3 WASM is not implemented in this build",
});
}
warnings
}
fn is_known_top_level_field(field: &str) -> bool {
matches!(
field,
"formatVersion"
| "responsive"
| "version"
| "id"
| "name"
| "themes"
| "variables"
| "pages"
| "children"
| "app"
| "routes"
| "state"
| "lifecycle"
| "logicModules"
| "designMd"
| "conversion"
| "editorMeta"
| "images"
| "imageThumbs"
)
}
fn collect_viewport_writes_deep(
root: &serde_json::Value,
warnings: &mut Vec<jian_ops_schema::LoadWarning>,
) {
use serde_json::Value;
let mut stack = vec![(root, "$".to_owned())];
while let Some((value, path)) = stack.pop() {
match value {
Value::Object(map) => {
for (key, child) in map.iter().rev() {
let child_path = format!("{path}.{key}");
if key.starts_with("$viewport") {
warnings.push(jian_ops_schema::LoadWarning::ViewportWrite {
path: child_path.clone(),
});
}
stack.push((child, child_path));
}
}
Value::Array(values) => {
for (index, child) in values.iter().enumerate().rev() {
stack.push((child, format!("{path}[{index}]")));
}
}
_ => {}
}
}
}
fn load_canonical_deep(src: &str) -> Result<CanonicalLoad, jian_ops_schema::OpsSchemaError> {
// Always use one stack-protected DOM for the deep compatibility pipeline.
// Legacy repair and version patching must not depend on the presence of an
// image table, and consuming that same DOM avoids a second full parse.
let mut raw: serde_json::Value = deserialize_deep(src)?;
let normalized_legacy = normalize_legacy_value(&mut raw);
reject_unsupported_format_version(&raw)?;
let patched_legacy_version = patch_unsupported_version(&mut raw);
if let Some(found) = patched_legacy_version.as_deref() {
log_best_effort_version(found);
}
let warnings = deep_load_warnings(&raw);
let pending_thumbs = jian_ops_schema::image_thumbs::take_pending_from_document(&mut raw);
let table = jian_ops_schema::image_table::take_image_table(&mut raw);
let mut doc = jian_ops_schema::node::image_src::intern::with_load_scope(table, || {
deserialize_deep_value(raw)
})?;
jian_ops_schema::image_thumbs::attach_to_document(&mut doc, pending_thumbs);
Ok(CanonicalLoad {
loaded: jian_ops_schema::LoadResult {
value: doc,
warnings,
},
compatibility: CompatibilityReport {
normalized_legacy,
patched_legacy_version,
},
})
}
fn reject_unsupported_format_version(
value: &serde_json::Value,
) -> Result<(), jian_ops_schema::OpsSchemaError> {
let Some(found) = value
.get("formatVersion")
.and_then(serde_json::Value::as_str)
else {
return Ok(());
};
if jian_ops_schema::version::supports(Some(found)) {
return Ok(());
}
Err(jian_ops_schema::OpsSchemaError::UnsupportedFormatVersion {
found: found.to_owned(),
supported: jian_ops_schema::version::FORMAT_VERSION_CURRENT,
})
}
#[cfg(test)]
#[path = "payload/load_tests.rs"]
mod load_tests;
#[cfg(test)]
#[path = "payload/deep_image_thumb_tests.rs"]
mod deep_image_thumb_tests;