1use 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#[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")?; Ok(())
33 })?
34 .build(|w| {
35 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 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)", (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
89pub 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
143pub 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
158async 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 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")?; Ok(())
207 })?
208 .build(|w| {
209 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 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 })?
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 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 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", 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 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 " < > &"))
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}