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; | margin-top: 5px; | ||||
z-index: 3; | z-index: 3; | ||||
border: 1px solid black; | border: 1px solid black; | ||||
top: 100%; | |||||
} | } | ||||
.entry { | .entry { | ||||
@@ -1,5 +1,6 @@ | |||||
<template> | <template> | ||||
<div id="pdf-doc" ref="doc" v-if="estimate"> | <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"> | <header class="heading"> | ||||
@@ -11,14 +12,18 @@ | |||||
<h4>{{user.firstName + " " + user.lastName}}</h4> | <h4>{{user.firstName + " " + user.lastName}}</h4> | ||||
<span>{{user.email}}</span> | <span>{{user.email}}</span> | ||||
<span>{{user.phone}}</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> | </div> | ||||
<img :src="avatar"/> | <img :src="avatar"/> | ||||
</div> | </div> | ||||
</header> | </header> | ||||
<button @click="makePDF">Generate</button> | |||||
<button @click="getPdf">Generate</button> | |||||
<a :href="pdfLink" v-if="pdfLink" download="estimate.pdf">download </a> | |||||
</div> | </div> | ||||
</template> | </template> | ||||
@@ -30,6 +35,7 @@ const doc = ref(null) | |||||
const props = defineProps(['token', 'estimate', 'user']) | const props = defineProps(['token', 'estimate', 'user']) | ||||
const estimate = ref(null) | const estimate = ref(null) | ||||
const estimates = ref(null) | const estimates = ref(null) | ||||
const pdfLink = ref('') | |||||
const letterhead = computed(() => { | const letterhead = computed(() => { | ||||
if (!props.user.letterhead) return null | if (!props.user.letterhead) return null | ||||
@@ -43,7 +49,11 @@ const avatar = computed(() => { | |||||
}) | }) | ||||
function makePDF() { | 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(() => { | onMounted(() => { | ||||
getEstimates().then(() => estimate.value = estimates.value[0]) | getEstimates().then(() => estimate.value = estimates.value[0]) | ||||
}) | }) | ||||
</script> | </script> | ||||
<style scoped> | <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 { | header.heading { | ||||
display: flex; | display: flex; | ||||
justify-content: space-between; | |||||
} | } | ||||
.user-info { | .user-info { | ||||
display: flex; | display: flex; | ||||
flex-flow: column; | flex-flow: column; | ||||
} | } | ||||
#pdf-doc header.heading > div { | |||||
display: flex; | |||||
gap: 8px; | |||||
text-align: right; | |||||
} | |||||
</style> | </style> |
@@ -31,7 +31,7 @@ | |||||
<label for="">NMLS ID</label> | <label for="">NMLS ID</label> | ||||
<input type="text"> | <input type="text"> | ||||
<label for="">Branch ID</label> | <label for="">Branch ID</label> | ||||
<input type="text" :value="user.branchId"> | |||||
<input type="text" :value="user.branchId" disabled> | |||||
<select id="" name="" :value="user.country"> | <select id="" name="" :value="user.country"> | ||||
<option value="USA">USA</option> | <option value="USA">USA</option> | ||||
@@ -288,5 +288,6 @@ watch(props.user, (u) => { | |||||
div.address-entry { | div.address-entry { | ||||
display: flex; | display: flex; | ||||
flex-flow: column; | flex-flow: column; | ||||
position: relative; | |||||
} | } | ||||
</style> | </style> |
@@ -2,6 +2,7 @@ package main | |||||
import ( | import ( | ||||
"os" | "os" | ||||
"os/exec" | |||||
"net/http" | "net/http" | ||||
"net/mail" | "net/mail" | ||||
"log" | "log" | ||||
@@ -12,6 +13,7 @@ import ( | |||||
_ "github.com/go-sql-driver/mysql" | _ "github.com/go-sql-driver/mysql" | ||||
"fmt" | "fmt" | ||||
"encoding/json" | "encoding/json" | ||||
"encoding/base64" | |||||
"strconv" | "strconv" | ||||
"bytes" | "bytes" | ||||
"time" | "time" | ||||
@@ -811,7 +813,8 @@ func queryUsers(db *sql.DB, id int) ( []User, error ) { | |||||
u.status, | u.status, | ||||
u.verified, | u.verified, | ||||
u.role, | u.role, | ||||
u.address | |||||
u.address, | |||||
u.phone | |||||
FROM user u WHERE u.id = CASE @e := ? WHEN 0 THEN u.id ELSE @e END | FROM user u WHERE u.id = CASE @e := ? WHEN 0 THEN u.id ELSE @e END | ||||
` | ` | ||||
rows, err = db.Query(query, id) | rows, err = db.Query(query, id) | ||||
@@ -838,6 +841,7 @@ func queryUsers(db *sql.DB, id int) ( []User, error ) { | |||||
&user.Verified, | &user.Verified, | ||||
&user.Role, | &user.Role, | ||||
&user.Address.Id, | &user.Address.Id, | ||||
&user.Phone, | |||||
) | ) | ||||
err != nil { | err != nil { | ||||
return users, err | return users, err | ||||
@@ -1807,7 +1811,7 @@ func showPDF(w http.ResponseWriter, r *http.Request) { | |||||
// p := r.URL.Path | // p := r.URL.Path | ||||
db, err := sql.Open("mysql", | 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("DBUser"), | ||||
os.Getenv("DBPass"))) | os.Getenv("DBPass"))) | ||||
@@ -1829,18 +1833,102 @@ func showPDF(w http.ResponseWriter, r *http.Request) { | |||||
info := struct { | info := struct { | ||||
Title string | Title string | ||||
Name string | Name string | ||||
Avatar string | |||||
Letterhead string | |||||
User User | User User | ||||
}{ | }{ | ||||
Title: "test PDF", | Title: "test PDF", | ||||
Name: "idk-random-name", | Name: "idk-random-name", | ||||
User: users[0], | 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) | err = pa.Execute(w, info) | ||||
if err != nil {fmt.Println(err)} | 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) { | func clipLetterhead(w http.ResponseWriter, db *sql.DB, r *http.Request) { | ||||
var validTypes []string = []string{"image/png", "image/jpeg"} | var validTypes []string = []string{"image/png", "image/jpeg"} | ||||
var isValidType bool | var isValidType bool | ||||
@@ -1966,6 +2054,10 @@ func api(w http.ResponseWriter, r *http.Request) { | |||||
r.Method == http.MethodPost && | r.Method == http.MethodPost && | ||||
guard(r, 1): | guard(r, 1): | ||||
summarize(w, db, r) | summarize(w, db, r) | ||||
case match(p, "/api/pdf", &args) && | |||||
r.Method == http.MethodGet && | |||||
guard(r, 1): | |||||
getPdf(w, db, r) | |||||
default: | default: | ||||
http.Error(w, "Invalid route or token", 404) | 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}} | {{end}} | ||||
{{define "main"}} | {{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}} | {{end}} | ||||