the htmltopdf.js library renders all PDF text as images instead of vectors, and Go does not have a reliable native library for generating PDFs that is actively maintained. The best solution appears to be to make a system call to wkhtmltopdf, but now there is a hidden package dependency that is not checked at build time and exists in another process. Maybe in the future a C library can be used and linked with the Go binary.master
@@ -17,6 +17,7 @@ | |||
margin-top: 5px; | |||
z-index: 3; | |||
border: 1px solid black; | |||
top: 100%; | |||
} | |||
.entry { | |||
@@ -1,5 +1,6 @@ | |||
<template> | |||
<div id="pdf-doc" ref="doc" v-if="estimate"> | |||
<div class="disclaimer"><p>Actual costs may vary from estimates after approval. Get an official quote before choosing a loan.</p></div> | |||
<header class="heading"> | |||
@@ -11,14 +12,18 @@ | |||
<h4>{{user.firstName + " " + user.lastName}}</h4> | |||
<span>{{user.email}}</span> | |||
<span>{{user.phone}}</span> | |||
<small>{{user.phone}}</small> | |||
<small>{{user.address.street}}</small> | |||
<small> | |||
{{`${user.address.city}, ${user.address.region} ${user.address.zip}`}} | |||
</small> | |||
</div> | |||
<img :src="avatar"/> | |||
</div> | |||
</header> | |||
<button @click="makePDF">Generate</button> | |||
<button @click="getPdf">Generate</button> | |||
<a :href="pdfLink" v-if="pdfLink" download="estimate.pdf">download </a> | |||
</div> | |||
</template> | |||
@@ -30,6 +35,7 @@ const doc = ref(null) | |||
const props = defineProps(['token', 'estimate', 'user']) | |||
const estimate = ref(null) | |||
const estimates = ref(null) | |||
const pdfLink = ref('') | |||
const letterhead = computed(() => { | |||
if (!props.user.letterhead) return null | |||
@@ -43,7 +49,11 @@ const avatar = computed(() => { | |||
}) | |||
function makePDF() { | |||
html2pdf(doc.value) | |||
var opt = { | |||
image: { type: 'png', quality: 1 }, | |||
} | |||
html2pdf(doc.value, opt) | |||
} | |||
@@ -65,18 +75,62 @@ function getEstimates() { | |||
}) | |||
} | |||
function getPdf() { | |||
fetch(`/api/pdf`, | |||
{method: 'GET', | |||
headers: { | |||
"Accept": "application/json", | |||
"Authorization": `Bearer ${props.token}`, | |||
}, | |||
}).then(response => { | |||
if (response.ok) { return response.blob() } | |||
else { | |||
return null | |||
} | |||
}).then (result => { | |||
if (!result) return | |||
pdfLink.value = URL.createObjectURL(result) | |||
}) | |||
} | |||
onMounted(() => { | |||
getEstimates().then(() => estimate.value = estimates.value[0]) | |||
}) | |||
</script> | |||
<style scoped> | |||
#pdf-doc { | |||
margin: 4px 30px; | |||
} | |||
.disclaimer { | |||
font-weight: bold; | |||
border-bottom: 1px solid lightgrey; | |||
margin-bottom: 20px; | |||
color: var(--text); | |||
} | |||
.disclaimer p { | |||
margin: 5px 0; | |||
} | |||
h4 { | |||
margin: 4px 0; | |||
} | |||
header.heading { | |||
display: flex; | |||
justify-content: space-between; | |||
} | |||
.user-info { | |||
display: flex; | |||
flex-flow: column; | |||
} | |||
#pdf-doc header.heading > div { | |||
display: flex; | |||
gap: 8px; | |||
text-align: right; | |||
} | |||
</style> |
@@ -31,7 +31,7 @@ | |||
<label for="">NMLS ID</label> | |||
<input type="text"> | |||
<label for="">Branch ID</label> | |||
<input type="text" :value="user.branchId"> | |||
<input type="text" :value="user.branchId" disabled> | |||
<select id="" name="" :value="user.country"> | |||
<option value="USA">USA</option> | |||
@@ -288,5 +288,6 @@ watch(props.user, (u) => { | |||
div.address-entry { | |||
display: flex; | |||
flex-flow: column; | |||
position: relative; | |||
} | |||
</style> |
@@ -2,6 +2,7 @@ package main | |||
import ( | |||
"os" | |||
"os/exec" | |||
"net/http" | |||
"net/mail" | |||
"log" | |||
@@ -12,6 +13,7 @@ import ( | |||
_ "github.com/go-sql-driver/mysql" | |||
"fmt" | |||
"encoding/json" | |||
"encoding/base64" | |||
"strconv" | |||
"bytes" | |||
"time" | |||
@@ -811,7 +813,8 @@ func queryUsers(db *sql.DB, id int) ( []User, error ) { | |||
u.status, | |||
u.verified, | |||
u.role, | |||
u.address | |||
u.address, | |||
u.phone | |||
FROM user u WHERE u.id = CASE @e := ? WHEN 0 THEN u.id ELSE @e END | |||
` | |||
rows, err = db.Query(query, id) | |||
@@ -838,6 +841,7 @@ func queryUsers(db *sql.DB, id int) ( []User, error ) { | |||
&user.Verified, | |||
&user.Role, | |||
&user.Address.Id, | |||
&user.Phone, | |||
) | |||
err != nil { | |||
return users, err | |||
@@ -1807,7 +1811,7 @@ func showPDF(w http.ResponseWriter, r *http.Request) { | |||
// p := r.URL.Path | |||
db, err := sql.Open("mysql", | |||
fmt.Sprintf("%s:%s@tcp(127.0.0.1:3306)/skouter", | |||
fmt.Sprintf("%s:%s@tcp(127.0.0.1:3306)/skouter_dev", | |||
os.Getenv("DBUser"), | |||
os.Getenv("DBPass"))) | |||
@@ -1829,18 +1833,102 @@ func showPDF(w http.ResponseWriter, r *http.Request) { | |||
info := struct { | |||
Title string | |||
Name string | |||
Avatar string | |||
Letterhead string | |||
User User | |||
}{ | |||
Title: "test PDF", | |||
Name: "idk-random-name", | |||
User: users[0], | |||
} | |||
avatar, err := fetchAvatar(db, info.User.Id) | |||
letterhead, err := fetchLetterhead(db, info.User.Id) | |||
info.Avatar = | |||
base64.StdEncoding.EncodeToString(avatar) | |||
info.Letterhead = | |||
base64.StdEncoding.EncodeToString(letterhead) | |||
err = pa.Execute(w, info) | |||
if err != nil {fmt.Println(err)} | |||
} | |||
func getPdf(w http.ResponseWriter, db *sql.DB, r *http.Request) { | |||
var err error | |||
cmd := exec.Command("wkhtmltopdf", "-", "-") | |||
stdout, err := cmd.StdoutPipe() | |||
if err != nil { | |||
w.WriteHeader(500); | |||
log.Println(err) | |||
return | |||
} | |||
stdin, err := cmd.StdinPipe() | |||
if err != nil { | |||
w.WriteHeader(500); | |||
log.Println(err) | |||
return | |||
} | |||
if err := cmd.Start(); err != nil { | |||
log.Fatal(err) | |||
} | |||
var pa = template.Must(template.ParseFiles("views/pdf.tpl", | |||
"views/test.tpl")) | |||
// claims, err := getClaims(r) | |||
if err != nil { | |||
w.WriteHeader(500); | |||
log.Println(err) | |||
return | |||
} | |||
users, err := queryUsers(db, 1) | |||
info := struct { | |||
Title string | |||
Name string | |||
Avatar string | |||
Letterhead string | |||
User User | |||
}{ | |||
Title: "test PDF", | |||
Name: "idk-random-name", | |||
User: users[0], | |||
} | |||
avatar, err := fetchAvatar(db, info.User.Id) | |||
letterhead, err := fetchLetterhead(db, info.User.Id) | |||
info.Avatar = | |||
base64.StdEncoding.EncodeToString(avatar) | |||
info.Letterhead = | |||
base64.StdEncoding.EncodeToString(letterhead) | |||
err = pa.Execute(stdin, info) | |||
if err != nil { | |||
w.WriteHeader(500); | |||
log.Println(err) | |||
return | |||
} | |||
stdin.Close() | |||
buf, err := io.ReadAll(stdout) | |||
if _, err := w.Write(buf); err != nil { | |||
w.WriteHeader(500); | |||
log.Println(err) | |||
return | |||
} | |||
if err := cmd.Wait(); err != nil { | |||
// w.WriteHeader(500) | |||
log.Println(err) | |||
return | |||
} | |||
} | |||
func clipLetterhead(w http.ResponseWriter, db *sql.DB, r *http.Request) { | |||
var validTypes []string = []string{"image/png", "image/jpeg"} | |||
var isValidType bool | |||
@@ -1966,6 +2054,10 @@ func api(w http.ResponseWriter, r *http.Request) { | |||
r.Method == http.MethodPost && | |||
guard(r, 1): | |||
summarize(w, db, r) | |||
case match(p, "/api/pdf", &args) && | |||
r.Method == http.MethodGet && | |||
guard(r, 1): | |||
getPdf(w, db, r) | |||
default: | |||
http.Error(w, "Invalid route or token", 404) | |||
} | |||
@@ -0,0 +1,14 @@ | |||
<!DOCTYPE html> | |||
<head> | |||
<meta charset='utf-8'> | |||
<meta name="viewport" | |||
content="width=device-width, initial-scale=1, shrink-to-fit=no"> | |||
<link rel="stylesheet" href=""> | |||
</head> | |||
<body> | |||
{{block "header" .}} | |||
{{end}} | |||
{{template "main" .}} | |||
</body> |
@@ -4,8 +4,67 @@ | |||
{{end}} | |||
{{define "main"}} | |||
<main class='fade-in-2'> | |||
<div>hello world {{.User.Title}}</div> | |||
<section>TESTING</section> | |||
</main> | |||
<div id="pdf-doc" ref="doc" v-if="estimate"> | |||
<div class="disclaimer"><p>Actual costs may vary from estimates after approval. Get an official quote before choosing a loan.</p></div> | |||
<header class="heading"> | |||
<img src="data:image/png;base64,{{.Letterhead}}" /> | |||
<div> | |||
<div class="user-info"> | |||
<h4>{{.User.FirstName}} {{.User.LastName}}</h4> | |||
<span>{{.User.Email}}</span> | |||
<span>{{.User.Phone}}</span> | |||
<small>{{.User.Address.Street}}</small> | |||
<small> | |||
{{.User.Address.City}}, {{.User.Address.Region}} {{.User.Address.Zip}} | |||
</small> | |||
</div> | |||
<img src="data:image/png;base64,{{.Avatar}}" /> | |||
</div> | |||
</header> | |||
<style scoped> | |||
#pdf-doc { | |||
margin: 4px 30px; | |||
} | |||
.disclaimer { | |||
font-weight: bold; | |||
border-bottom: 1px solid lightgrey; | |||
margin-bottom: 20px; | |||
color: var(--text); | |||
} | |||
.disclaimer p { | |||
margin: 5px 0; | |||
} | |||
h4 { | |||
margin: 4px 0; | |||
} | |||
header.heading { | |||
display: flex; | |||
justify-content: space-between; | |||
} | |||
.user-info { | |||
display: flex; | |||
flex-flow: column; | |||
} | |||
#pdf-doc header.heading > div { | |||
display: flex; | |||
text-align: right; | |||
} | |||
header.heading .user-info { | |||
margin-right: 8px; | |||
} | |||
</style> | |||
{{end}} | |||