Skip to main content

weimo_wdf/adapters/
apple.rs

1use crate::{
2    diagnostic::{Diagnostic, Diagnostics},
3    model::*,
4    validate::validate_and_canonicalize,
5};
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8
9/// The wire envelope of an Apple document: `{"type":"apple.document","version":1,"blocks":[...]}`.
10#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
11pub struct AppleWireDocument {
12    #[serde(rename = "type")]
13    pub wire_type: String,
14    pub version: u32,
15    pub blocks: Vec<AppleWireBlock>,
16}
17
18/// One styled text run in an Apple document: plain `text` plus an optional
19/// `bold: true`. Presentation-only attributes are never carried in the wire
20/// format.
21#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
22pub struct AppleWireRun {
23    pub text: String,
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub bold: Option<bool>,
26}
27
28/// A group of runs used inside blockquotes and list items.
29#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
30pub struct AppleWireParagraph {
31    pub runs: Vec<AppleWireRun>,
32}
33
34/// A block of an Apple document, tagged by `type`:
35/// `paragraph`, `heading` (with `level`), `blockquote`, `bullet_list`,
36/// `ordered_list` (with `start`).
37#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
38#[serde(tag = "type")]
39pub enum AppleWireBlock {
40    #[serde(rename = "paragraph")]
41    Paragraph { runs: Vec<AppleWireRun> },
42    #[serde(rename = "heading")]
43    Heading { level: u8, runs: Vec<AppleWireRun> },
44    #[serde(rename = "blockquote")]
45    Blockquote { paragraphs: Vec<AppleWireParagraph> },
46    #[serde(rename = "bullet_list")]
47    BulletList { items: Vec<AppleWireParagraph> },
48    #[serde(rename = "ordered_list")]
49    OrderedList {
50        start: u32,
51        items: Vec<AppleWireParagraph>,
52    },
53}
54
55/// Parses Apple document JSON into a canonical [`WdfDocument`].
56///
57/// Expects the envelope `{"type":"apple.document","version":1,"blocks":[...]}`
58/// with blocks carrying `runs` instead of inline text nodes. Unknown shapes
59/// are rejected with `apple.*` diagnostics.
60///
61/// ```
62/// use serde_json::json;
63/// use weimo_wdf::from_apple;
64///
65/// let value = json!({
66///     "type": "apple.document",
67///     "version": 1,
68///     "blocks": [{ "type": "paragraph", "runs": [{"text": "hello"}] }]
69/// });
70/// let doc = from_apple(&value).expect("supported apple document");
71/// assert_eq!(doc.root.content.len(), 1);
72/// ```
73pub fn from_apple(value: &Value) -> Result<WdfDocument, Diagnostics> {
74    let wire = serde_json::from_value::<AppleWireDocument>(value.clone()).map_err(|error| {
75        vec![Diagnostic::error(
76            "apple",
77            "apple.invalid_document",
78            error.to_string(),
79        )]
80    })?;
81    if wire.wire_type != "apple.document" || wire.version != 1 {
82        return Err(vec![Diagnostic::error(
83            "apple",
84            "apple.invalid_document",
85            "type must be apple.document and version must be 1",
86        )]);
87    }
88    let blocks = wire
89        .blocks
90        .into_iter()
91        .map(|block| match block {
92            AppleWireBlock::Paragraph { runs } => WdfBlock::Paragraph {
93                content: runs_to_wdf(runs),
94            },
95            AppleWireBlock::Heading { level, runs } => WdfBlock::Heading {
96                attrs: HeadingAttrs { level },
97                content: runs_to_wdf(runs),
98            },
99            AppleWireBlock::Blockquote { paragraphs } => WdfBlock::Blockquote {
100                content: paragraphs
101                    .into_iter()
102                    .map(|p| paragraph(runs_to_wdf(p.runs)))
103                    .collect(),
104            },
105            AppleWireBlock::BulletList { items } => WdfBlock::BulletList {
106                content: items
107                    .into_iter()
108                    .map(|p| list_item(runs_to_wdf(p.runs)))
109                    .collect(),
110            },
111            AppleWireBlock::OrderedList { start, items } => WdfBlock::OrderedList {
112                attrs: Some(OrderedListAttrs { start }),
113                content: items
114                    .into_iter()
115                    .map(|p| list_item(runs_to_wdf(p.runs)))
116                    .collect(),
117            },
118        })
119        .collect::<Vec<_>>();
120    validate_and_canonicalize(
121        &serde_json::to_value(WdfDocument {
122            schema: WDF_SCHEMA.to_string(),
123            version: WDF_VERSION,
124            root: WdfRoot {
125                node_type: "doc".to_string(),
126                content: blocks,
127            },
128        })
129        .expect("WDF serialization"),
130    )
131}
132
133fn runs_to_wdf(runs: Vec<AppleWireRun>) -> Vec<WdfText> {
134    runs.into_iter()
135        .map(|run| {
136            if run.bold == Some(true) {
137                WdfText::bold(run.text)
138            } else {
139                WdfText::plain(run.text)
140            }
141        })
142        .collect()
143}
144fn runs(content: &[WdfText]) -> Vec<AppleWireRun> {
145    content
146        .iter()
147        .filter(|text| !text.text.is_empty())
148        .map(|text| AppleWireRun {
149            text: text.text.clone(),
150            bold: text.is_bold().then_some(true),
151        })
152        .collect()
153}
154
155/// Renders a [`WdfDocument`] as Apple document JSON.
156///
157/// Inverse of [`from_apple`]: text becomes `runs` with an optional `bold`,
158/// headings carry `level`, ordered lists carry `start`.
159pub fn to_apple(document: &WdfDocument) -> Value {
160    let wire = AppleWireDocument {
161        wire_type: "apple.document".to_string(),
162        version: 1,
163        blocks: document
164            .root
165            .content
166            .iter()
167            .map(|block| match block {
168                WdfBlock::Paragraph { content } => AppleWireBlock::Paragraph {
169                    runs: runs(content),
170                },
171                WdfBlock::Heading { attrs, content } => AppleWireBlock::Heading {
172                    level: attrs.level,
173                    runs: runs(content),
174                },
175                WdfBlock::Blockquote { content } => AppleWireBlock::Blockquote {
176                    paragraphs: content
177                        .iter()
178                        .map(|p| AppleWireParagraph {
179                            runs: runs(&p.content),
180                        })
181                        .collect(),
182                },
183                WdfBlock::BulletList { content } => AppleWireBlock::BulletList {
184                    items: content
185                        .iter()
186                        .map(|i| AppleWireParagraph {
187                            runs: runs(&i.content[0].content),
188                        })
189                        .collect(),
190                },
191                WdfBlock::OrderedList { attrs, content } => AppleWireBlock::OrderedList {
192                    start: attrs.as_ref().map_or(1, |a| a.start),
193                    items: content
194                        .iter()
195                        .map(|i| AppleWireParagraph {
196                            runs: runs(&i.content[0].content),
197                        })
198                        .collect(),
199                },
200            })
201            .collect(),
202    };
203    serde_json::to_value(wire).expect("Apple wire serialization")
204}