476 lines
13 KiB
Go
476 lines
13 KiB
Go
// Package pdf renders a printable representation of a form definition.
|
||
package pdf
|
||
|
||
import (
|
||
"bytes"
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/go-pdf/fpdf"
|
||
"github.com/google/uuid"
|
||
|
||
"kereru.forms/internal/database"
|
||
)
|
||
|
||
const (
|
||
pdfLeftMargin = 18.0
|
||
pdfPageWidth = 210.0
|
||
pdfPageHeight = 297.0
|
||
pdfBottomEdge = pdfPageHeight - 18
|
||
pdfContentWide = pdfPageWidth - pdfLeftMargin*2
|
||
pdfLabelWidth = 112.0
|
||
pdfAnswerX = pdfLeftMargin + pdfLabelWidth + 4
|
||
pdfAnswerWidth = pdfContentWide - pdfLabelWidth - 4
|
||
)
|
||
|
||
// Generate builds a PDF document for the supplied form. The document doubles
|
||
// as a printable offline form: it includes a submitter details area and space
|
||
// for handwritten answers.
|
||
func Generate(form *database.Form) ([]byte, error) {
|
||
if form == nil {
|
||
return nil, fmt.Errorf("form is nil")
|
||
}
|
||
|
||
doc := fpdf.New("P", "mm", "A4", "")
|
||
doc.SetMargins(pdfLeftMargin, pdfLeftMargin, pdfLeftMargin)
|
||
doc.SetAutoPageBreak(true, 18)
|
||
doc.SetTitle(sanitize(form.Title), false)
|
||
doc.SetAuthor(sanitize(deref(form.CompanyName, "Kereru Forms")), false)
|
||
doc.SetCreator("Kereru Forms", false)
|
||
doc.SetSubject(fmt.Sprintf("Form version %d", form.Version), false)
|
||
|
||
doc.SetFooterFunc(func() {
|
||
doc.SetY(-14)
|
||
doc.SetFont("Helvetica", "I", 8)
|
||
doc.SetTextColor(120, 120, 120)
|
||
doc.CellFormat(0, 6,
|
||
fmt.Sprintf("Kereru Forms - %s - page %d", sanitize(form.Title), doc.PageNo()),
|
||
"", 0, "C", false, 0, "")
|
||
})
|
||
|
||
doc.AddPage()
|
||
doc.SetTextColor(20, 20, 20)
|
||
|
||
if form.CompanyName != nil && *form.CompanyName != "" {
|
||
doc.SetFont("Helvetica", "", 11)
|
||
doc.SetTextColor(90, 90, 90)
|
||
doc.MultiCell(0, 6, sanitize(*form.CompanyName), "", "L", false)
|
||
}
|
||
|
||
doc.SetFont("Helvetica", "B", 20)
|
||
doc.SetTextColor(20, 20, 20)
|
||
doc.MultiCell(0, 9, sanitize(form.Title), "", "L", false)
|
||
|
||
doc.SetFont("Helvetica", "", 9)
|
||
doc.SetTextColor(110, 110, 110)
|
||
doc.MultiCell(0, 5, fmt.Sprintf("Version %d - form id %s", form.Version, form.ID), "", "L", false)
|
||
doc.Ln(2)
|
||
|
||
if form.Description != nil && strings.TrimSpace(*form.Description) != "" {
|
||
doc.SetDrawColor(220, 220, 220)
|
||
doc.SetFont("Helvetica", "", 10)
|
||
doc.SetTextColor(60, 60, 60)
|
||
doc.MultiCell(0, 5, sanitize(*form.Description), "", "L", false)
|
||
doc.Ln(2)
|
||
}
|
||
|
||
if form.Introduction != nil && strings.TrimSpace(*form.Introduction) != "" {
|
||
doc.SetFont("Helvetica", "B", 11)
|
||
doc.SetTextColor(20, 20, 20)
|
||
doc.CellFormat(0, 7, "Introduction", "", 1, "L", false, 0, "")
|
||
doc.SetFont("Helvetica", "", 10)
|
||
doc.SetTextColor(60, 60, 60)
|
||
doc.MultiCell(0, 5, sanitize(*form.Introduction), "", "L", false)
|
||
doc.Ln(2)
|
||
}
|
||
|
||
if hasContact(form) {
|
||
doc.SetFont("Helvetica", "B", 11)
|
||
doc.SetTextColor(20, 20, 20)
|
||
doc.CellFormat(0, 7, "Contact", "", 1, "L", false, 0, "")
|
||
doc.SetFont("Helvetica", "", 10)
|
||
doc.SetTextColor(60, 60, 60)
|
||
if form.ContactEmail != nil && *form.ContactEmail != "" {
|
||
doc.MultiCell(0, 5, sanitize("Email: "+*form.ContactEmail), "", "L", false)
|
||
}
|
||
if form.ContactPhone != nil && *form.ContactPhone != "" {
|
||
doc.MultiCell(0, 5, sanitize("Phone: "+*form.ContactPhone), "", "L", false)
|
||
}
|
||
if form.ContactURL != nil && *form.ContactURL != "" {
|
||
doc.MultiCell(0, 5, sanitize("Web: "+*form.ContactURL), "", "L", false)
|
||
}
|
||
doc.Ln(2)
|
||
}
|
||
|
||
doc.SetDrawColor(210, 210, 210)
|
||
doc.Line(pdfLeftMargin, doc.GetY(), pdfPageWidth-pdfLeftMargin, doc.GetY())
|
||
doc.Ln(4)
|
||
|
||
renderSubmitterDetails(doc, form)
|
||
|
||
doc.SetFont("Helvetica", "B", 12)
|
||
doc.SetTextColor(20, 20, 20)
|
||
doc.CellFormat(0, 8, "Questions", "", 1, "L", false, 0, "")
|
||
doc.Ln(1)
|
||
|
||
if len(form.Questions) == 0 {
|
||
doc.SetFont("Helvetica", "I", 10)
|
||
doc.SetTextColor(110, 110, 110)
|
||
doc.MultiCell(0, 5, "This form does not contain any questions yet.", "", "L", false)
|
||
}
|
||
|
||
for i, q := range form.Questions {
|
||
renderQuestion(doc, i+1, q)
|
||
}
|
||
|
||
if form.SignatureRequired {
|
||
renderSignatureSection(doc)
|
||
}
|
||
|
||
doc.Ln(4)
|
||
doc.SetFont("Helvetica", "I", 8)
|
||
doc.SetTextColor(130, 130, 130)
|
||
doc.MultiCell(0, 4, fmt.Sprintf(
|
||
"Generated %s - %s", time.Now().UTC().Format(time.RFC3339), uuid.NewString()),
|
||
"", "L", false)
|
||
|
||
var buf bytes.Buffer
|
||
if err := doc.Output(&buf); err != nil {
|
||
return nil, fmt.Errorf("render pdf: %w", err)
|
||
}
|
||
return buf.Bytes(), nil
|
||
}
|
||
|
||
func renderSignatureSection(doc *fpdf.Fpdf) {
|
||
ensureSpace(doc, 46)
|
||
doc.SetFont("Helvetica", "B", 12)
|
||
doc.SetTextColor(20, 20, 20)
|
||
doc.CellFormat(0, 8, "Digital signature", "", 1, "L", false, 0, "")
|
||
doc.SetFont("Helvetica", "I", 8)
|
||
doc.SetTextColor(120, 120, 120)
|
||
doc.MultiCell(0, 4, "Completed in the online form as a drawn or uploaded signature.", "", "L", false)
|
||
doc.Ln(2)
|
||
|
||
y := doc.GetY()
|
||
drawnField(doc, pdfLeftMargin, y, "Name:", 90)
|
||
drawnField(doc, pdfLeftMargin+95, y, "Title (optional):", 79)
|
||
doc.SetY(y + 11)
|
||
drawnField(doc, pdfLeftMargin, doc.GetY(), "Signature:", pdfContentWide)
|
||
doc.SetY(doc.GetY() + 11)
|
||
}
|
||
|
||
// renderSubmitterDetails draws the section a respondent completes by hand when
|
||
// the form is filled in offline.
|
||
func renderSubmitterDetails(doc *fpdf.Fpdf, form *database.Form) {
|
||
ensureSpace(doc, 62)
|
||
|
||
doc.SetFont("Helvetica", "B", 12)
|
||
doc.SetTextColor(20, 20, 20)
|
||
doc.CellFormat(0, 8, "Submitter details", "", 1, "L", false, 0, "")
|
||
|
||
doc.SetFont("Helvetica", "I", 8)
|
||
doc.SetTextColor(120, 120, 120)
|
||
doc.MultiCell(0, 4, "Complete this form offline, then return it using the contact details above.", "", "L", false)
|
||
doc.Ln(2)
|
||
|
||
y := doc.GetY()
|
||
drawnField(doc, pdfLeftMargin, y, "Name / identifier:", pdfContentWide)
|
||
y += 9
|
||
|
||
doc.SetFont("Helvetica", "", 9)
|
||
doc.SetTextColor(90, 90, 90)
|
||
doc.SetXY(pdfLeftMargin, y)
|
||
doc.CellFormat(26, 6, "Identifier type:", "", 0, "L", false, 0, "")
|
||
x := pdfLeftMargin + 28
|
||
for _, label := range []string{"Email", "Username", "Phone", "GUID"} {
|
||
drawCheckbox(doc, x, y+1)
|
||
doc.SetXY(x+5, y)
|
||
doc.CellFormat(0, 6, label, "", 0, "L", false, 0, "")
|
||
x += 5 + doc.GetStringWidth(label) + 8
|
||
}
|
||
doc.SetY(y + 9)
|
||
|
||
y = doc.GetY()
|
||
fieldWidth := 82.0
|
||
if form.RequiresAccessCode {
|
||
drawnField(doc, pdfLeftMargin, y, "Access code:", fieldWidth)
|
||
}
|
||
drawnField(doc, pdfLeftMargin+90, y, "Date completed:", fieldWidth)
|
||
doc.SetY(y + 9)
|
||
|
||
if form.ExternalReferenceRequired {
|
||
label := "External reference"
|
||
if form.ExternalReferenceLabel != nil && *form.ExternalReferenceLabel != "" {
|
||
label = *form.ExternalReferenceLabel
|
||
}
|
||
y = doc.GetY()
|
||
drawnField(doc, pdfLeftMargin, y, sanitize(label)+":", pdfContentWide)
|
||
doc.SetY(y + 9)
|
||
}
|
||
|
||
y = doc.GetY()
|
||
drawnField(doc, pdfLeftMargin, y, "Signature:", pdfContentWide)
|
||
doc.SetY(y + 10)
|
||
|
||
doc.SetDrawColor(210, 210, 210)
|
||
doc.Line(pdfLeftMargin, doc.GetY(), pdfPageWidth-pdfLeftMargin, doc.GetY())
|
||
doc.Ln(4)
|
||
}
|
||
|
||
func renderQuestion(doc *fpdf.Fpdf, number int, q database.Question) {
|
||
label := fmt.Sprintf("%d. %s", number, sanitize(q.Label))
|
||
if q.Required {
|
||
label += " *"
|
||
}
|
||
|
||
if q.Type == "text" {
|
||
renderTextQuestion(doc, label, q)
|
||
return
|
||
}
|
||
|
||
labelHeight := measureLabel(doc, label, q)
|
||
rowHeight := max(labelHeight, answerBlockHeight(q))
|
||
|
||
doc.SetFont("Helvetica", "", 10)
|
||
startY := doc.GetY()
|
||
if startY+rowHeight > pdfBottomEdge {
|
||
doc.AddPage()
|
||
startY = doc.GetY()
|
||
}
|
||
|
||
doc.SetXY(pdfLeftMargin, startY)
|
||
doc.SetFont("Helvetica", "B", 10)
|
||
doc.SetTextColor(25, 25, 25)
|
||
doc.MultiCell(pdfLabelWidth, 5, label, "", "L", false)
|
||
|
||
doc.SetFont("Helvetica", "", 8)
|
||
doc.SetTextColor(120, 120, 120)
|
||
doc.MultiCell(pdfLabelWidth, 4, "Key: "+sanitize(q.Key)+" - "+typeDescription(q), "", "L", false)
|
||
|
||
if q.Description != nil && *q.Description != "" {
|
||
doc.SetFont("Helvetica", "I", 9)
|
||
doc.SetTextColor(90, 90, 90)
|
||
doc.MultiCell(pdfLabelWidth, 4, sanitize(*q.Description), "", "L", false)
|
||
}
|
||
|
||
if q.Placeholder != nil && *q.Placeholder != "" {
|
||
doc.SetFont("Helvetica", "I", 9)
|
||
doc.SetTextColor(140, 140, 140)
|
||
doc.MultiCell(pdfLabelWidth, 4, "Hint: "+sanitize(*q.Placeholder), "", "L", false)
|
||
}
|
||
|
||
renderAnswerBlock(doc, startY, q)
|
||
doc.SetY(max(doc.GetY(), startY+rowHeight) + 3)
|
||
}
|
||
|
||
func renderTextQuestion(doc *fpdf.Fpdf, label string, q database.Question) {
|
||
lines := 1
|
||
if q.Multiline {
|
||
lines = 4
|
||
}
|
||
needed := 8.0 + float64(lines)*9 + 6
|
||
if q.Description != nil && *q.Description != "" {
|
||
needed += 5
|
||
}
|
||
ensureSpace(doc, needed)
|
||
|
||
doc.SetFont("Helvetica", "B", 10)
|
||
doc.SetTextColor(25, 25, 25)
|
||
doc.MultiCell(0, 5, label, "", "L", false)
|
||
|
||
doc.SetFont("Helvetica", "", 8)
|
||
doc.SetTextColor(120, 120, 120)
|
||
doc.MultiCell(0, 4, "Key: "+sanitize(q.Key)+" - "+typeDescription(q), "", "L", false)
|
||
|
||
if q.Description != nil && *q.Description != "" {
|
||
doc.SetFont("Helvetica", "I", 9)
|
||
doc.SetTextColor(90, 90, 90)
|
||
doc.MultiCell(0, 4, sanitize(*q.Description), "", "L", false)
|
||
}
|
||
|
||
if q.Placeholder != nil && *q.Placeholder != "" {
|
||
doc.SetFont("Helvetica", "I", 9)
|
||
doc.SetTextColor(140, 140, 140)
|
||
doc.MultiCell(0, 4, "Hint: "+sanitize(*q.Placeholder), "", "L", false)
|
||
}
|
||
|
||
doc.SetDrawColor(190, 190, 190)
|
||
doc.Ln(2)
|
||
for i := 0; i < lines; i++ {
|
||
doc.CellFormat(0, 7, "", "B", 1, "L", false, 0, "")
|
||
doc.Ln(2)
|
||
}
|
||
doc.Ln(2)
|
||
}
|
||
|
||
func renderAnswerBlock(doc *fpdf.Fpdf, startY float64, q database.Question) {
|
||
switch q.Type {
|
||
case "boolean":
|
||
drawCheckbox(doc, pdfAnswerX, startY+1)
|
||
doc.SetFont("Helvetica", "", 10)
|
||
doc.SetTextColor(50, 50, 50)
|
||
doc.SetXY(pdfAnswerX+6, startY)
|
||
doc.CellFormat(20, 6, "True", "", 0, "L", false, 0, "")
|
||
|
||
drawCheckbox(doc, pdfAnswerX+30, startY+1)
|
||
doc.SetXY(pdfAnswerX+36, startY)
|
||
doc.CellFormat(20, 6, "False", "", 0, "L", false, 0, "")
|
||
case "select":
|
||
y := startY
|
||
for _, opt := range q.Options {
|
||
drawCheckbox(doc, pdfAnswerX, y+1)
|
||
doc.SetXY(pdfAnswerX+6, y)
|
||
doc.SetFont("Helvetica", "", 9)
|
||
doc.SetTextColor(50, 50, 50)
|
||
doc.MultiCell(pdfAnswerWidth-6, 5, sanitize(opt.Label), "", "L", false)
|
||
y = doc.GetY()
|
||
}
|
||
case "number":
|
||
doc.SetDrawColor(150, 150, 150)
|
||
doc.Line(pdfAnswerX, startY+6, pdfAnswerX+48, startY+6)
|
||
case "date":
|
||
doc.SetFont("Helvetica", "", 8)
|
||
doc.SetTextColor(140, 140, 140)
|
||
doc.SetXY(pdfAnswerX+50, startY+1)
|
||
doc.CellFormat(0, 4, "YYYY-MM-DD", "", 0, "L", false, 0, "")
|
||
doc.SetDrawColor(150, 150, 150)
|
||
doc.Line(pdfAnswerX, startY+6, pdfAnswerX+48, startY+6)
|
||
}
|
||
}
|
||
|
||
func measureLabel(doc *fpdf.Fpdf, label string, q database.Question) float64 {
|
||
doc.SetFont("Helvetica", "B", 10)
|
||
height := float64(len(doc.SplitText(label, pdfLabelWidth))) * 5
|
||
doc.SetFont("Helvetica", "", 8)
|
||
hint := "Key: " + sanitize(q.Key) + " - " + typeDescription(q)
|
||
height += float64(len(doc.SplitText(hint, pdfLabelWidth))) * 4
|
||
if q.Description != nil && *q.Description != "" {
|
||
doc.SetFont("Helvetica", "I", 9)
|
||
height += float64(len(doc.SplitText(sanitize(*q.Description), pdfLabelWidth))) * 4
|
||
}
|
||
if q.Placeholder != nil && *q.Placeholder != "" {
|
||
doc.SetFont("Helvetica", "I", 9)
|
||
height += float64(len(doc.SplitText("Hint: "+sanitize(*q.Placeholder), pdfLabelWidth))) * 4
|
||
}
|
||
return height + 2
|
||
}
|
||
|
||
func answerBlockHeight(q database.Question) float64 {
|
||
switch q.Type {
|
||
case "boolean", "number", "date":
|
||
return 10
|
||
case "select":
|
||
return float64(max(1, len(q.Options))) * 6
|
||
default:
|
||
return 10
|
||
}
|
||
}
|
||
|
||
func ensureSpace(doc *fpdf.Fpdf, needed float64) {
|
||
if doc.GetY()+needed > pdfBottomEdge {
|
||
doc.AddPage()
|
||
}
|
||
}
|
||
|
||
func drawnField(doc *fpdf.Fpdf, x, y float64, label string, width float64) {
|
||
doc.SetFont("Helvetica", "", 9)
|
||
doc.SetTextColor(90, 90, 90)
|
||
runes := []rune(label)
|
||
for len(runes) > 0 && doc.GetStringWidth(string(runes))+2 > width-12 {
|
||
runes = runes[:len(runes)-1]
|
||
}
|
||
label = string(runes)
|
||
labelWidth := doc.GetStringWidth(label) + 2
|
||
doc.SetXY(x, y)
|
||
doc.CellFormat(labelWidth, 6, label, "", 0, "L", false, 0, "")
|
||
doc.SetDrawColor(150, 150, 150)
|
||
doc.CellFormat(width-labelWidth, 6, "", "B", 0, "L", false, 0, "")
|
||
}
|
||
|
||
func drawCheckbox(doc *fpdf.Fpdf, x, y float64) {
|
||
doc.SetDrawColor(120, 120, 120)
|
||
doc.Rect(x, y+1.2, 4, 4, "D")
|
||
}
|
||
|
||
func typeDescription(q database.Question) string {
|
||
switch q.Type {
|
||
case "boolean":
|
||
return "True / false"
|
||
case "number":
|
||
desc := "Number"
|
||
if q.MinValue != nil || q.MaxValue != nil {
|
||
desc += " ("
|
||
if q.MinValue != nil {
|
||
desc += fmt.Sprintf("min %g", *q.MinValue)
|
||
}
|
||
if q.MinValue != nil && q.MaxValue != nil {
|
||
desc += ", "
|
||
}
|
||
if q.MaxValue != nil {
|
||
desc += fmt.Sprintf("max %g", *q.MaxValue)
|
||
}
|
||
desc += ")"
|
||
}
|
||
return desc
|
||
case "select":
|
||
return "Select one option"
|
||
case "text":
|
||
if q.Multiline {
|
||
return "Text (multiline)"
|
||
}
|
||
return "Text (single line)"
|
||
case "date":
|
||
return "Date (YYYY-MM-DD)"
|
||
default:
|
||
return q.Type
|
||
}
|
||
}
|
||
|
||
func hasContact(form *database.Form) bool {
|
||
vals := []*string{form.ContactEmail, form.ContactPhone, form.ContactURL}
|
||
for _, v := range vals {
|
||
if v != nil && strings.TrimSpace(*v) != "" {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func deref(v *string, fallback string) string {
|
||
if v == nil || *v == "" {
|
||
return fallback
|
||
}
|
||
return *v
|
||
}
|
||
|
||
var replacements = map[rune]string{
|
||
'ā': "a", 'Ā': "A", 'ē': "e", 'Ē': "E", 'ī': "i", 'Ī': "I",
|
||
'ō': "o", 'Ō': "O", 'ū': "u", 'Ū': "U",
|
||
'‘': "'", '’': "'", '“': `"`, '”': `"`,
|
||
'–': "-", '—': "-", '…': "...", '\u00a0': " ",
|
||
}
|
||
|
||
// sanitize maps text to the WinAnsi/Latin-1 range supported by the built-in
|
||
// PDF fonts. Characters outside that range are transliterated or replaced.
|
||
func sanitize(s string) string {
|
||
return strings.Map(func(r rune) rune {
|
||
if r >= 32 && r <= 255 {
|
||
return r
|
||
}
|
||
return '?'
|
||
}, replaceRunes(s))
|
||
}
|
||
|
||
func replaceRunes(s string) string {
|
||
var b strings.Builder
|
||
for _, r := range s {
|
||
if rep, ok := replacements[r]; ok {
|
||
b.WriteString(rep)
|
||
continue
|
||
}
|
||
b.WriteRune(r)
|
||
}
|
||
return b.String()
|
||
}
|