generate qr code svg (#2815)

* generate qr code svg prototype

* qr code for groups
fix formatting

* - letter avatar in qrcode
- escape xml in userinput (display/groupname)
- fix "Me" display name
- merge import declarations

* remove dot at the end of VerifyContactQRDescription

* if addr == displayname, show only one of them

Especially useful for yggmail accounts without usernames,
because the text would overflow otherwise.

* use real clipPath for rounded avatar

* - center avatar text better (dominant-baseline)
- add "sans-serif" to font fallback for text if arial is missing

* make corner always blue

* add [logo + "get.delta.chat"] footer to qrcode

* Update deltachat-ffi/deltachat.h

Co-authored-by: bjoern <r10s@b44t.com>

* Apply suggestions from code review

Co-authored-by: bjoern <r10s@b44t.com>

* new card design
- add stockstrings
- update changelog

* make qrcode pixels also #f2f2f2 instead of full white

* rename VERIFY_CONTACT_QR_DESC to SETUP_CONTACT
make footer text a tiny bit darker upon r10s's request

* avoid using  which is a doxygen command

* point out that one will join a group (this is still shorted and was also suggested in recent chats)

* add option to generate qr-code-svg to repl tool

* use same font-family in text and footer

* thinner card border

* remove superfluous <tspan> from footer to make color tweaking easier

* move font-weight to style, ios renderer does not pick it up from attribute; remove default font attributes not used consequently

* make get.delta.chat more visible

* align properly using dominant-baseline=central and alignment-baseline=middle, this makes things nice on all systems but android (before, ios was wrong and all others not 100% aligned as font metrics are ignored) (android needs a subsequent improvement)

Co-authored-by: bjoern <r10s@b44t.com>
This commit is contained in:
Simon Laux
2021-11-24 23:23:01 +01:00
committed by GitHub
parent cc78347293
commit 416bf3a829
12 changed files with 427 additions and 3 deletions

262
src/qr_code_generator.rs Normal file
View File

@@ -0,0 +1,262 @@
use anyhow::Result;
use qrcodegen::{QrCode, QrCodeEcc};
use crate::{
blob::BlobObject,
chat::{Chat, ChatId},
color::color_int_to_hex_string,
config::Config,
constants::DC_CONTACT_ID_SELF,
contact::Contact,
context::Context,
securejoin, stock_str,
};
pub async fn get_securejoin_qr_svg(context: &Context, chat_id: Option<ChatId>) -> Result<String> {
if let Some(chat_id) = chat_id {
generate_join_group_qr_code(context, chat_id).await
} else {
generate_verification_qr(context).await
}
}
async fn generate_join_group_qr_code(context: &Context, chat_id: ChatId) -> Result<String> {
let chat = Chat::load_from_db(context, chat_id).await?;
let avatar = match chat.get_profile_image(context).await? {
Some(path) => {
let avatar_blob = BlobObject::from_path(context, &path)?;
Some(std::fs::read(avatar_blob.to_abs_path())?)
}
None => None,
};
inner_generate_secure_join_qr_code(
&stock_str::secure_join_group_qr_description(context, &chat).await,
&securejoin::dc_get_securejoin_qr(context, Some(chat_id)).await?,
&color_int_to_hex_string(chat.get_color(context).await?),
avatar,
chat.get_name().chars().next().unwrap_or('#'),
)
}
async fn generate_verification_qr(context: &Context) -> Result<String> {
let contact = Contact::get_by_id(context, DC_CONTACT_ID_SELF).await?;
let avatar = match contact.get_profile_image(context).await? {
Some(path) => {
let avatar_blob = BlobObject::from_path(context, &path)?;
Some(std::fs::read(avatar_blob.to_abs_path())?)
}
None => None,
};
let displayname = match context.get_config(Config::Displayname).await? {
Some(name) => name,
None => contact.get_addr().to_owned(),
};
inner_generate_secure_join_qr_code(
&stock_str::setup_contact_qr_description(context, &displayname, contact.get_addr()).await,
&securejoin::dc_get_securejoin_qr(context, None).await?,
&color_int_to_hex_string(contact.get_color()),
avatar,
displayname.chars().next().unwrap_or('#'),
)
}
fn inner_generate_secure_join_qr_code(
raw_qrcode_description: &str,
qrcode_content: &str,
color: &str,
avatar: Option<Vec<u8>>,
avatar_letter: char,
) -> Result<String> {
let qrcode_description = &escaper::encode_minimal(raw_qrcode_description);
// config
let width = 515.0;
let height = 630.0;
let logo_offset = 28.0;
let qr_code_size = 400.0;
let qr_translate_up = 40.0;
let text_y_pos = ((height - qr_code_size) / 2.0) + qr_code_size;
let (text_font_size, max_text_width) = if qrcode_description.len() <= 75 {
(27.0, 32)
} else {
(19.0, 38)
};
let avatar_border_size = 9.0;
let card_border_size = 2.0;
let card_roundness = 40.0;
let qr = QrCode::encode_text(qrcode_content, QrCodeEcc::Medium)?;
let mut svg = String::with_capacity(28000);
let mut w = tagger::new(&mut svg);
w.elem("svg", |d| {
d.attr("xmlns", "http://www.w3.org/2000/svg")
.attr("viewBox", format_args!("0 0 {} {}", width, height));
})
.build(|w| {
// White Background apears like a card
w.single("rect", |d| {
d.attr("x", card_border_size)
.attr("y", card_border_size)
.attr("rx", card_roundness)
.attr("stroke", "#c6c6c6")
.attr("stroke-width", card_border_size)
.attr("width", width - (card_border_size * 2.0))
.attr("height", height - (card_border_size * 2.0))
.attr("style", "fill:#f2f2f2");
});
// Qrcode
w.elem("g", |d| {
d.attr(
"transform",
format!(
"translate({},{})",
(width - qr_code_size) / 2.0,
((height - qr_code_size) / 2.0) - qr_translate_up
),
);
// If the qr code should be in the wrong place,
// we could also translate and scale the points in the path already,
// but that would make the resulting svg way bigger in size and might bring up rounding issues,
// so better avoid doing it manually if possible
})
.build(|w| {
w.single("path", |d| {
let mut path_data = String::with_capacity(0);
let scale = qr_code_size / qr.size() as f32;
for y in 0..qr.size() {
for x in 0..qr.size() {
if qr.get_module(x, y) {
path_data += &format!("M{},{}h1v1h-1z", x, y);
}
}
}
d.attr("style", "fill:#000000")
.attr("d", path_data)
.attr("transform", format!("scale({})", scale));
});
});
// Text
for (count, line) in textwrap::fill(qrcode_description, max_text_width)
.split('\n')
.enumerate()
{
w.elem("text", |d| {
d.attr("y", (count as f32 * (text_font_size * 1.2)) + text_y_pos)
.attr("x", width / 2.0)
.attr("text-anchor", "middle")
.attr(
"style",
format!(
"font-family:sans-serif;\
font-weight:bold;\
font-size:{}px;\
fill:#000000;\
stroke:none",
text_font_size
),
);
})
.build(|w| {
w.put_raw(line);
});
}
// contact avatar in middle of qrcode
const LOGO_SIZE: f32 = 94.4;
const HALF_LOGO_SIZE: f32 = LOGO_SIZE / 2.0;
let logo_position_in_qr = (qr_code_size / 2.0) - HALF_LOGO_SIZE;
let logo_position_x = ((width - qr_code_size) / 2.0) + logo_position_in_qr;
let logo_position_y =
((height - qr_code_size) / 2.0) - qr_translate_up + logo_position_in_qr;
w.single("circle", |d| {
d.attr("cx", logo_position_x + HALF_LOGO_SIZE)
.attr("cy", logo_position_y + HALF_LOGO_SIZE)
.attr("r", HALF_LOGO_SIZE + avatar_border_size)
.attr("style", "fill:#f2f2f2");
});
if let Some(img) = avatar {
w.elem("defs", |_| {}).build(|w| {
w.elem("clipPath", |d| {
d.attr("id", "avatar-cut");
})
.build(|w| {
w.single("circle", |d| {
d.attr("cx", logo_position_x + HALF_LOGO_SIZE)
.attr("cy", logo_position_y + HALF_LOGO_SIZE)
.attr("r", HALF_LOGO_SIZE);
});
});
});
w.single("image", |d| {
d.attr("x", logo_position_x)
.attr("y", logo_position_y)
.attr("width", HALF_LOGO_SIZE * 2.0)
.attr("height", HALF_LOGO_SIZE * 2.0)
.attr("preserveAspectRatio", "none")
.attr("clip-path", "url(#avatar-cut)")
.attr(
"href" /*might need xlink:href instead if it doesn't work on older devices?*/,
format!("data:image/jpeg;base64,{}", base64::encode(img)),
);
});
} else {
w.single("circle", |d| {
d.attr("cx", logo_position_x + HALF_LOGO_SIZE)
.attr("cy", logo_position_y + HALF_LOGO_SIZE)
.attr("r", HALF_LOGO_SIZE)
.attr("style", format!("fill:{}", &color));
});
let avatar_font_size = LOGO_SIZE * 0.65;
let font_offset = avatar_font_size * 0.1;
w.elem("text", |d| {
d.attr("y", logo_position_y + HALF_LOGO_SIZE + font_offset)
.attr("x", logo_position_x + HALF_LOGO_SIZE)
.attr("text-anchor", "middle")
.attr("dominant-baseline", "central")
.attr("alignment-baseline", "middle")
.attr(
"style",
format!(
"font-family:sans-serif;\
font-weight:400;\
font-size:{}px;\
fill:#ffffff;",
avatar_font_size
),
);
})
.build(|w| {
w.put_raw(avatar_letter.to_uppercase());
});
}
// Footer logo
const FOOTER_HEIGHT: f32 = 35.0;
const FOOTER_WIDTH: f32 = 198.0;
w.elem("g", |d| {
d.attr(
"transform",
format!(
"translate({},{})",
(width - FOOTER_WIDTH) / 2.0,
height - logo_offset - FOOTER_HEIGHT
),
);
})
.build(|w| {
w.put_raw(include_str!("../assets/qrcode_logo_footer.svg"));
});
});
Ok(svg)
}