Skip to main content

inkferro_core/dom/
decode.rs

1//! Op-buffer decoder: flat byte buffer -> [`Vec<Op>`].
2//!
3//! This is the FFI wire format for M3: the JS reconciler (M3-G) emits a flat
4//! `Buffer` of `[opcode, id, payload...]` records, napi (M3-D) hands the bytes
5//! through here as `&[u8]`, and the result drives [`apply`](super::apply). This
6//! module is the **decode** half only — Rust never emits this format at runtime
7//! (JS is the sole emitter), so there is intentionally no `pub` encoder here. A
8//! test-only encoder lives in the test submodule for round-tripping. The
9//! cross-language contract M3-G's JS emitter targets is the wire-format table
10//! documented below — there is no persisted byte artifact; the inline
11//! literal-byte fixtures in the test submodule pin that table Rust-side.
12//!
13//! # Trust boundary
14//!
15//! The input is attacker-shaped (a JS `Buffer` of arbitrary bytes). Decoding
16//! therefore **never panics**: every read is bounds-checked through [`Reader`]
17//! and every malformed input returns a typed [`DecodeError`]. This is distinct
18//! from [`apply`](super::apply), which is *total over semantics* (unknown ids
19//! are silent no-ops, mirroring ink's JS guards). The decoder rejects malformed
20//! *bytes*; `apply` tolerates malformed *meaning*. Keep the two boundaries
21//! separate: a truncated buffer is a decode error, an op referencing a dead id
22//! is not.
23//!
24//! # Wire format (v1) — the cross-language contract
25//!
26//! A buffer is a flat sequence of op records decoded until end-of-input. There
27//! is no outer length/count prefix and no version byte; the buffer is exactly
28//! the concatenation of `encode_op(op)` for each op in order. Mid-record
29//! truncation yields [`DecodeError::UnexpectedEof`].
30//!
31//! ## Primitives
32//!
33//! * **u8**  — 1 byte. Used for opcodes, enum tags, and field ids.
34//! * **u32** — 4 bytes, **little-endian**. Used for every node id and for
35//!   string length prefixes.
36//! * **f32** — 4 bytes, little-endian IEEE-754. Used for every `Style` float
37//!   (`flex_grow`, `flex_shrink`, `aspect_ratio`, `gap`, `column_gap`,
38//!   `row_gap`) and for the inner value of `Dim::Points`/`Dim::Percent`,
39//!   `Lp::Points`/`Lp::Percent`.
40//! * **f64** — 8 bytes, little-endian IEEE-754. Used for exactly one thing:
41//!   `AttrValue::Number` (JS numbers are f64).
42//! * **bool** — 1 byte: `0x00` = false, `0x01` = true. Any other value is
43//!   [`DecodeError::InvalidBool`].
44//! * **string** — a u32 length prefix (LE) followed by that many bytes of
45//!   UTF-8. Invalid UTF-8 is [`DecodeError::InvalidUtf8`].
46//!
47//! ## Opcodes (record = `[opcode:u8][fields...]`)
48//!
49//! | opcode | variant       | fields after opcode                                |
50//! |--------|---------------|----------------------------------------------------|
51//! | `0x00` | Create        | `id:u32`, `kind:u8`                                 |
52//! | `0x01` | AppendChild   | `parent:u32`, `child:u32`                          |
53//! | `0x02` | InsertBefore  | `parent:u32`, `child:u32`, `before:u32`           |
54//! | `0x03` | RemoveChild   | `parent:u32`, `child:u32`                          |
55//! | `0x04` | SetText       | `id:u32`, `text:string`                            |
56//! | `0x05` | SetStyle      | `id:u32`, `style` (see below)                      |
57//! | `0x06` | SetAttribute  | `id:u32`, `key:string`, `value:AttrValue`         |
58//! | `0x07` | SetTransform  | `id:u32`, `has:bool`                               |
59//! | `0x08` | SetStatic     | `id:u32`, `value:bool`                            |
60//! | `0x09` | Hide          | `id:u32`                                           |
61//! | `0x0A` | Unhide        | `id:u32`                                           |
62//! | `0x0B` | Free          | `id:u32`                                           |
63//! | `0x0C` | SetTextStyle  | `id:u32`, `TextStyle` (see below)                  |
64//! | `0x0D` | ClearTextStyle| `id:u32`                                           |
65//!
66//! Any other opcode is [`DecodeError::UnknownOpcode`].
67//!
68//! ## `TextStyle` — tagged field-id scheme (P5.1 SET_TEXT_STYLE)
69//!
70//! Same self-describing scheme as `Style`:
71//!
72//! ```text
73//! [field_count:u32]  then field_count repetitions of:
74//!   [field_id:u8][typed value for that field]
75//! ```
76//!
77//! The decoder starts from `TextStyle::default()` and writes each decoded field.
78//!
79//! | id  | field            | value encoding |
80//! |-----|------------------|----------------|
81//! | `0` | color            | `string`       |
82//! | `1` | background_color | `string`       |
83//! | `2` | bold             | `bool`         |
84//! | `3` | italic           | `bool`         |
85//! | `4` | underline        | `bool`         |
86//! | `5` | strikethrough    | `bool`         |
87//! | `6` | inverse          | `bool`         |
88//! | `7` | dim_color        | `bool`         |
89//!
90//! Any other field id is [`DecodeError::UnknownFieldId`].
91//!
92//! ## `Kind` tag (u8)
93//!
94//! `0x00` Root, `0x01` Box, `0x02` Text, `0x03` VirtualText. Other =
95//! [`DecodeError::UnknownTag`].
96//!
97//! ## `AttrValue`  (`[tag:u8][payload]`)
98//!
99//! * `0x00` Bool — payload `bool` (1 byte).
100//! * `0x01` Str  — payload `string`.
101//! * `0x02` Number — payload `f64` (8 bytes, LE).
102//!
103//! ## `Style` — tagged field-id scheme
104//!
105//! `Style` has ~60 optional fields. Rather than make JS know the Rust struct
106//! layout (a fixed 60-slot record), the style payload is **self-describing**:
107//!
108//! ```text
109//! [field_count:u32]  then field_count repetitions of:
110//!   [field_id:u8][typed value for that field]
111//! ```
112//!
113//! Only fields that are `Some` (or, for the non-`Option` visual strings, set)
114//! are emitted; the decoder starts from `Style::default()` and writes each
115//! decoded field. A field id present in the buffer means the field is `Some`;
116//! its absence means `None`. This was chosen over a positional fixed-layout
117//! record because (1) it decouples the JS emitter from the exact Rust field
118//! order and count — M3-G writes `[id][value]` pairs from the doc table alone,
119//! never mirroring `struct Style`; (2) it is compact for the common sparse case
120//! (a Box usually sets a handful of props); and (3) adding a future style field
121//! is a backward-compatible new field-id, not a breaking width change.
122//!
123//! ### Style field ids and their value encodings
124//!
125//! | id   | field                 | value encoding                              |
126//! |------|-----------------------|---------------------------------------------|
127//! | `0`  | position              | `Position` tag (u8)                         |
128//! | `1`  | top                   | `Dim`                                       |
129//! | `2`  | right                 | `Dim`                                       |
130//! | `3`  | bottom                | `Dim`                                       |
131//! | `4`  | left                  | `Dim`                                       |
132//! | `5`  | margin                | `Lp`                                        |
133//! | `6`  | margin_x              | `Lp`                                        |
134//! | `7`  | margin_y              | `Lp`                                        |
135//! | `8`  | margin_top            | `Lp`                                        |
136//! | `9`  | margin_right          | `Lp`                                        |
137//! | `10` | margin_bottom         | `Lp`                                        |
138//! | `11` | margin_left           | `Lp`                                        |
139//! | `12` | padding               | `Lp`                                        |
140//! | `13` | padding_x             | `Lp`                                        |
141//! | `14` | padding_y             | `Lp`                                        |
142//! | `15` | padding_top           | `Lp`                                        |
143//! | `16` | padding_right         | `Lp`                                        |
144//! | `17` | padding_bottom        | `Lp`                                        |
145//! | `18` | padding_left          | `Lp`                                        |
146//! | `19` | flex_direction        | `FlexDir` tag (u8)                         |
147//! | `20` | flex_wrap             | `FlexWrap` tag (u8)                        |
148//! | `21` | flex_grow             | `f32`                                       |
149//! | `22` | flex_shrink           | `f32`                                       |
150//! | `23` | flex_basis            | `Dim`                                       |
151//! | `24` | align_items           | `Align` tag (u8)                           |
152//! | `25` | align_self            | `Align` tag (u8)                           |
153//! | `26` | align_content         | `ContentAlign` tag (u8)                    |
154//! | `27` | justify_content       | `ContentAlign` tag (u8)                    |
155//! | `28` | width                 | `Dim`                                       |
156//! | `29` | height                | `Dim`                                       |
157//! | `30` | min_width             | `Dim`                                       |
158//! | `31` | min_height            | `Dim`                                       |
159//! | `32` | max_width             | `Dim`                                       |
160//! | `33` | max_height            | `Dim`                                       |
161//! | `34` | aspect_ratio          | `f32`                                       |
162//! | `35` | display               | `Display` tag (u8)                         |
163//! | `36` | border_style          | `BorderStyle`                               |
164//! | `37` | border_top            | `bool`                                      |
165//! | `38` | border_right          | `bool`                                      |
166//! | `39` | border_bottom         | `bool`                                      |
167//! | `40` | border_left           | `bool`                                      |
168//! | `41` | gap                   | `f32`                                       |
169//! | `42` | column_gap            | `f32`                                       |
170//! | `43` | row_gap               | `f32`                                       |
171//! | `44` | text_wrap             | `TextWrap` tag (u8)                        |
172//! | `45` | overflow_x            | `Overflow` tag (u8)                        |
173//! | `46` | overflow_y            | `Overflow` tag (u8)                        |
174//! | `47` | background_color      | `string`                                    |
175//! | `48` | border_color          | `string`                                    |
176//! | `49` | border_top_color      | `string`                                    |
177//! | `50` | border_right_color    | `string`                                    |
178//! | `51` | border_bottom_color   | `string`                                    |
179//! | `52` | border_left_color     | `string`                                    |
180//! | `53` | border_background_color        | `string`                           |
181//! | `54` | border_top_background_color    | `string`                           |
182//! | `55` | border_right_background_color  | `string`                           |
183//! | `56` | border_bottom_background_color | `string`                           |
184//! | `57` | border_left_background_color   | `string`                           |
185//! | `58` | border_dim_color               | `bool`                             |
186//! | `59` | border_top_dim_color           | `bool`                             |
187//! | `60` | border_right_dim_color         | `bool`                             |
188//! | `61` | border_bottom_dim_color        | `bool`                             |
189//! | `62` | border_left_dim_color          | `bool`                             |
190//!
191//! Any other field id is [`DecodeError::UnknownFieldId`].
192//!
193//! ### Nested enum encodings
194//!
195//! * **`Dim`** — `[tag:u8]` then: `0x00` Points → `f32`; `0x01` Percent → `f32`;
196//!   `0x02` Auto → (no payload).
197//! * **`Lp`** — `[tag:u8]` then: `0x00` Points → `f32`; `0x01` Percent → `f32`.
198//! * **`Position`** — `0x00` Relative, `0x01` Absolute, `0x02` Static.
199//! * **`FlexDir`** — `0x00` Row, `0x01` Column, `0x02` RowReverse, `0x03`
200//!   ColumnReverse.
201//! * **`FlexWrap`** — `0x00` NoWrap, `0x01` Wrap, `0x02` WrapReverse.
202//! * **`Align`** — `0x00` Stretch, `0x01` FlexStart, `0x02` Center, `0x03`
203//!   FlexEnd, `0x04` Baseline.
204//! * **`ContentAlign`** — `0x00` FlexStart, `0x01` Center, `0x02` FlexEnd,
205//!   `0x03` SpaceBetween, `0x04` SpaceAround, `0x05` SpaceEvenly, `0x06`
206//!   Stretch.
207//! * **`Display`** — `0x00` Flex, `0x01` None.
208//! * **`TextWrap`** — `0x00` Wrap, `0x01` Hard, `0x02` TruncateEnd, `0x03`
209//!   TruncateMiddle, `0x04` TruncateStart.
210//! * **`Overflow`** — `0x00` Visible, `0x01` Hidden.
211//! * **`BorderStyle`** — `[tag:u8]` then: `0x00` Named → `string`; `0x01`
212//!   Custom → eight `string`s in order `top_left, top, top_right, right,
213//!   bottom_right, bottom, bottom_left, left`.
214//!
215//! Any out-of-range nested tag is [`DecodeError::UnknownTag`].
216
217use super::node::{
218    Align, AttrValue, BorderStyle, ContentAlign, Dim, Display, FlexDir, FlexWrap, Kind, Lp,
219    Overflow, Position, Style, TextStyle, TextWrap,
220};
221use super::op::Op;
222
223/// A typed decode failure. No variant is reachable by a *valid* buffer; every
224/// one corresponds to a specific malformation at the FFI trust boundary.
225#[derive(Debug, Clone, PartialEq, Eq)]
226pub enum DecodeError {
227    /// The buffer ended in the middle of a record/primitive. Carries the number
228    /// of bytes that were still required.
229    UnexpectedEof,
230    /// An opcode byte that does not map to any [`Op`] variant.
231    UnknownOpcode(u8),
232    /// An enum tag byte (Kind, Dim, AttrValue, a Style sub-enum, …) out of
233    /// range for its position.
234    UnknownTag(u8),
235    /// A `Style` field id with no assigned meaning.
236    UnknownFieldId(u8),
237    /// A `bool` byte that was neither `0x00` nor `0x01`.
238    InvalidBool(u8),
239    /// A length-prefixed string whose bytes were not valid UTF-8.
240    InvalidUtf8,
241}
242
243impl core::fmt::Display for DecodeError {
244    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
245        match self {
246            DecodeError::UnexpectedEof => write!(f, "unexpected end of op buffer"),
247            DecodeError::UnknownOpcode(b) => write!(f, "unknown opcode 0x{b:02X}"),
248            DecodeError::UnknownTag(b) => write!(f, "unknown enum tag 0x{b:02X}"),
249            DecodeError::UnknownFieldId(b) => write!(f, "unknown style field id {b}"),
250            DecodeError::InvalidBool(b) => write!(f, "invalid bool byte 0x{b:02X}"),
251            DecodeError::InvalidUtf8 => write!(f, "invalid utf-8 in length-prefixed string"),
252        }
253    }
254}
255
256impl std::error::Error for DecodeError {}
257
258type Result<T> = core::result::Result<T, DecodeError>;
259
260/// A bounds-checked cursor over the input bytes. Every primitive read goes
261/// through one of these methods, so truncation anywhere is an `UnexpectedEof`
262/// rather than a panic.
263struct Reader<'a> {
264    buf: &'a [u8],
265    pos: usize,
266}
267
268impl<'a> Reader<'a> {
269    fn new(buf: &'a [u8]) -> Self {
270        Self { buf, pos: 0 }
271    }
272
273    fn at_end(&self) -> bool {
274        self.pos >= self.buf.len()
275    }
276
277    fn take(&mut self, n: usize) -> Result<&'a [u8]> {
278        let end = self.pos.checked_add(n).ok_or(DecodeError::UnexpectedEof)?;
279        let slice = self
280            .buf
281            .get(self.pos..end)
282            .ok_or(DecodeError::UnexpectedEof)?;
283        self.pos = end;
284        Ok(slice)
285    }
286
287    fn u8(&mut self) -> Result<u8> {
288        Ok(self.take(1)?[0])
289    }
290
291    fn u32(&mut self) -> Result<u32> {
292        let b = self.take(4)?;
293        Ok(u32::from_le_bytes([b[0], b[1], b[2], b[3]]))
294    }
295
296    fn f32(&mut self) -> Result<f32> {
297        let b = self.take(4)?;
298        Ok(f32::from_le_bytes([b[0], b[1], b[2], b[3]]))
299    }
300
301    fn f64(&mut self) -> Result<f64> {
302        let b = self.take(8)?;
303        Ok(f64::from_le_bytes([
304            b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7],
305        ]))
306    }
307
308    fn bool(&mut self) -> Result<bool> {
309        match self.u8()? {
310            0 => Ok(false),
311            1 => Ok(true),
312            other => Err(DecodeError::InvalidBool(other)),
313        }
314    }
315
316    fn string(&mut self) -> Result<String> {
317        let len = self.u32()? as usize;
318        let bytes = self.take(len)?;
319        core::str::from_utf8(bytes)
320            .map(str::to_owned)
321            .map_err(|_| DecodeError::InvalidUtf8)
322    }
323}
324
325/// Decode a flat op buffer into a [`Vec<Op>`].
326///
327/// The bytes are the cross-language wire format documented at the module level:
328/// a sequence of `[opcode, id, payload]` records with no outer count, decoded
329/// until end-of-input. Returns a typed [`DecodeError`] on any malformation; it
330/// never panics, even on truncated or garbage input (FFI trust boundary).
331///
332/// # Errors
333///
334/// Returns [`DecodeError`] if the buffer is truncated mid-record
335/// ([`DecodeError::UnexpectedEof`]), contains an unknown opcode, enum tag, or
336/// style field id, an invalid bool byte, or invalid UTF-8 in a string.
337pub fn decode_ops(buf: &[u8]) -> Result<Vec<Op>> {
338    let mut r = Reader::new(buf);
339    let mut ops = Vec::new();
340    while !r.at_end() {
341        ops.push(decode_op(&mut r)?);
342    }
343    Ok(ops)
344}
345
346fn decode_op(r: &mut Reader<'_>) -> Result<Op> {
347    let opcode = r.u8()?;
348    let op = match opcode {
349        0x00 => Op::Create {
350            id: r.u32()?,
351            kind: decode_kind(r)?,
352        },
353        0x01 => Op::AppendChild {
354            parent: r.u32()?,
355            child: r.u32()?,
356        },
357        0x02 => Op::InsertBefore {
358            parent: r.u32()?,
359            child: r.u32()?,
360            before: r.u32()?,
361        },
362        0x03 => Op::RemoveChild {
363            parent: r.u32()?,
364            child: r.u32()?,
365        },
366        0x04 => Op::SetText {
367            id: r.u32()?,
368            text: r.string()?,
369        },
370        0x05 => Op::SetStyle {
371            id: r.u32()?,
372            style: Box::new(decode_style(r)?),
373        },
374        0x06 => Op::SetAttribute {
375            id: r.u32()?,
376            key: r.string()?,
377            value: decode_attr_value(r)?,
378        },
379        0x07 => Op::SetTransform {
380            id: r.u32()?,
381            has: r.bool()?,
382        },
383        0x08 => Op::SetStatic {
384            id: r.u32()?,
385            value: r.bool()?,
386        },
387        0x09 => Op::Hide { id: r.u32()? },
388        0x0A => Op::Unhide { id: r.u32()? },
389        0x0B => Op::Free { id: r.u32()? },
390        0x0C => Op::SetTextStyle {
391            id: r.u32()?,
392            style: decode_text_style(r)?,
393        },
394        0x0D => Op::ClearTextStyle { id: r.u32()? },
395        other => return Err(DecodeError::UnknownOpcode(other)),
396    };
397    Ok(op)
398}
399
400fn decode_kind(r: &mut Reader<'_>) -> Result<Kind> {
401    match r.u8()? {
402        0x00 => Ok(Kind::Root),
403        0x01 => Ok(Kind::Box),
404        0x02 => Ok(Kind::Text),
405        0x03 => Ok(Kind::VirtualText),
406        other => Err(DecodeError::UnknownTag(other)),
407    }
408}
409
410fn decode_attr_value(r: &mut Reader<'_>) -> Result<AttrValue> {
411    match r.u8()? {
412        0x00 => Ok(AttrValue::Bool(r.bool()?)),
413        0x01 => Ok(AttrValue::Str(r.string()?)),
414        0x02 => Ok(AttrValue::Number(r.f64()?)),
415        other => Err(DecodeError::UnknownTag(other)),
416    }
417}
418
419fn decode_dim(r: &mut Reader<'_>) -> Result<Dim> {
420    match r.u8()? {
421        0x00 => Ok(Dim::Points(r.f32()?)),
422        0x01 => Ok(Dim::Percent(r.f32()?)),
423        0x02 => Ok(Dim::Auto),
424        other => Err(DecodeError::UnknownTag(other)),
425    }
426}
427
428fn decode_lp(r: &mut Reader<'_>) -> Result<Lp> {
429    match r.u8()? {
430        0x00 => Ok(Lp::Points(r.f32()?)),
431        0x01 => Ok(Lp::Percent(r.f32()?)),
432        other => Err(DecodeError::UnknownTag(other)),
433    }
434}
435
436fn decode_position(r: &mut Reader<'_>) -> Result<Position> {
437    match r.u8()? {
438        0x00 => Ok(Position::Relative),
439        0x01 => Ok(Position::Absolute),
440        0x02 => Ok(Position::Static),
441        other => Err(DecodeError::UnknownTag(other)),
442    }
443}
444
445fn decode_flex_dir(r: &mut Reader<'_>) -> Result<FlexDir> {
446    match r.u8()? {
447        0x00 => Ok(FlexDir::Row),
448        0x01 => Ok(FlexDir::Column),
449        0x02 => Ok(FlexDir::RowReverse),
450        0x03 => Ok(FlexDir::ColumnReverse),
451        other => Err(DecodeError::UnknownTag(other)),
452    }
453}
454
455fn decode_flex_wrap(r: &mut Reader<'_>) -> Result<FlexWrap> {
456    match r.u8()? {
457        0x00 => Ok(FlexWrap::NoWrap),
458        0x01 => Ok(FlexWrap::Wrap),
459        0x02 => Ok(FlexWrap::WrapReverse),
460        other => Err(DecodeError::UnknownTag(other)),
461    }
462}
463
464fn decode_align(r: &mut Reader<'_>) -> Result<Align> {
465    match r.u8()? {
466        0x00 => Ok(Align::Stretch),
467        0x01 => Ok(Align::FlexStart),
468        0x02 => Ok(Align::Center),
469        0x03 => Ok(Align::FlexEnd),
470        0x04 => Ok(Align::Baseline),
471        other => Err(DecodeError::UnknownTag(other)),
472    }
473}
474
475fn decode_content_align(r: &mut Reader<'_>) -> Result<ContentAlign> {
476    match r.u8()? {
477        0x00 => Ok(ContentAlign::FlexStart),
478        0x01 => Ok(ContentAlign::Center),
479        0x02 => Ok(ContentAlign::FlexEnd),
480        0x03 => Ok(ContentAlign::SpaceBetween),
481        0x04 => Ok(ContentAlign::SpaceAround),
482        0x05 => Ok(ContentAlign::SpaceEvenly),
483        0x06 => Ok(ContentAlign::Stretch),
484        other => Err(DecodeError::UnknownTag(other)),
485    }
486}
487
488fn decode_display(r: &mut Reader<'_>) -> Result<Display> {
489    match r.u8()? {
490        0x00 => Ok(Display::Flex),
491        0x01 => Ok(Display::None),
492        other => Err(DecodeError::UnknownTag(other)),
493    }
494}
495
496fn decode_text_wrap(r: &mut Reader<'_>) -> Result<TextWrap> {
497    match r.u8()? {
498        0x00 => Ok(TextWrap::Wrap),
499        0x01 => Ok(TextWrap::Hard),
500        0x02 => Ok(TextWrap::TruncateEnd),
501        0x03 => Ok(TextWrap::TruncateMiddle),
502        0x04 => Ok(TextWrap::TruncateStart),
503        other => Err(DecodeError::UnknownTag(other)),
504    }
505}
506
507fn decode_overflow(r: &mut Reader<'_>) -> Result<Overflow> {
508    match r.u8()? {
509        0x00 => Ok(Overflow::Visible),
510        0x01 => Ok(Overflow::Hidden),
511        other => Err(DecodeError::UnknownTag(other)),
512    }
513}
514
515fn decode_border_style(r: &mut Reader<'_>) -> Result<BorderStyle> {
516    match r.u8()? {
517        0x00 => Ok(BorderStyle::Named(r.string()?)),
518        0x01 => Ok(BorderStyle::Custom {
519            top_left: r.string()?,
520            top: r.string()?,
521            top_right: r.string()?,
522            right: r.string()?,
523            bottom_right: r.string()?,
524            bottom: r.string()?,
525            bottom_left: r.string()?,
526            left: r.string()?,
527        }),
528        other => Err(DecodeError::UnknownTag(other)),
529    }
530}
531
532fn decode_style(r: &mut Reader<'_>) -> Result<Style> {
533    let mut style = Style::default();
534    let field_count = r.u32()?;
535    for _ in 0..field_count {
536        let field_id = r.u8()?;
537        match field_id {
538            0 => style.position = Some(decode_position(r)?),
539            1 => style.top = Some(decode_dim(r)?),
540            2 => style.right = Some(decode_dim(r)?),
541            3 => style.bottom = Some(decode_dim(r)?),
542            4 => style.left = Some(decode_dim(r)?),
543            5 => style.margin = Some(decode_lp(r)?),
544            6 => style.margin_x = Some(decode_lp(r)?),
545            7 => style.margin_y = Some(decode_lp(r)?),
546            8 => style.margin_top = Some(decode_lp(r)?),
547            9 => style.margin_right = Some(decode_lp(r)?),
548            10 => style.margin_bottom = Some(decode_lp(r)?),
549            11 => style.margin_left = Some(decode_lp(r)?),
550            12 => style.padding = Some(decode_lp(r)?),
551            13 => style.padding_x = Some(decode_lp(r)?),
552            14 => style.padding_y = Some(decode_lp(r)?),
553            15 => style.padding_top = Some(decode_lp(r)?),
554            16 => style.padding_right = Some(decode_lp(r)?),
555            17 => style.padding_bottom = Some(decode_lp(r)?),
556            18 => style.padding_left = Some(decode_lp(r)?),
557            19 => style.flex_direction = Some(decode_flex_dir(r)?),
558            20 => style.flex_wrap = Some(decode_flex_wrap(r)?),
559            21 => style.flex_grow = Some(r.f32()?),
560            22 => style.flex_shrink = Some(r.f32()?),
561            23 => style.flex_basis = Some(decode_dim(r)?),
562            24 => style.align_items = Some(decode_align(r)?),
563            25 => style.align_self = Some(decode_align(r)?),
564            26 => style.align_content = Some(decode_content_align(r)?),
565            27 => style.justify_content = Some(decode_content_align(r)?),
566            28 => style.width = Some(decode_dim(r)?),
567            29 => style.height = Some(decode_dim(r)?),
568            30 => style.min_width = Some(decode_dim(r)?),
569            31 => style.min_height = Some(decode_dim(r)?),
570            32 => style.max_width = Some(decode_dim(r)?),
571            33 => style.max_height = Some(decode_dim(r)?),
572            34 => style.aspect_ratio = Some(r.f32()?),
573            35 => style.display = Some(decode_display(r)?),
574            36 => style.border_style = Some(decode_border_style(r)?),
575            37 => style.border_top = Some(r.bool()?),
576            38 => style.border_right = Some(r.bool()?),
577            39 => style.border_bottom = Some(r.bool()?),
578            40 => style.border_left = Some(r.bool()?),
579            41 => style.gap = Some(r.f32()?),
580            42 => style.column_gap = Some(r.f32()?),
581            43 => style.row_gap = Some(r.f32()?),
582            44 => style.text_wrap = Some(decode_text_wrap(r)?),
583            45 => style.overflow_x = Some(decode_overflow(r)?),
584            46 => style.overflow_y = Some(decode_overflow(r)?),
585            47 => style.background_color = Some(r.string()?),
586            48 => style.border_color = Some(r.string()?),
587            49 => style.border_top_color = Some(r.string()?),
588            50 => style.border_right_color = Some(r.string()?),
589            51 => style.border_bottom_color = Some(r.string()?),
590            52 => style.border_left_color = Some(r.string()?),
591            53 => style.border_background_color = Some(r.string()?),
592            54 => style.border_top_background_color = Some(r.string()?),
593            55 => style.border_right_background_color = Some(r.string()?),
594            56 => style.border_bottom_background_color = Some(r.string()?),
595            57 => style.border_left_background_color = Some(r.string()?),
596            58 => style.border_dim_color = Some(r.bool()?),
597            59 => style.border_top_dim_color = Some(r.bool()?),
598            60 => style.border_right_dim_color = Some(r.bool()?),
599            61 => style.border_bottom_dim_color = Some(r.bool()?),
600            62 => style.border_left_dim_color = Some(r.bool()?),
601            other => return Err(DecodeError::UnknownFieldId(other)),
602        }
603    }
604    Ok(style)
605}
606
607/// Decode a `TextStyle` (P5.1 SET_TEXT_STYLE) from the self-describing tagged
608/// field-id scheme.  Mirrors [`decode_style`]: start from default, write each
609/// decoded field.  The render walk reads the stored `text_styling` via
610/// `resolve_transform` for the native simple-`<Text>` path (P5.1b); a later
611/// `ClearTextStyle` (0x0D) resets it to `None` on a styled→plain rerender.
612fn decode_text_style(r: &mut Reader<'_>) -> Result<TextStyle> {
613    let mut style = TextStyle::default();
614    let field_count = r.u32()?;
615    for _ in 0..field_count {
616        let field_id = r.u8()?;
617        match field_id {
618            0 => style.color = Some(r.string()?),
619            1 => style.background_color = Some(r.string()?),
620            2 => style.bold = r.bool()?,
621            3 => style.italic = r.bool()?,
622            4 => style.underline = r.bool()?,
623            5 => style.strikethrough = r.bool()?,
624            6 => style.inverse = r.bool()?,
625            7 => style.dim_color = r.bool()?,
626            other => return Err(DecodeError::UnknownFieldId(other)),
627        }
628    }
629    Ok(style)
630}
631
632#[cfg(test)]
633#[path = "decode_tests.rs"]
634mod decode_tests;