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