oficina / slides Public

Clone
git clone https://amadan.net/oficina/slides

Plain git — no account needed to clone.

Download

Download this file

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
35use crate::schema::{self, Paragraph};
36use serde::Serialize;
37use serde_json::Value;
38use std::collections::BTreeMap;
39use yrs::{Array, Doc, Map, ReadTxn, Transact};
40
41#[derive(Serialize)]
42struct View {
43 meta: Meta,
44 slides: Vec<Slide>,
45 assets: BTreeMap<String, Value>,
46}
47
48#[derive(Serialize)]
49struct Meta {
50 title: String,
51 theme: String,
52 accent: Option<String>,
53 fonts: Option<Value>,
54 aspect: String,
55}
56
57#[derive(Serialize)]
58struct 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)]
70struct 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
84fn 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
100type Object = serde_json::Map<String, Value>;
101
102fn 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.
111fn 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
119fn 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.
128fn 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.
139fn 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.
151fn 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.
164fn 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.
188fn 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
208fn 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
236fn 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.
265pub 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)]
300mod 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