This commit is contained in:
Ondrej Vlach 2023-12-02 00:52:53 +01:00
parent 0e0754fa10
commit b51840d09a
Signed by: ovlach
GPG Key ID: 4FF1A23B4914DE70
14 changed files with 1113 additions and 107 deletions

771
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -20,6 +20,11 @@ tokio = { version = "1.34.0", features = ["macros"] }
tracing = { version = "0.1.40", features = ["attributes", "std"] }
yansi = "0.5.1"
tera = "1.19.1"
nanobyte_opentelemetry = { path = "../nanobyte_opentelemetry" }
tempfile = "3.8.1"
urlencoding = "2.1.3"
urlencoding = "2.1.3"
nanobyte_opentelemetry = { version = "0.2.2", registry = "gitea", features = ["rocket-reqwest"]}
reqwest = { version = "0.11", features = ["json"] }
phf = { version = "0.11.2", features = ["macros"] }
nanobyte_tera = { git = "https://glpat-Us_EdFTzQLv4shViQXi_:glpat-Us_EdFTzQLv4shViQXi_@gitlab.nanobyte.cz/tools/nanobyte_tera.git", branch = "master" }
ovlach_tera = { git = "https://glpat-_yPuXbEzECyk3FaHudCN:glpat-_yPuXbEzECyk3FaHudCN@gitlab.nanobyte.cz/ondrej/ovlach_tera.git", branch = "master" }

View File

@ -1,2 +1,8 @@
[debug]
static_route = "http://localhost:8001"
cv_backend_path = "http://localhost:8002"
[default]
static_route = "http://localhost:8001"
cv_backend_path = "http://localhost:8002"
port = 8003

View File

@ -0,0 +1,21 @@
devops-enginner-web-developer = DevOPS engineer/Backend web developer(TODO do API)
download-cv = Stáhnout životopis
hire-me = Kontakt
about-me = O mě
professional-skills = Jazyky a technologie
work-experience = Zaměstnání
contact = Kontakt
bio = Základní udaje
education = Vzdělání
title-about = O mě
title-skills = Technologie
title-experience = Zaměstnání
title-education = Vzdělání
title-contact = Kontakt
age = Věk
email = E-mail
phone = Telefon
czech = Česky
english = Anglicky
skills-languages = Jazyky
skills-technology = Technologie/Frameworky

View File

@ -0,0 +1,21 @@
devops-enginner-web-developer = DevOPS engineer/Backend web developer
download-cv = Download CV
hire-me = Hire me
about-me = About me
professional-skills = Professional Skills
education = Education
work-experience = Work experience
contact = Contact
bio = Bio
title-about = About
title-skills = Skills
title-experience = Experience
title-education = Education
title-contact = Contact
age = Age
email = Email
phone = Phone
czech = Czech
english = English
skills-languages = Languages
skills-technology = Technologies/Frameworks

View File

@ -32,7 +32,6 @@ impl Fairing for Chromium {
async fn on_ignite(&self, rocket: Rocket<Build>) -> fairing::Result {
let new_rocket = rocket.manage(ChromiumCoordinator::new().await);
let coordinator = new_rocket.state::<ChromiumCoordinator>().unwrap();
Ok(new_rocket)
}
}

View File

@ -1,15 +1,27 @@
use chromium::rocket::Chromium;
use nanobyte_opentelemetry::rocket::TracingFairing;
use rocket::{Rocket, Build, routes};
use rocket::{Rocket, Build, routes, fairing::AdHoc};
use serde::Deserialize;
pub mod routes;
mod chromium;
mod tools;
mod services;
#[derive(Deserialize)]
#[serde(crate = "rocket::serde")]
pub struct CVBackendConfig {
cv_backend_path: String,
}
pub fn rocket_builder() -> Rocket<Build> {
rocket::build()
.attach(TracingFairing::ignite())
.attach(Chromium::ignite())
.attach(
AdHoc::config::<CVBackendConfig>()
)
.mount("/", routes![
routes::pdf::render_pdf_cv,
routes::pdf::render_html_cv

View File

@ -1,12 +1,15 @@
use headless_chrome::Browser;
use headless_chrome::types::PrintToPdfOptions;
use nanobyte_opentelemetry::rocket::TracingSpan;
use rocket::{get, response::stream::ByteStream, fairing::Fairing, fs::NamedFile, futures::TryFutureExt};
use nanobyte_opentelemetry::rocket::{TracingSpan, RequestId, OtelReqwestClient};
use ovlach_data::cv::cv::CV;
use reqwest::Client;
use rocket::fs::NamedFile;
use ::rocket::{State, http::Status};
use ::rocket::get;
use tempfile::NamedTempFile;
use tera::Context;
use tracing::{info_span, error, debug};
use crate::{chromium::rocket::BrowserHolder, tools::{tera::NanoTera, pdf::PdfStream}};
use urlencoding::encode;
use crate::{chromium::rocket::BrowserHolder, tools::{tera::NanoTera, pdf::PdfStream, rocket::RequestLanguage}, services::cv::fetch_cv_data_from_backend, CVBackendConfig};
// TODO: request-id
fn generate_pdf(browser: Browser, file: &NamedTempFile) -> Vec<u8> {
@ -38,29 +41,59 @@ fn generate_pdf(browser: Browser, file: &NamedTempFile) -> Vec<u8> {
}
#[tracing::instrument]
fn render_template(template_name: &str, file: &NamedTempFile, tera: NanoTera) {
fn render_template(template_name: &str, file: &NamedTempFile, tera: NanoTera, cv: CV, language: String) {
// TODO: handle errors
tera.0.render_to("two_column.html.tera", &Context::new(), file);
let mut tera_context = Context::new();
tera_context.insert("cv", &cv);
tera_context.insert("lang", language.as_str());
match tera.0.render_to("two_column.html.tera", &tera_context, file) {
Ok(_) => {
debug!("Rendered template to {}", file.path().to_str().unwrap());
},
Err(e) => {
error!("Error rendering template {}: {}", &template_name, e);
}
}
}
#[get("/cv/<username>/output.pdf")]
pub async fn render_pdf_cv(username: &str, browser: BrowserHolder, tera: NanoTera, tracing: TracingSpan) -> PdfStream {
#[get("/cv/<username>/<language>/output.pdf")]
pub async fn render_pdf_cv(username: &str, tera: NanoTera, tracing: TracingSpan, request_client: OtelReqwestClient,
cv_config: &State<CVBackendConfig>, language: RequestLanguage, browser: BrowserHolder) -> Result<PdfStream, Status> {
let entered_span = tracing.0.enter();
let file = tempfile::Builder::new().suffix(".html").tempfile().unwrap();
render_template("two_column", &file, tera);
let span = info_span!("render_pdf", username = username);
let pdf = span.in_scope(||{
generate_pdf(browser.browser, &file)
});
drop(entered_span);
PdfStream::new(pdf)
match fetch_cv_data_from_backend(cv_config.cv_backend_path.clone(), request_client.0).await {
Ok(cv_data) => {
let file = tempfile::Builder::new().suffix(".html").tempfile().unwrap();
render_template("two_column", &file, tera, cv_data, language.language);
let span = info_span!("render_pdf", username = username);
let pdf = span.in_scope(||{
generate_pdf(browser.browser, &file)
});
drop(entered_span);
Ok(PdfStream::new(pdf))
},
Err(e) => {
error!("Error fetching cv data: {:?}", e);
Err(Status::InternalServerError)
}
}
}
/// Route only for debuging
#[get("/cv/<username>/output.html")]
pub async fn render_html_cv(username: &str, tera: NanoTera, tracing: TracingSpan) -> NamedFile {
let entered_span = tracing.0.enter();
let file = tempfile::Builder::new().suffix(".html").tempfile().unwrap();
render_template("two_column", &file, tera);
NamedFile::open(file.path()).await.unwrap()
#[get("/cv/<username>/<language>/output.html")]
pub async fn render_html_cv(username: &str, tera: NanoTera, tracing: TracingSpan, request_client: OtelReqwestClient,
cv_config: &State<CVBackendConfig>, language: RequestLanguage) -> Result<NamedFile, Status> {
let _ = tracing.0.enter();
match fetch_cv_data_from_backend(cv_config.cv_backend_path.clone(), request_client.0).await {
Ok(cv_data) => {
let file = tempfile::Builder::new().suffix(".html").tempfile().unwrap();
render_template("two_column", &file, tera, cv_data, language.language);
Ok(NamedFile::open(file.path()).await.unwrap())
},
Err(e) => {
error!("Error fetching cv data: {:?}", e);
Err(Status::InternalServerError)
}
}
}

23
src/services/cv.rs Normal file
View File

@ -0,0 +1,23 @@
use ovlach_data::cv::cv::CV;
use reqwest::Client;
#[derive(Debug)]
pub enum FetchError {
ReqwestError(reqwest::Error)
}
impl From<reqwest::Error> for FetchError {
fn from(e: reqwest::Error) -> Self {
FetchError::ReqwestError(e)
}
}
pub async fn fetch_cv_data_from_backend(backend_host: String, client: Client) -> Result<CV, FetchError> {
let resp = client
.get(format!("{}/{}", backend_host, "/api/cv/ovlach")).send()
.await?
.json::<CV>()
.await?;
Ok(resp)
}

1
src/services/mod.rs Normal file
View File

@ -0,0 +1 @@
pub mod cv;

View File

@ -1,2 +1,3 @@
pub mod tera;
pub mod pdf;
pub mod pdf;
pub(crate) mod rocket;

26
src/tools/rocket.rs Normal file
View File

@ -0,0 +1,26 @@
use phf::phf_map;
use rocket::request::FromParam;
pub struct RequestLanguage {
pub language: String,
}
static LANG_TO_CODES: phf::Map<&'static str, &'static str> = phf_map! {
"cs" => "cs-CZ",
"en" => "en-US",
};
impl<'r> FromParam<'r> for RequestLanguage {
type Error = &'r str;
fn from_param(param: &'r str) -> Result<Self, Self::Error> {
match LANG_TO_CODES.get(param) {
Some(val) => Ok(RequestLanguage {
language: val.to_string(),
}),
None => Ok(RequestLanguage {
language: LANG_TO_CODES["en"].to_string(),
}),
}
}
}

View File

@ -1,5 +1,9 @@
use std::{collections::HashMap};
use nanobyte_tera::{l18n::translate_filter, date::{calculate_age, get_year}, string::insert_space_every, gravatar::gravatar_link};
use ovlach_tera::entity::lang_entity;
use rocket::{request::{FromRequest, Outcome}, Request};
use tera::Tera;
use tera::{Tera, Value, Error};
#[derive(Debug)]
pub struct NanoTera(pub Tera);
@ -11,6 +15,33 @@ impl<'r> FromRequest<'r> for NanoTera {
type Error = ();
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, ()> {
rocket::outcome::Outcome::Success(NanoTera(Tera::new("templates/*").unwrap()))
let mut tera = Tera::new("templates/*").unwrap();
tera.register_filter("translate", translate_filter);
tera.register_filter("calculate_age", calculate_age);
tera.register_filter("insert_space_every", insert_space_every);
tera.register_filter("gravatar_link", gravatar_link);
// filters specific to API
tera.register_filter("lang_entity", lang_entity);
tera.register_filter("format_date", get_year); // deprecated
tera.register_filter("get_year", get_year);
tera.register_filter("strip_proto", strip_proto);
rocket::outcome::Outcome::Success(NanoTera(tera))
}
}
}
/// Strip protocol from URL (value)
pub fn strip_proto(
tera_value: &Value,
_: &HashMap<String, Value>
) -> Result<Value, Error> {
let value = tera_value.as_str().unwrap();
if value.starts_with("http://") {
Ok(Value::String(value.strip_prefix("http://").unwrap().to_string()))
} else if value.starts_with("https://") {
Ok(Value::String(value.strip_prefix("https://").unwrap().to_string()))
} else {
Ok(Value::String(value.to_string()))
}
}

View File

@ -113,6 +113,90 @@
order: 0;
}
.flex-container-skills {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
justify-content: normal;
align-items: normal;
align-content: normal;
width: 99%;
}
.section-header .flex-container-skills {
width: 100%;
}
.header {
padding-top: 1em;
}
.flex-items-skills:nth-child(1) {
display: block;
flex-grow: 0;
flex-shrink: 1;
flex-basis: auto;
align-self: auto;
width: 50%;
order: 0;
}
.flex-items-skills:nth-child(2) {
display: block;
flex-grow: 0;
flex-shrink: 1;
flex-basis: auto;
text-align: left;
align-self: auto;
width: 50%;
order: 0;
}
.tech-name {
font-weight: 800;
}
.tech-level {
}
.job p {
margin: 0;
padding: 0;
}
.job:nth-child(n+3) {
padding-top: 10px;
margin: 0;
}
.contact-bar {
font-size: 11pt;
padding-left: 1em;
color: black;
}
.contact-bar i::after{
content: "\a";
white-space: pre;
}
.photo {
padding-bottom: 1em;
}
.contact-bar:first-child {
padding-top: 2em;
}
.email{
}
.icon {
height: 1em;
vertical-align:middle;
}
</style>
</head>
<body>
@ -120,78 +204,134 @@
<div class="flex-items">
<div class="header">
<h1>Ondřej Vlach</h1>
<div class="about-me-small">Software engineer</div>
<div class="about-me-small">{{ "devops-enginner-web-developer" | translate(lang=lang) }}</div>
</div>
<div class="part">
<div class="section-header">
<h2>Experience</h2>
</div>
<div class="job part-content">
<div class="flex-container-experience">
<div class="flex-items-experience">
<div class="title">Title</div>
<div class="company">Company</div>
<div class="languages">PHP HTML Aholamora Snake</div>
<p>
baklklbkalkblaklkb lakbl aklkbl akblka lkblakbla
</p>
<div class="flex-container-skills">
<div class="flex-items-skills">
<h2>{{ "skills-languages" | translate(lang=lang) }}</h2>
</div>
<div class="flex-items-experience">
<div class="location">Location</div>
<div class="dates">Dates</div>
<div class="flex-items-skills">
<h2>{{ "skills-technology" | translate(lang=lang) }}</h2>
</div>
</div>
</div>
<div class="flex-container-skills">
<div class="flex-items-skills">
{% for skill in cv.skills %}
{% if skill.techtype == "LANGUAGE" %}
<div>
<span class="tech-name">{{ skill.name }}</span><span class="tech-level"> ({{ skill.skill | lower}})</span>
</div>
{% endif %}
{% endfor %}
</div>
<div class="flex-items-skills">
{% for skill in cv.skills %}
{% if skill.techtype == "TECHNOLOGY" %}
<div>
<span class="tech-name">{{ skill.name }}</span><span class="tech-level"> ({{ skill.skill | lower}})</span>
</div>
{% endif %}
{% endfor %}
</div>
</div>
</div>
<div class="part">
<div class="section-header">
<h2>Experience</h2>
<h2>{{ "work-experience" | translate(lang=lang) }}</h2>
</div>
{% for job in cv.jobs %}
<div class="job part-content">
<div class="flex-container-experience">
<div class="flex-items-experience">
<div class="title">Title</div>
<div class="company">Company</div>
<div class="languages">PHP HTML Aholamora Snake</div>
<div class="title">{{ job.title }}</div>
<div class="languages">{{ job.languages }}</div>
<p>
baklklbkalkblaklkb lakbl aklkbl akblka lkblakbla
{{ job.description | lang_entity(lang=lang) }}
</p>
</div>
<div class="flex-items-experience">
<div class="location">Location</div>
<div class="dates">Dates</div>
<div class="company">@{{ job.company }}</div>
<div class="dates">
{% if job.from | format_date(type="job") != job.from | format_date(type="job") %}
<div class="text-muted text-small mb-3">{{ job.from | format_date(type="job") }} - {{ job.to | format_date(type="job") }}</div>
{% else %}
<div class="text-muted text-small mb-3">{{ job.from | format_date(type="job") }}</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endfor %}
</div>
<div class="part">
<div class="section-header">
<h2>Experience</h2>
<h2>{{ "education" | translate(lang=lang) }}</h2>
</div>
<div class="job part-content">
{% for education in cv.education %}
<div class="flex-container-experience">
<div class="flex-items-experience">
<div class="title">Title</div>
<div class="company">Company</div>
<div class="languages">PHP HTML Aholamora Snake</div>
<p>
baklklbkalkblaklkb lakbl aklkbl akblka lkblakbla
</p>
{% if education.degree %}
<div class="title">{{ education.degree | lang_entity(lang=lang) }}</div>
{% else %}
<div class="title">{{ education.school }}</div>
{% endif %}
{% if education.description %}
<p>
{{ education.description | lang_entity(lang=lang) }}
</p>
{% endif %}
</div>
<div class="flex-items-experience">
<div class="location">Location</div>
<div class="dates">Dates</div>
{% if education.degree %}
<div class="company">@{{ education.school }}</div>
{% endif %}
<div class="dates">{{ education.from | format_date(type="job") }} - {{ education.to | format_date(type="job") }}</div>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
<div class="flex-items">
Photo2342342314213 423142342314213423
<div class="contact-bar">
<span class="phone">L</span>
<span class="email">L</span>
<div class="photo">
<img class="img-thumbnail shadow-2-strong" src="{{ cv.person.email | gravatar_link }}" width="160" height="160"/>
</div>
{% if cv.person.social.facebook %}
<i class="fa-facebook"> {{ cv.person.social.facebook | strip_proto }}</i>
{% endif %}
{% if cv.person.social.github %}
<i class="fa-github">
<img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBkPSJNMTIgMGMtNi42MjYgMC0xMiA1LjM3My0xMiAxMiAwIDUuMzAyIDMuNDM4IDkuOCA4LjIwNyAxMS4zODcuNTk5LjExMS43OTMtLjI2MS43OTMtLjU3N3YtMi4yMzRjLTMuMzM4LjcyNi00LjAzMy0xLjQxNi00LjAzMy0xLjQxNi0uNTQ2LTEuMzg3LTEuMzMzLTEuNzU2LTEuMzMzLTEuNzU2LTEuMDg5LS43NDUuMDgzLS43MjkuMDgzLS43MjkgMS4yMDUuMDg0IDEuODM5IDEuMjM3IDEuODM5IDEuMjM3IDEuMDcgMS44MzQgMi44MDcgMS4zMDQgMy40OTIuOTk3LjEwNy0uNzc1LjQxOC0xLjMwNS43NjItMS42MDQtMi42NjUtLjMwNS01LjQ2Ny0xLjMzNC01LjQ2Ny01LjkzMSAwLTEuMzExLjQ2OS0yLjM4MSAxLjIzNi0zLjIyMS0uMTI0LS4zMDMtLjUzNS0xLjUyNC4xMTctMy4xNzYgMCAwIDEuMDA4LS4zMjIgMy4zMDEgMS4yMy45NTctLjI2NiAxLjk4My0uMzk5IDMuMDAzLS40MDQgMS4wMi4wMDUgMi4wNDcuMTM4IDMuMDA2LjQwNCAyLjI5MS0xLjU1MiAzLjI5Ny0xLjIzIDMuMjk3LTEuMjMuNjUzIDEuNjUzLjI0MiAyLjg3NC4xMTggMy4xNzYuNzcuODQgMS4yMzUgMS45MTEgMS4yMzUgMy4yMjEgMCA0LjYwOS0yLjgwNyA1LjYyNC01LjQ3OSA1LjkyMS40My4zNzIuODIzIDEuMTAyLjgyMyAyLjIyMnYzLjI5M2MwIC4zMTkuMTkyLjY5NC44MDEuNTc2IDQuNzY1LTEuNTg5IDguMTk5LTYuMDg2IDguMTk5LTExLjM4NiAwLTYuNjI3LTUuMzczLTEyLTEyLTEyeiIvPjwvc3ZnPg==" class="icon">
{{ cv.person.social.github | strip_proto }}
</i>
{% endif %}
{% if cv.person.social.linkedin %}
<i class="fa-linkedin"> {{ cv.person.social.linikedin | strip_proto }}</i>
{% endif %}
{% if cv.person.social.instagram %}
<i class="fa-instagram"> {{ cv.person.social.instagram | strip_proto }}</i>
{% endif %}
{% if cv.person.social.mastodon %}
<i class="fa-mastodon">
<img alt="Mastodon" src="data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz48IS0tIFVwbG9hZGVkIHRvOiBTVkcgUmVwbywgd3d3LnN2Z3JlcG8uY29tLCBHZW5lcmF0b3I6IFNWRyBSZXBvIE1peGVyIFRvb2xzIC0tPgo8c3ZnIGZpbGw9IiMwMDAwMDAiIHdpZHRoPSI4MDBweCIgaGVpZ2h0PSI4MDBweCIgdmlld0JveD0iMCAwIDI0IDI0IiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbDpzcGFjZT0icHJlc2VydmUiPjxwYXRoIGQ9Ik0yMS4zMjcgOC41NjZjMC00LjMzOS0yLjg0My01LjYxLTIuODQzLTUuNjEtMS40MzMtLjY1OC0zLjg5NC0uOTM1LTYuNDUxLS45NTZoLS4wNjNjLTIuNTU3LjAyMS01LjAxNi4yOTgtNi40NS45NTYgMCAwLTIuODQzIDEuMjcyLTIuODQzIDUuNjEgMCAuOTkzLS4wMTkgMi4xODEuMDEyIDMuNDQxLjEwMyA0LjI0My43NzggOC40MjUgNC43MDEgOS40NjMgMS44MDkuNDc5IDMuMzYyLjU3OSA0LjYxMi41MSAyLjI2OC0uMTI2IDMuNTQxLS44MDkgMy41NDEtLjgwOWwtLjA3NS0xLjY0NnMtMS42MjEuNTExLTMuNDQxLjQ0OWMtMS44MDQtLjA2Mi0zLjcwNy0uMTk0LTMuOTk5LTIuNDA5YTQuNTIzIDQuNTIzIDAgMCAxLS4wNC0uNjIxczEuNzcuNDMzIDQuMDE0LjUzNmMxLjM3Mi4wNjMgMi42NTgtLjA4IDMuOTY1LS4yMzYgMi41MDYtLjI5OSA0LjY4OC0xLjg0MyA0Ljk2Mi0zLjI1NC40MzQtMi4yMjMuMzk4LTUuNDI0LjM5OC01LjQyNHptLTMuMzUzIDUuNTloLTIuMDgxVjkuMDU3YzAtMS4wNzUtLjQ1Mi0xLjYyLTEuMzU3LTEuNjItMSAwLTEuNTAxLjY0Ny0xLjUwMSAxLjkyN3YyLjc5MWgtMi4wNjlWOS4zNjRjMC0xLjI4LS41MDEtMS45MjctMS41MDItMS45MjctLjkwNSAwLTEuMzU3LjU0Ni0xLjM1NyAxLjYydjUuMDk5SDYuMDI2VjguOTAzYzAtMS4wNzQuMjczLTEuOTI3LjgyMy0yLjU1OC41NjYtLjYzMSAxLjMwNy0uOTU1IDIuMjI4LS45NTUgMS4wNjUgMCAxLjg3Mi40MDkgMi40MDUgMS4yMjhsLjUxOC44NjkuNTE5LS44NjljLjUzMy0uODE5IDEuMzQtMS4yMjggMi40MDUtMS4yMjguOTIgMCAxLjY2Mi4zMjQgMi4yMjguOTU1LjU0OS42MzEuODIyIDEuNDg0LjgyMiAyLjU1OHY1LjI1M3oiLz48L3N2Zz4=" class="icon" />
{{ cv.person.social.mastodon | strip_proto }}</i>
{% endif %}
<i class="email fa-envelope">
<img alt="E-mail" src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBkPSJNMCAzdjE4aDI0di0xOGgtMjR6bTYuNjIzIDcuOTI5bC00LjYyMyA1LjcxMnYtOS40NThsNC42MjMgMy43NDZ6bS00LjE0MS01LjkyOWgxOS4wMzVsLTkuNTE3IDcuNzEzLTkuNTE4LTcuNzEzem01LjY5NCA3LjE4OGwzLjgyNCAzLjA5OSAzLjgzLTMuMTA0IDUuNjEyIDYuODE3aC0xOC43NzlsNS41MTMtNi44MTJ6bTkuMjA4LTEuMjY0bDQuNjE2LTMuNzQxdjkuMzQ4bC00LjYxNi01LjYwN3oiLz48L3N2Zz4=" class="icon">
{{cv.person.email}}
</i>
<i class="phone fa-phone">
<img alt="Phone" src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBkPSJNNi4xNzYgMS4zMjJsMi44NDQtMS4zMjIgNC4wNDEgNy44OS0yLjcyNCAxLjM0MWMtLjUzOCAxLjI1OSAyLjE1OSA2LjI4OSAzLjI5NyA2LjM3Mi4wOS0uMDU4IDIuNjcxLTEuMzI4IDIuNjcxLTEuMzI4bDQuMTEgNy45MzJzLTIuNzY0IDEuMzU0LTIuODU0IDEuMzk2Yy03Ljg2MiAzLjU5MS0xOS4xMDMtMTguMjU4LTExLjM4NS0yMi4yODF6bTEuOTI5IDEuMjc0bC0xLjAyMy41MDRjLTUuMjk0IDIuNzYyIDQuMTc3IDIxLjE4NSA5LjY0OCAxOC42ODZsLjk3MS0uNDc0LTIuMjcxLTQuMzgzLTEuMDI2LjVjLTMuMTYzIDEuNTQ3LTguMjYyLTguMjE5LTUuMDU1LTkuOTM4bDEuMDA3LS40OTctMi4yNTEtNC4zOTh6Ii8+PC9zdmc+" class="icon">
+{{cv.person.phone | insert_space_every(times=3)}}
</i>
</div>
</div>