// 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() }