| 1 | //! The JSON view of a deck: what `GET /api/v1/decks/{sqid}` returns and |
| 2 | //! what the renderer, the thumbnailer and the PDF exporter read. Its |
| 3 | //! shape is the contract in the task brief, verbatim: |
| 4 | //! |
| 5 | //! ```json |
| 6 | //! {"meta":{"title":"","theme":"paper","accent":null,"fonts":null,"aspect":"16:9"}, |
| 7 | //! "slides":[{"id":"…","layout":"flow","template":"title", |
| 8 | //! "background":{"kind":"theme","value":""}, |
| 9 | //! "phone":"inherit","transition":"none", |
| 10 | //! "notes":[[{"t":"Say hello"}]], |
| 11 | //! "blocks":[{"id":"…","type":"heading","props":{"level":1}, |
| 12 | //! "region":"heading","box":null,"z":0,"phone":{}, |
| 13 | //! "text":[[{"t":"Hello","b":true}]]}]}], |
| 14 | //! "assets":{"id":{"ref":"…","name":"","width":0,"height":0,"alt":""}}} |
| 15 | //! ``` |
| 16 | //! |
| 17 | //! `blocks` is in reading order — the `order` key sorted, array index |
| 18 | //! for ties — and the key itself is not in the view: one sequence for |
| 19 | //! every reader (schema.rs, "block order"). |
| 20 | //! |
| 21 | //! Text is an array of paragraphs, a paragraph an array of runs |
| 22 | //! `{t, b?, i?, c?, h?, href?}` with false and absent marks omitted. A |
| 23 | //! block's `text` key is present for text-bearing types (any block that |
| 24 | //! carries a `text` fragment) and absent otherwise; a `table`'s cells |
| 25 | //! appear as `props.cells`, a row-major array of texts. |
| 26 | //! |
| 27 | //! Every key has a default so a deck a peer left half-written still |
| 28 | //! renders as something rather than failing to serialise: an absent |
| 29 | //! `layout` is `flow`, an absent `template` is `blank`, an absent `z` |
| 30 | //! is 0. Key order within `props`, `assets` and other free-form objects |
| 31 | //! is sorted (serde_json's default map), so the output is deterministic |
| 32 | //! and two documents with the same content render byte-identically — |
| 33 | //! which is what the update round-trip tests compare. |
| 34 | |
| 35 | use crate::schema::{self, Paragraph}; |
| 36 | use serde::Serialize; |
| 37 | use serde_json::Value; |
| 38 | use std::collections::BTreeMap; |
| 39 | use yrs::{Array, Doc, Map, ReadTxn, Transact}; |
| 40 | |
| 41 | #[derive(Serialize)] |
| 42 | struct View { |
| 43 | meta: Meta, |
| 44 | slides: Vec<Slide>, |
| 45 | assets: BTreeMap<String, Value>, |
| 46 | } |
| 47 | |
| 48 | #[derive(Serialize)] |
| 49 | struct Meta { |
| 50 | title: String, |
| 51 | theme: String, |
| 52 | accent: Option<String>, |
| 53 | fonts: Option<Value>, |
| 54 | aspect: String, |
| 55 | } |
| 56 | |
| 57 | #[derive(Serialize)] |
| 58 | struct Slide { |
| 59 | id: String, |
| 60 | layout: String, |
| 61 | template: String, |
| 62 | background: Value, |
| 63 | phone: String, |
| 64 | transition: String, |
| 65 | notes: Vec<Paragraph>, |
| 66 | blocks: Vec<Block>, |
| 67 | } |
| 68 | |
| 69 | #[derive(Serialize)] |
| 70 | struct Block { |
| 71 | id: String, |
| 72 | #[serde(rename = "type")] |
| 73 | kind: String, |
| 74 | props: Value, |
| 75 | region: Option<String>, |
| 76 | #[serde(rename = "box")] |
| 77 | bx: Option<Value>, |
| 78 | z: Value, |
| 79 | phone: Value, |
| 80 | #[serde(skip_serializing_if = "Option::is_none")] |
| 81 | text: Option<Vec<Paragraph>>, |
| 82 | } |
| 83 | |
| 84 | fn empty_object() -> Value { |
| 85 | Value::Object(serde_json::Map::new()) |
| 86 | } |
| 87 | |
| 88 | // --- typed leaves --- |
| 89 | // |
| 90 | // The host reads this view through typed structs (internal/slides/deck), |
| 91 | // and a browser peer can put any shape at any key: `box.x = "a"`, |
| 92 | // `background.kind = 5`, `fonts.body = []`, an asset that is a string. |
| 93 | // Passed through untyped, any one of those fails the host's parse of the |
| 94 | // WHOLE deck and every surface that opens it. So each leaf with a fixed |
| 95 | // shape is rebuilt here from the fields that have the right type, and a |
| 96 | // shape that cannot be made is omitted (null, the default, a skipped |
| 97 | // entry) rather than emitted. `fixtures/view.mjs` applies the same |
| 98 | // rules, key for key, because the vectors compare the two. |
| 99 | |
| 100 | type Object = serde_json::Map<String, Value>; |
| 101 | |
| 102 | fn as_object(v: Option<Value>) -> Option<Object> { |
| 103 | match v { |
| 104 | Some(Value::Object(o)) => Some(o), |
| 105 | _ => None, |
| 106 | } |
| 107 | } |
| 108 | |
| 109 | /// A present string field, or None. `Err` is a present field of the |
| 110 | /// wrong type — the case that disqualifies a fixed-shape object. |
| 111 | fn string_field(o: &Object, key: &str) -> Result<Option<String>, ()> { |
| 112 | match o.get(key) { |
| 113 | None | Some(Value::Null) => Ok(None), |
| 114 | Some(Value::String(s)) => Ok(Some(s.clone())), |
| 115 | Some(_) => Err(()), |
| 116 | } |
| 117 | } |
| 118 | |
| 119 | fn number_field(o: &Object, key: &str) -> Result<Option<f64>, ()> { |
| 120 | match o.get(key) { |
| 121 | None | Some(Value::Null) => Ok(None), |
| 122 | Some(Value::Number(n)) => Ok(n.as_f64()), |
| 123 | Some(_) => Err(()), |
| 124 | } |
| 125 | } |
| 126 | |
| 127 | /// `{x, y, w, h}`, all four numbers, or None. |
| 128 | fn box_value(v: Option<Value>) -> Option<Value> { |
| 129 | let o = as_object(v)?; |
| 130 | let mut out = Object::new(); |
| 131 | for key in ["x", "y", "w", "h"] { |
| 132 | let n = number_field(&o, key).ok()??; |
| 133 | out.insert(key.into(), schema::number_json(n)); |
| 134 | } |
| 135 | Some(Value::Object(out)) |
| 136 | } |
| 137 | |
| 138 | /// `{kind, value}` with kind a string, else the theme default. |
| 139 | fn background_value(v: Option<Value>) -> Value { |
| 140 | let fallback = || serde_json::json!({"kind": "theme", "value": ""}); |
| 141 | let Some(o) = as_object(v) else { |
| 142 | return fallback(); |
| 143 | }; |
| 144 | let (Ok(Some(kind)), Ok(value)) = (string_field(&o, "kind"), string_field(&o, "value")) else { |
| 145 | return fallback(); |
| 146 | }; |
| 147 | serde_json::json!({"kind": kind, "value": value.unwrap_or_default()}) |
| 148 | } |
| 149 | |
| 150 | /// `{heading, body}`, both strings when present, else None. |
| 151 | fn fonts_value(v: Option<Value>) -> Option<Value> { |
| 152 | let o = as_object(v)?; |
| 153 | let heading = string_field(&o, "heading").ok()?; |
| 154 | let body = string_field(&o, "body").ok()?; |
| 155 | Some(serde_json::json!({ |
| 156 | "heading": heading.unwrap_or_default(), |
| 157 | "body": body.unwrap_or_default(), |
| 158 | })) |
| 159 | } |
| 160 | |
| 161 | /// The §2.5 overrides that have the right type, field by field: the |
| 162 | /// layer is additions to the desktop layout, so a bad `hidden` should |
| 163 | /// not cost a good `order`. `order` is a whole number to the host. |
| 164 | fn phone_value(v: Option<Value>) -> Value { |
| 165 | let Some(o) = as_object(v) else { |
| 166 | return empty_object(); |
| 167 | }; |
| 168 | let mut out = Object::new(); |
| 169 | if let Some(Value::Bool(b)) = o.get("hidden") { |
| 170 | out.insert("hidden".into(), Value::Bool(*b)); |
| 171 | } |
| 172 | if let Ok(Some(n)) = number_field(&o, "order") { |
| 173 | out.insert("order".into(), schema::number_json(n.trunc())); |
| 174 | } |
| 175 | if let Ok(Some(s)) = string_field(&o, "size") { |
| 176 | out.insert("size".into(), Value::String(s)); |
| 177 | } |
| 178 | if let Some(b) = box_value(o.get("box").cloned()) { |
| 179 | out.insert("box".into(), b); |
| 180 | } |
| 181 | Value::Object(out) |
| 182 | } |
| 183 | |
| 184 | /// `{ref, name, width, height, alt}` for an entry with a string `ref`; |
| 185 | /// None for anything else. The other four take their defaults when |
| 186 | /// absent or mistyped: the picture is the ref, and losing it over a |
| 187 | /// bad alt would be the wrong trade. |
| 188 | fn asset_value(v: Value) -> Option<Value> { |
| 189 | let o = as_object(Some(v))?; |
| 190 | let r = string_field(&o, "ref").ok()??; |
| 191 | let text = |key: &str| string_field(&o, key).ok().flatten().unwrap_or_default(); |
| 192 | let whole = |key: &str| { |
| 193 | number_field(&o, key) |
| 194 | .ok() |
| 195 | .flatten() |
| 196 | .map(|n| schema::number_json(n.trunc())) |
| 197 | .unwrap_or(Value::from(0)) |
| 198 | }; |
| 199 | Some(serde_json::json!({ |
| 200 | "ref": r, |
| 201 | "name": text("name"), |
| 202 | "width": whole("width"), |
| 203 | "height": whole("height"), |
| 204 | "alt": text("alt"), |
| 205 | })) |
| 206 | } |
| 207 | |
| 208 | fn block_view<T: ReadTxn>(txn: &T, m: &yrs::MapRef) -> Block { |
| 209 | let mut props = schema::get_object(txn, m, "props").unwrap_or_else(empty_object); |
| 210 | if let Some(cells) = schema::get_array(txn, m, "cells") { |
| 211 | let texts: Vec<Value> = cells |
| 212 | .iter(txn) |
| 213 | .map(|c| match c { |
| 214 | yrs::Out::YXmlFragment(f) => { |
| 215 | serde_json::to_value(schema::read_text(txn, &f)).unwrap_or_default() |
| 216 | } |
| 217 | other => schema::out_to_json(txn, &other), |
| 218 | }) |
| 219 | .collect(); |
| 220 | if let Value::Object(p) = &mut props { |
| 221 | p.insert("cells".into(), Value::Array(texts)); |
| 222 | } |
| 223 | } |
| 224 | Block { |
| 225 | id: schema::get_string(txn, m, "id").unwrap_or_default(), |
| 226 | kind: schema::get_string(txn, m, "type").unwrap_or_default(), |
| 227 | props, |
| 228 | region: schema::get_string(txn, m, "region"), |
| 229 | bx: box_value(schema::get_object(txn, m, "box")), |
| 230 | z: schema::number_json(schema::get_number(txn, m, "z").unwrap_or(0.0)), |
| 231 | phone: phone_value(schema::get_object(txn, m, "phone")), |
| 232 | text: schema::get_fragment(txn, m, "text").map(|f| schema::read_text(txn, &f)), |
| 233 | } |
| 234 | } |
| 235 | |
| 236 | fn slide_view<T: ReadTxn>(txn: &T, m: &yrs::MapRef) -> Slide { |
| 237 | // Blocks in reading order (schema::ordered_maps), so every |
| 238 | // reader of the view — the renderer, the layout engine, the API — |
| 239 | // sees one sequence and none of them knows there is an `order` key. |
| 240 | let blocks = schema::get_array(txn, m, "blocks") |
| 241 | .map(|a| { |
| 242 | schema::ordered_maps(txn, &a) |
| 243 | .iter() |
| 244 | .map(|(_, b)| block_view(txn, b)) |
| 245 | .collect() |
| 246 | }) |
| 247 | .unwrap_or_default(); |
| 248 | Slide { |
| 249 | id: schema::get_string(txn, m, "id").unwrap_or_default(), |
| 250 | layout: schema::get_string(txn, m, "layout").unwrap_or_else(|| "flow".into()), |
| 251 | template: schema::get_string(txn, m, "template") |
| 252 | .unwrap_or_else(|| schema::DEFAULT_TEMPLATE.into()), |
| 253 | background: background_value(schema::get_object(txn, m, "background")), |
| 254 | phone: schema::get_string(txn, m, "phone").unwrap_or_else(|| "inherit".into()), |
| 255 | transition: schema::get_string(txn, m, "transition").unwrap_or_else(|| "none".into()), |
| 256 | notes: schema::get_fragment(txn, m, "notes") |
| 257 | .map(|f| schema::read_text(txn, &f)) |
| 258 | .unwrap_or_default(), |
| 259 | blocks, |
| 260 | } |
| 261 | } |
| 262 | |
| 263 | /// The deck as JSON. Never empty: an untouched document renders as the |
| 264 | /// defaults with no slides and no assets. |
| 265 | pub fn view_json(doc: &Doc) -> String { |
| 266 | let roots = schema::roots(doc); |
| 267 | let txn = doc.transact(); |
| 268 | let meta = Meta { |
| 269 | title: schema::get_string(&txn, &roots.meta, "title").unwrap_or_default(), |
| 270 | theme: schema::get_string(&txn, &roots.meta, "theme") |
| 271 | .unwrap_or_else(|| schema::DEFAULT_THEME.into()), |
| 272 | accent: schema::get_string(&txn, &roots.meta, "accent"), |
| 273 | fonts: fonts_value(schema::get_object(&txn, &roots.meta, "fonts")), |
| 274 | aspect: schema::get_string(&txn, &roots.meta, "aspect") |
| 275 | .unwrap_or_else(|| schema::DEFAULT_ASPECT.into()), |
| 276 | }; |
| 277 | // Slides in reading order (schema::ordered_maps), the same way the |
| 278 | // blocks of one are: the `order` key never reaches the view, so no |
| 279 | // reader of it knows there is one. |
| 280 | let slides = schema::ordered_maps(&txn, &roots.slides) |
| 281 | .iter() |
| 282 | .map(|(_, m)| slide_view(&txn, m)) |
| 283 | .collect(); |
| 284 | let assets = roots |
| 285 | .assets |
| 286 | .iter(&txn) |
| 287 | .filter_map(|(k, v)| asset_value(schema::out_to_json(&txn, &v)).map(|a| (k.to_string(), a))) |
| 288 | .collect(); |
| 289 | let view = View { |
| 290 | meta, |
| 291 | slides, |
| 292 | assets, |
| 293 | }; |
| 294 | // Serialising a struct of strings, numbers and already-built Values |
| 295 | // cannot fail; the fallback is belt and braces, not a path. |
| 296 | serde_json::to_string(&view).unwrap_or_else(|_| String::from("{}")) |
| 297 | } |
| 298 | |
| 299 | #[cfg(test)] |
| 300 | mod tests { |
| 301 | //! The view has to be readable by the host's typed `deck.Parse` for |
| 302 | //! ANY document the CRDT can hold, not only ones this crate's ops |
| 303 | //! wrote — a browser peer can put any shape at any key. These build |
| 304 | //! the malformed shapes directly with yrs and require the view to |
| 305 | //! omit or default them rather than pass them through untyped. |
| 306 | //! `fixtures/view.test.mjs` is the JS twin. |
| 307 | use super::*; |
| 308 | use crate::new_doc; |
| 309 | use yrs::{In, MapPrelim}; |
| 310 | |
| 311 | fn any(v: serde_json::Value) -> In { |
| 312 | In::Any(schema::json_to_any(&v)) |
| 313 | } |
| 314 | |
| 315 | fn poisoned() -> Value { |
| 316 | let doc = new_doc("poison", 7); |
| 317 | let roots = schema::roots(&doc); |
| 318 | { |
| 319 | let mut txn = doc.transact_mut(); |
| 320 | roots.meta.insert( |
| 321 | &mut txn, |
| 322 | "fonts", |
| 323 | any(serde_json::json!({"heading": "Serif", "body": []})), |
| 324 | ); |
| 325 | let phone: MapPrelim = [ |
| 326 | ("hidden", any(serde_json::json!("yes"))), |
| 327 | ("order", any(serde_json::json!(2.7))), |
| 328 | ("size", any(serde_json::json!(7))), |
| 329 | ( |
| 330 | "box", |
| 331 | any(serde_json::json!({"x": 0, "y": 0, "w": 1, "h": 0.5})), |
| 332 | ), |
| 333 | ] |
| 334 | .into_iter() |
| 335 | .collect(); |
| 336 | let block: MapPrelim = [ |
| 337 | ("id", any(serde_json::json!("blk"))), |
| 338 | ("type", any(serde_json::json!("text"))), |
| 339 | ( |
| 340 | "box", |
| 341 | any(serde_json::json!({"x": "a", "y": 0, "w": 1, "h": 1})), |
| 342 | ), |
| 343 | ("z", any(serde_json::json!(1))), |
| 344 | ("phone", In::Map(phone)), |
| 345 | ] |
| 346 | .into_iter() |
| 347 | .collect(); |
| 348 | let background: MapPrelim = [ |
| 349 | ("kind", any(serde_json::json!(5))), |
| 350 | ("value", any(serde_json::json!(""))), |
| 351 | ] |
| 352 | .into_iter() |
| 353 | .collect(); |
| 354 | let blocks: yrs::ArrayPrelim = [In::Map(block)].into_iter().collect(); |
| 355 | let slide: MapPrelim = [ |
| 356 | ("id", any(serde_json::json!("sld"))), |
| 357 | ("layout", any(serde_json::json!("canvas"))), |
| 358 | ("background", In::Map(background)), |
| 359 | ("blocks", In::Array(blocks)), |
| 360 | ] |
| 361 | .into_iter() |
| 362 | .collect(); |
| 363 | roots.slides.push_back(&mut txn, slide); |
| 364 | roots |
| 365 | .assets |
| 366 | .insert(&mut txn, "str", any(serde_json::json!("not a map"))); |
| 367 | roots |
| 368 | .assets |
| 369 | .insert(&mut txn, "noref", any(serde_json::json!({"name": "x.png"}))); |
| 370 | roots.assets.insert( |
| 371 | &mut txn, |
| 372 | "badfields", |
| 373 | any(serde_json::json!({ |
| 374 | "ref": "abc", "name": 3, "width": "w", "height": 9.9, "alt": null |
| 375 | })), |
| 376 | ); |
| 377 | roots.assets.insert( |
| 378 | &mut txn, |
| 379 | "good", |
| 380 | any(serde_json::json!({ |
| 381 | "ref": "def", "name": "p.png", "width": 10, "height": 20, "alt": "A" |
| 382 | })), |
| 383 | ); |
| 384 | } |
| 385 | serde_json::from_str(&view_json(&doc)).expect("view is JSON") |
| 386 | } |
| 387 | |
| 388 | #[test] |
| 389 | fn malformed_fonts_are_null() { |
| 390 | assert_eq!(poisoned()["meta"]["fonts"], Value::Null); |
| 391 | } |
| 392 | |
| 393 | #[test] |
| 394 | fn malformed_background_is_the_default() { |
| 395 | assert_eq!( |
| 396 | poisoned()["slides"][0]["background"], |
| 397 | serde_json::json!({"kind": "theme", "value": ""}) |
| 398 | ); |
| 399 | } |
| 400 | |
| 401 | #[test] |
| 402 | fn malformed_box_is_null_and_phone_keeps_only_typed_fields() { |
| 403 | let v = poisoned(); |
| 404 | let block = &v["slides"][0]["blocks"][0]; |
| 405 | assert_eq!(block["box"], Value::Null); |
| 406 | assert_eq!( |
| 407 | block["phone"], |
| 408 | serde_json::json!({"order": 2, "box": {"x": 0, "y": 0, "w": 1, "h": 0.5}}) |
| 409 | ); |
| 410 | } |
| 411 | |
| 412 | #[test] |
| 413 | fn assets_are_typed_or_skipped() { |
| 414 | let v = poisoned(); |
| 415 | assert_eq!( |
| 416 | v["assets"], |
| 417 | serde_json::json!({ |
| 418 | "badfields": {"ref": "abc", "name": "", "width": 0, "height": 9, "alt": ""}, |
| 419 | "good": {"ref": "def", "name": "p.png", "width": 10, "height": 20, "alt": "A"} |
| 420 | }) |
| 421 | ); |
| 422 | } |
| 423 | } |
| 424 | |