deltachat/
qr_code_generator.rs

1//! # QR code generation module.
2
3use anyhow::{Result, bail};
4use base64::Engine as _;
5use qrcodegen::{QrCode, QrCodeEcc};
6
7use crate::blob::BlobObject;
8use crate::chat::{Chat, ChatId};
9use crate::color::color_int_to_hex_string;
10use crate::config::Config;
11use crate::contact::{Contact, ContactId};
12use crate::context::Context;
13use crate::qr::{self, Qr};
14use crate::securejoin;
15use crate::stock_str::{self, backup_transfer_qr};
16
17/// Create a QR code from any input data.
18pub fn create_qr_svg(qrcode_content: &str) -> Result<String> {
19    let all_size = 512.0;
20    let qr_code_size = 416.0;
21    let logo_size = 96.0;
22
23    let qr = QrCode::encode_text(qrcode_content, QrCodeEcc::Medium)?;
24    let mut svg = String::with_capacity(28000);
25    let mut w = tagger::new(&mut svg);
26
27    w.elem("svg", |d| {
28        d.attr("xmlns", "http://www.w3.org/2000/svg")?;
29        d.attr("viewBox", format_args!("0 0 {all_size} {all_size}"))?;
30        d.attr("xmlns:xlink", "http://www.w3.org/1999/xlink")?; // required for enabling xlink:href on browsers
31        Ok(())
32    })?
33    .build(|w| {
34        // background
35        w.single("rect", |d| {
36            d.attr("x", 0)?;
37            d.attr("y", 0)?;
38            d.attr("width", all_size)?;
39            d.attr("height", all_size)?;
40            d.attr("style", "fill:#ffffff")?;
41            Ok(())
42        })?;
43        // QR code
44        w.elem("g", |d| {
45            d.attr(
46                "transform",
47                format!(
48                    "translate({},{})",
49                    (all_size - qr_code_size) / 2.0,
50                    ((all_size - qr_code_size) / 2.0)
51                ),
52            )
53        })?
54        .build(|w| {
55            w.single("path", |d| {
56                let mut path_data = String::with_capacity(0);
57                let scale = qr_code_size / qr.size() as f32;
58
59                for y in 0..qr.size() {
60                    for x in 0..qr.size() {
61                        if qr.get_module(x, y) {
62                            path_data += &format!("M{x},{y}h1v1h-1z");
63                        }
64                    }
65                }
66
67                d.attr("style", "fill:#000000")?;
68                d.attr("d", path_data)?;
69                d.attr("transform", format!("scale({scale})"))
70            })
71        })?;
72        w.elem("g", |d| {
73            d.attr(
74                "transform",
75                format!(
76                    "translate({},{}) scale(2)", // data in qr_overlay_delta.svg-part are 48 x 48, scaling by 2 results in desired logo_size of 96
77                    (all_size - logo_size) / 2.0,
78                    (all_size - logo_size) / 2.0
79                ),
80            )
81        })?
82        .build(|w| w.put_raw_escapable(include_str!("../assets/qr_overlay_delta.svg-part")))
83    })?;
84
85    Ok(svg)
86}
87
88/// Returns SVG of the QR code to join the group or verify contact.
89///
90/// If `chat_id` is `None`, returns verification QR code.
91/// Otherwise, returns secure join QR code.
92pub async fn get_securejoin_qr_svg(context: &Context, chat_id: Option<ChatId>) -> Result<String> {
93    if let Some(chat_id) = chat_id {
94        generate_join_group_qr_code(context, chat_id).await
95    } else {
96        generate_verification_qr(context).await
97    }
98}
99
100async fn generate_join_group_qr_code(context: &Context, chat_id: ChatId) -> Result<String> {
101    let chat = Chat::load_from_db(context, chat_id).await?;
102
103    let avatar = match chat.get_profile_image(context).await? {
104        Some(path) => {
105            let avatar_blob = BlobObject::from_path(context, &path)?;
106            Some(tokio::fs::read(avatar_blob.to_abs_path()).await?)
107        }
108        None => None,
109    };
110
111    let qrcode_description = match chat.typ {
112        crate::constants::Chattype::Group => {
113            stock_str::secure_join_group_qr_description(context, &chat).await
114        }
115        crate::constants::Chattype::OutBroadcast => {
116            stock_str::secure_join_broadcast_qr_description(context, &chat).await
117        }
118        _ => bail!("Unexpected chat type {}", chat.typ),
119    };
120
121    inner_generate_secure_join_qr_code(
122        &qrcode_description,
123        &securejoin::get_securejoin_qr(context, Some(chat_id)).await?,
124        &color_int_to_hex_string(chat.get_color(context).await?),
125        avatar,
126        chat.get_name().chars().next().unwrap_or('#'),
127    )
128}
129
130async fn generate_verification_qr(context: &Context) -> Result<String> {
131    let (avatar, displayname, addr, color) = self_info(context).await?;
132
133    inner_generate_secure_join_qr_code(
134        &stock_str::setup_contact_qr_description(context, &displayname, &addr).await,
135        &securejoin::get_securejoin_qr(context, None).await?,
136        &color,
137        avatar,
138        displayname.chars().next().unwrap_or('#'),
139    )
140}
141
142/// Renders a [`Qr::Backup2`] QR code as an SVG image.
143pub async fn generate_backup_qr(context: &Context, qr: &Qr) -> Result<String> {
144    let content = qr::format_backup(qr)?;
145    let (avatar, displayname, _addr, color) = self_info(context).await?;
146    let description = backup_transfer_qr(context).await?;
147
148    inner_generate_secure_join_qr_code(
149        &description,
150        &content,
151        &color,
152        avatar,
153        displayname.chars().next().unwrap_or('#'),
154    )
155}
156
157/// Returns `(avatar, displayname, addr, color) of the configured account.
158async fn self_info(context: &Context) -> Result<(Option<Vec<u8>>, String, String, String)> {
159    let contact = Contact::get_by_id(context, ContactId::SELF).await?;
160
161    let avatar = match contact.get_profile_image(context).await? {
162        Some(path) => {
163            let avatar_blob = BlobObject::from_path(context, &path)?;
164            Some(tokio::fs::read(avatar_blob.to_abs_path()).await?)
165        }
166        None => None,
167    };
168
169    let displayname = match context.get_config(Config::Displayname).await? {
170        Some(name) => name,
171        None => contact.get_addr().to_string(),
172    };
173    let addr = contact.get_addr().to_string();
174    let color = color_int_to_hex_string(contact.get_color());
175    Ok((avatar, displayname, addr, color))
176}
177
178fn inner_generate_secure_join_qr_code(
179    qrcode_description: &str,
180    qrcode_content: &str,
181    color: &str,
182    avatar: Option<Vec<u8>>,
183    avatar_letter: char,
184) -> Result<String> {
185    // config
186    let width = 515.0;
187    let height = 630.0;
188    let logo_offset = 28.0;
189    let qr_code_size = 400.0;
190    let qr_translate_up = 40.0;
191    let text_y_pos = ((height - qr_code_size) / 2.0) + qr_code_size;
192    let avatar_border_size = 9.0;
193    let card_border_size = 2.0;
194    let card_roundness = 40.0;
195
196    let qr = QrCode::encode_text(qrcode_content, QrCodeEcc::Medium)?;
197    let mut svg = String::with_capacity(28000);
198    let mut w = tagger::new(&mut svg);
199
200    w.elem("svg", |d| {
201        d.attr("xmlns", "http://www.w3.org/2000/svg")?;
202        d.attr("viewBox", format_args!("0 0 {width} {height}"))?;
203        d.attr("xmlns:xlink", "http://www.w3.org/1999/xlink")?; // required for enabling xlink:href on browsers
204        Ok(())
205    })?
206    .build(|w| {
207        // White Background appears like a card
208        w.single("rect", |d| {
209            d.attr("x", card_border_size)?;
210            d.attr("y", card_border_size)?;
211            d.attr("rx", card_roundness)?;
212            d.attr("stroke", "#c6c6c6")?;
213            d.attr("stroke-width", card_border_size)?;
214            d.attr("width", width - (card_border_size * 2.0))?;
215            d.attr("height", height - (card_border_size * 2.0))?;
216            d.attr("style", "fill:#f2f2f2")?;
217            Ok(())
218        })?;
219        // Qrcode
220        w.elem("g", |d| {
221            d.attr(
222                "transform",
223                format!(
224                    "translate({},{})",
225                    (width - qr_code_size) / 2.0,
226                    ((height - qr_code_size) / 2.0) - qr_translate_up
227                ),
228            )
229            // If the qr code should be in the wrong place,
230            // we could also translate and scale the points in the path already,
231            // but that would make the resulting svg way bigger in size and might bring up rounding issues,
232            // so better avoid doing it manually if possible
233        })?
234        .build(|w| {
235            w.single("path", |d| {
236                let mut path_data = String::with_capacity(0);
237                let scale = qr_code_size / qr.size() as f32;
238
239                for y in 0..qr.size() {
240                    for x in 0..qr.size() {
241                        if qr.get_module(x, y) {
242                            path_data += &format!("M{x},{y}h1v1h-1z");
243                        }
244                    }
245                }
246
247                d.attr("style", "fill:#000000")?;
248                d.attr("d", path_data)?;
249                d.attr("transform", format!("scale({scale})"))
250            })
251        })?;
252
253        // Text
254        const BIG_TEXT_CHARS_PER_LINE: usize = 32;
255        const SMALL_TEXT_CHARS_PER_LINE: usize = 38;
256        let chars_per_line = if qrcode_description.len() > SMALL_TEXT_CHARS_PER_LINE * 2 {
257            SMALL_TEXT_CHARS_PER_LINE
258        } else {
259            BIG_TEXT_CHARS_PER_LINE
260        };
261        let lines = textwrap::fill(qrcode_description, chars_per_line);
262        let (text_font_size, text_y_shift) = if lines.split('\n').count() <= 2 {
263            (27.0, 0.0)
264        } else {
265            (19.0, -10.0)
266        };
267        for (count, line) in lines.split('\n').enumerate() {
268            w.elem("text", |d| {
269                d.attr(
270                    "y",
271                    (count as f32 * (text_font_size * 1.2)) + text_y_pos + text_y_shift,
272                )?;
273                d.attr("x", width / 2.0)?;
274                d.attr("text-anchor", "middle")?;
275                d.attr(
276                    "style",
277                    format!(
278                        "font-family:sans-serif;\
279                        font-weight:bold;\
280                        font-size:{text_font_size}px;\
281                        fill:#000000;\
282                        stroke:none"
283                    ),
284                )
285            })?
286            .build(|w| w.put_raw(line))?;
287        }
288        // contact avatar in middle of qrcode
289        const LOGO_SIZE: f32 = 94.4;
290        const HALF_LOGO_SIZE: f32 = LOGO_SIZE / 2.0;
291        let logo_position_in_qr = (qr_code_size / 2.0) - HALF_LOGO_SIZE;
292        let logo_position_x = ((width - qr_code_size) / 2.0) + logo_position_in_qr;
293        let logo_position_y =
294            ((height - qr_code_size) / 2.0) - qr_translate_up + logo_position_in_qr;
295
296        w.single("circle", |d| {
297            d.attr("cx", logo_position_x + HALF_LOGO_SIZE)?;
298            d.attr("cy", logo_position_y + HALF_LOGO_SIZE)?;
299            d.attr("r", HALF_LOGO_SIZE + avatar_border_size)?;
300            d.attr("style", "fill:#f2f2f2")
301        })?;
302
303        if let Some(img) = avatar {
304            w.elem("defs", tagger::no_attr())?.build(|w| {
305                w.elem("clipPath", |d| d.attr("id", "avatar-cut"))?
306                    .build(|w| {
307                        w.single("circle", |d| {
308                            d.attr("cx", logo_position_x + HALF_LOGO_SIZE)?;
309                            d.attr("cy", logo_position_y + HALF_LOGO_SIZE)?;
310                            d.attr("r", HALF_LOGO_SIZE)
311                        })
312                    })
313            })?;
314
315            w.single("image", |d| {
316                d.attr("x", logo_position_x)?;
317                d.attr("y", logo_position_y)?;
318                d.attr("width", HALF_LOGO_SIZE * 2.0)?;
319                d.attr("height", HALF_LOGO_SIZE * 2.0)?;
320                d.attr("preserveAspectRatio", "none")?;
321                d.attr("clip-path", "url(#avatar-cut)")?;
322                d.attr(
323                    "xlink:href", /* xlink:href is needed otherwise it won't even display in inkscape not to mention qt's QSvgHandler */
324                    format!(
325                        "data:image/jpeg;base64,{}",
326                        base64::engine::general_purpose::STANDARD.encode(img)
327                    ),
328                )
329            })?;
330        } else {
331            w.single("circle", |d| {
332                d.attr("cx", logo_position_x + HALF_LOGO_SIZE)?;
333                d.attr("cy", logo_position_y + HALF_LOGO_SIZE)?;
334                d.attr("r", HALF_LOGO_SIZE)?;
335                d.attr("style", format!("fill:{}", &color))
336            })?;
337
338            let avatar_font_size = LOGO_SIZE * 0.65;
339            let font_offset = avatar_font_size * 0.1;
340            w.elem("text", |d| {
341                d.attr("y", logo_position_y + HALF_LOGO_SIZE + font_offset)?;
342                d.attr("x", logo_position_x + HALF_LOGO_SIZE)?;
343                d.attr("text-anchor", "middle")?;
344                d.attr("dominant-baseline", "central")?;
345                d.attr("alignment-baseline", "middle")?;
346                d.attr(
347                    "style",
348                    format!(
349                        "font-family:sans-serif;\
350                            font-weight:400;\
351                            font-size:{avatar_font_size}px;\
352                            fill:#ffffff;"
353                    ),
354                )
355            })?
356            .build(|w| w.put_raw(avatar_letter.to_uppercase()))?;
357        }
358
359        // Footer logo
360        const FOOTER_HEIGHT: f32 = 35.0;
361        const FOOTER_WIDTH: f32 = 198.0;
362        w.elem("g", |d| {
363            d.attr(
364                "transform",
365                format!(
366                    "translate({},{})",
367                    (width - FOOTER_WIDTH) / 2.0,
368                    height - logo_offset - FOOTER_HEIGHT - text_y_shift
369                ),
370            )
371        })?
372        .build(|w| w.put_raw(include_str!("../assets/qrcode_logo_footer.svg")))
373    })?;
374
375    Ok(svg)
376}
377
378#[cfg(test)]
379mod tests {
380    use testdir::testdir;
381
382    use crate::imex::BackupProvider;
383    use crate::qr::format_backup;
384    use crate::test_utils::TestContextManager;
385
386    use super::*;
387
388    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
389    async fn test_create_qr_svg() -> Result<()> {
390        let svg = create_qr_svg("this is a test QR code \" < > &")?;
391        assert!(svg.contains("<svg"));
392        assert!(svg.contains("</svg>"));
393        Ok(())
394    }
395
396    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
397    async fn test_svg_escaping() {
398        let svg = inner_generate_secure_join_qr_code(
399            "descr123 \" < > &",
400            "qr-code-content",
401            "#000000",
402            None,
403            'X',
404        )
405        .unwrap();
406        assert!(svg.contains("descr123 &quot; &lt; &gt; &amp;"))
407    }
408
409    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
410    async fn test_generate_backup_qr() {
411        let dir = testdir!();
412        let mut tcm = TestContextManager::new();
413        let ctx = tcm.alice().await;
414        let provider = BackupProvider::prepare(&ctx).await.unwrap();
415        let qr = provider.qr();
416
417        println!("{}", format_backup(&qr).unwrap());
418        let rendered = generate_backup_qr(&ctx, &qr).await.unwrap();
419        tokio::fs::write(dir.join("qr.svg"), &rendered)
420            .await
421            .unwrap();
422        assert_eq!(rendered.get(..4), Some("<svg"));
423    }
424}