//! The JSON view of a deck: what `GET /api/v1/decks/{sqid}` returns and //! what the renderer, the thumbnailer and the PDF exporter read. Its //! shape is the contract in the task brief, verbatim: //! //! ```json //! {"meta":{"title":"","theme":"paper","accent":null,"fonts":null,"aspect":"16:9"}, //! "slides":[{"id":"…","layout":"flow","template":"title", //! "background":{"kind":"theme","value":""}, //! "phone":"inherit","transition":"none", //! "notes":[[{"t":"Say hello"}]], //! "blocks":[{"id":"…","type":"heading","props":{"level":1}, //! "region":"heading","box":null,"z":0,"phone":{}, //! "text":[[{"t":"Hello","b":true}]]}]}], //! "assets":{"id":{"ref":"…","name":"","width":0,"height":0,"alt":""}}} //! ``` //! //! `blocks` is in reading order — the `order` key sorted, array index //! for ties — and the key itself is not in the view: one sequence for //! every reader (schema.rs, "block order"). //! //! Text is an array of paragraphs, a paragraph an array of runs //! `{t, b?, i?, c?, h?, href?}` with false and absent marks omitted. A //! block's `text` key is present for text-bearing types (any block that //! carries a `text` fragment) and absent otherwise; a `table`'s cells //! appear as `props.cells`, a row-major array of texts. //! //! Every key has a default so a deck a peer left half-written still //! renders as something rather than failing to serialise: an absent //! `layout` is `flow`, an absent `template` is `blank`, an absent `z` //! is 0. Key order within `props`, `assets` and other free-form objects //! is sorted (serde_json's default map), so the output is deterministic //! and two documents with the same content render byte-identically — //! which is what the update round-trip tests compare. use crate::schema::{self, Paragraph}; use serde::Serialize; use serde_json::Value; use std::collections::BTreeMap; use yrs::{Array, Doc, Map, ReadTxn, Transact}; #[derive(Serialize)] struct View { meta: Meta, slides: Vec, assets: BTreeMap, } #[derive(Serialize)] struct Meta { title: String, theme: String, accent: Option, fonts: Option, aspect: String, } #[derive(Serialize)] struct Slide { id: String, layout: String, template: String, background: Value, phone: String, transition: String, notes: Vec, blocks: Vec, } #[derive(Serialize)] struct Block { id: String, #[serde(rename = "type")] kind: String, props: Value, region: Option, #[serde(rename = "box")] bx: Option, z: Value, phone: Value, #[serde(skip_serializing_if = "Option::is_none")] text: Option>, } fn empty_object() -> Value { Value::Object(serde_json::Map::new()) } // --- typed leaves --- // // The host reads this view through typed structs (internal/slides/deck), // and a browser peer can put any shape at any key: `box.x = "a"`, // `background.kind = 5`, `fonts.body = []`, an asset that is a string. // Passed through untyped, any one of those fails the host's parse of the // WHOLE deck and every surface that opens it. So each leaf with a fixed // shape is rebuilt here from the fields that have the right type, and a // shape that cannot be made is omitted (null, the default, a skipped // entry) rather than emitted. `fixtures/view.mjs` applies the same // rules, key for key, because the vectors compare the two. type Object = serde_json::Map; fn as_object(v: Option) -> Option { match v { Some(Value::Object(o)) => Some(o), _ => None, } } /// A present string field, or None. `Err` is a present field of the /// wrong type — the case that disqualifies a fixed-shape object. fn string_field(o: &Object, key: &str) -> Result, ()> { match o.get(key) { None | Some(Value::Null) => Ok(None), Some(Value::String(s)) => Ok(Some(s.clone())), Some(_) => Err(()), } } fn number_field(o: &Object, key: &str) -> Result, ()> { match o.get(key) { None | Some(Value::Null) => Ok(None), Some(Value::Number(n)) => Ok(n.as_f64()), Some(_) => Err(()), } } /// `{x, y, w, h}`, all four numbers, or None. fn box_value(v: Option) -> Option { let o = as_object(v)?; let mut out = Object::new(); for key in ["x", "y", "w", "h"] { let n = number_field(&o, key).ok()??; out.insert(key.into(), schema::number_json(n)); } Some(Value::Object(out)) } /// `{kind, value}` with kind a string, else the theme default. fn background_value(v: Option) -> Value { let fallback = || serde_json::json!({"kind": "theme", "value": ""}); let Some(o) = as_object(v) else { return fallback(); }; let (Ok(Some(kind)), Ok(value)) = (string_field(&o, "kind"), string_field(&o, "value")) else { return fallback(); }; serde_json::json!({"kind": kind, "value": value.unwrap_or_default()}) } /// `{heading, body}`, both strings when present, else None. fn fonts_value(v: Option) -> Option { let o = as_object(v)?; let heading = string_field(&o, "heading").ok()?; let body = string_field(&o, "body").ok()?; Some(serde_json::json!({ "heading": heading.unwrap_or_default(), "body": body.unwrap_or_default(), })) } /// The §2.5 overrides that have the right type, field by field: the /// layer is additions to the desktop layout, so a bad `hidden` should /// not cost a good `order`. `order` is a whole number to the host. fn phone_value(v: Option) -> Value { let Some(o) = as_object(v) else { return empty_object(); }; let mut out = Object::new(); if let Some(Value::Bool(b)) = o.get("hidden") { out.insert("hidden".into(), Value::Bool(*b)); } if let Ok(Some(n)) = number_field(&o, "order") { out.insert("order".into(), schema::number_json(n.trunc())); } if let Ok(Some(s)) = string_field(&o, "size") { out.insert("size".into(), Value::String(s)); } if let Some(b) = box_value(o.get("box").cloned()) { out.insert("box".into(), b); } Value::Object(out) } /// `{ref, name, width, height, alt}` for an entry with a string `ref`; /// None for anything else. The other four take their defaults when /// absent or mistyped: the picture is the ref, and losing it over a /// bad alt would be the wrong trade. fn asset_value(v: Value) -> Option { let o = as_object(Some(v))?; let r = string_field(&o, "ref").ok()??; let text = |key: &str| string_field(&o, key).ok().flatten().unwrap_or_default(); let whole = |key: &str| { number_field(&o, key) .ok() .flatten() .map(|n| schema::number_json(n.trunc())) .unwrap_or(Value::from(0)) }; Some(serde_json::json!({ "ref": r, "name": text("name"), "width": whole("width"), "height": whole("height"), "alt": text("alt"), })) } fn block_view(txn: &T, m: &yrs::MapRef) -> Block { let mut props = schema::get_object(txn, m, "props").unwrap_or_else(empty_object); if let Some(cells) = schema::get_array(txn, m, "cells") { let texts: Vec = cells .iter(txn) .map(|c| match c { yrs::Out::YXmlFragment(f) => { serde_json::to_value(schema::read_text(txn, &f)).unwrap_or_default() } other => schema::out_to_json(txn, &other), }) .collect(); if let Value::Object(p) = &mut props { p.insert("cells".into(), Value::Array(texts)); } } Block { id: schema::get_string(txn, m, "id").unwrap_or_default(), kind: schema::get_string(txn, m, "type").unwrap_or_default(), props, region: schema::get_string(txn, m, "region"), bx: box_value(schema::get_object(txn, m, "box")), z: schema::number_json(schema::get_number(txn, m, "z").unwrap_or(0.0)), phone: phone_value(schema::get_object(txn, m, "phone")), text: schema::get_fragment(txn, m, "text").map(|f| schema::read_text(txn, &f)), } } fn slide_view(txn: &T, m: &yrs::MapRef) -> Slide { // Blocks in reading order (schema::ordered_maps), so every // reader of the view — the renderer, the layout engine, the API — // sees one sequence and none of them knows there is an `order` key. let blocks = schema::get_array(txn, m, "blocks") .map(|a| { schema::ordered_maps(txn, &a) .iter() .map(|(_, b)| block_view(txn, b)) .collect() }) .unwrap_or_default(); Slide { id: schema::get_string(txn, m, "id").unwrap_or_default(), layout: schema::get_string(txn, m, "layout").unwrap_or_else(|| "flow".into()), template: schema::get_string(txn, m, "template") .unwrap_or_else(|| schema::DEFAULT_TEMPLATE.into()), background: background_value(schema::get_object(txn, m, "background")), phone: schema::get_string(txn, m, "phone").unwrap_or_else(|| "inherit".into()), transition: schema::get_string(txn, m, "transition").unwrap_or_else(|| "none".into()), notes: schema::get_fragment(txn, m, "notes") .map(|f| schema::read_text(txn, &f)) .unwrap_or_default(), blocks, } } /// The deck as JSON. Never empty: an untouched document renders as the /// defaults with no slides and no assets. pub fn view_json(doc: &Doc) -> String { let roots = schema::roots(doc); let txn = doc.transact(); let meta = Meta { title: schema::get_string(&txn, &roots.meta, "title").unwrap_or_default(), theme: schema::get_string(&txn, &roots.meta, "theme") .unwrap_or_else(|| schema::DEFAULT_THEME.into()), accent: schema::get_string(&txn, &roots.meta, "accent"), fonts: fonts_value(schema::get_object(&txn, &roots.meta, "fonts")), aspect: schema::get_string(&txn, &roots.meta, "aspect") .unwrap_or_else(|| schema::DEFAULT_ASPECT.into()), }; // Slides in reading order (schema::ordered_maps), the same way the // blocks of one are: the `order` key never reaches the view, so no // reader of it knows there is one. let slides = schema::ordered_maps(&txn, &roots.slides) .iter() .map(|(_, m)| slide_view(&txn, m)) .collect(); let assets = roots .assets .iter(&txn) .filter_map(|(k, v)| asset_value(schema::out_to_json(&txn, &v)).map(|a| (k.to_string(), a))) .collect(); let view = View { meta, slides, assets, }; // Serialising a struct of strings, numbers and already-built Values // cannot fail; the fallback is belt and braces, not a path. serde_json::to_string(&view).unwrap_or_else(|_| String::from("{}")) } #[cfg(test)] mod tests { //! The view has to be readable by the host's typed `deck.Parse` for //! ANY document the CRDT can hold, not only ones this crate's ops //! wrote — a browser peer can put any shape at any key. These build //! the malformed shapes directly with yrs and require the view to //! omit or default them rather than pass them through untyped. //! `fixtures/view.test.mjs` is the JS twin. use super::*; use crate::new_doc; use yrs::{In, MapPrelim}; fn any(v: serde_json::Value) -> In { In::Any(schema::json_to_any(&v)) } fn poisoned() -> Value { let doc = new_doc("poison", 7); let roots = schema::roots(&doc); { let mut txn = doc.transact_mut(); roots.meta.insert( &mut txn, "fonts", any(serde_json::json!({"heading": "Serif", "body": []})), ); let phone: MapPrelim = [ ("hidden", any(serde_json::json!("yes"))), ("order", any(serde_json::json!(2.7))), ("size", any(serde_json::json!(7))), ( "box", any(serde_json::json!({"x": 0, "y": 0, "w": 1, "h": 0.5})), ), ] .into_iter() .collect(); let block: MapPrelim = [ ("id", any(serde_json::json!("blk"))), ("type", any(serde_json::json!("text"))), ( "box", any(serde_json::json!({"x": "a", "y": 0, "w": 1, "h": 1})), ), ("z", any(serde_json::json!(1))), ("phone", In::Map(phone)), ] .into_iter() .collect(); let background: MapPrelim = [ ("kind", any(serde_json::json!(5))), ("value", any(serde_json::json!(""))), ] .into_iter() .collect(); let blocks: yrs::ArrayPrelim = [In::Map(block)].into_iter().collect(); let slide: MapPrelim = [ ("id", any(serde_json::json!("sld"))), ("layout", any(serde_json::json!("canvas"))), ("background", In::Map(background)), ("blocks", In::Array(blocks)), ] .into_iter() .collect(); roots.slides.push_back(&mut txn, slide); roots .assets .insert(&mut txn, "str", any(serde_json::json!("not a map"))); roots .assets .insert(&mut txn, "noref", any(serde_json::json!({"name": "x.png"}))); roots.assets.insert( &mut txn, "badfields", any(serde_json::json!({ "ref": "abc", "name": 3, "width": "w", "height": 9.9, "alt": null })), ); roots.assets.insert( &mut txn, "good", any(serde_json::json!({ "ref": "def", "name": "p.png", "width": 10, "height": 20, "alt": "A" })), ); } serde_json::from_str(&view_json(&doc)).expect("view is JSON") } #[test] fn malformed_fonts_are_null() { assert_eq!(poisoned()["meta"]["fonts"], Value::Null); } #[test] fn malformed_background_is_the_default() { assert_eq!( poisoned()["slides"][0]["background"], serde_json::json!({"kind": "theme", "value": ""}) ); } #[test] fn malformed_box_is_null_and_phone_keeps_only_typed_fields() { let v = poisoned(); let block = &v["slides"][0]["blocks"][0]; assert_eq!(block["box"], Value::Null); assert_eq!( block["phone"], serde_json::json!({"order": 2, "box": {"x": 0, "y": 0, "w": 1, "h": 0.5}}) ); } #[test] fn assets_are_typed_or_skipped() { let v = poisoned(); assert_eq!( v["assets"], serde_json::json!({ "badfields": {"ref": "abc", "name": "", "width": 0, "height": 9, "alt": ""}, "good": {"ref": "def", "name": "p.png", "width": 10, "height": 20, "alt": "A"} }) ); } }