first commit

main
Paul Ngo 3 days ago
commit 79ca7ab62a
Signed by: Paul Ngo
GPG Key ID: CB758CE7AD528CB9

BIN
.DS_Store vendored

Binary file not shown.

3
.gitignore vendored

@ -0,0 +1,3 @@
*.pdf
*.tsv
*.csv

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

@ -0,0 +1,50 @@
# PDF generation End of Term Surveys
This is how you can generate PDFs for End of Term Surveys. Some examples are provided in examples/
## Usage
### If you do not want emoji support:
```
pip install fpdf2
```
Use `generate_pdf.py` to convert a tsv to a pdf.
### If you do want emoji support:
```
pip install playwright jinja2
playwright install
```
Use `generate_pdf_emoji_support.py` to convert a tsv to a pdf.
## Usage Examples
```
python generate_pdf.py -i "F2024 End of Term Survey Responses.tsv" -o "F24 End of Term Survey (Sorted by Locality).pdf"
```
```
python generate_pdf_emoji_support.py -i "F2024 End of Term Survey Responses.tsv" -o "F24 End of Term Survey (Sorted by Locality).pdf"
```
```
python generate_pdf.py -i "F2024 Fourth Term Survey Responses.tsv" -o "F24 Fourth Term Survey (Sorted by Locality).pdf"
```
```
python generate_pdf_emoji_support.py -i "F2024 Fourth Term Survey Responses.tsv" -o "F24 Fourth Term Survey (Sorted by Locality).pdf"
```
## Actual Usage Example
1. Find the original survey links that were shared with you from the training (to every trainee)
2. Go to the google sheet of responses (shared with only you) and add the correct headings based on the questions
- there are probably columns called "Column #". You should rename these according to the original survey
- they should be renamed to things like: "Please evaluate your progress in "Truth (e.g. study times, Bible reading, etc.)" for this past term (1-5, 5 indicating the most progress):"
- for comments about these, name them "Comments 1", "Comments 2", etc.
- at the end of Fourth Term Survey, it probably should be called "Further Comments"
3. Required columns: 'Timestamp', 'Email Address', 'Name (last, first):', 'Sending locality:'. WARNING: In previous terms, the column named "Name (last, first):" was called "Name (last, first): " with a space. You need to remove this space to make the output work.

BIN
examples/.DS_Store vendored

Binary file not shown.

@ -0,0 +1,99 @@
import csv
import re
import argparse
from fpdf import FPDF
def is_number(s):
try:
float(s)
return True
except ValueError:
return False
def ensure_colon(heading: str) -> str:
heading = heading.strip()
# If it already ends with a colon, don't add another one
if not heading.endswith(":"):
heading += ":"
return heading
def main():
parser = argparse.ArgumentParser(description="Generate a PDF report from a TSV input.")
parser.add_argument("-i", "--input_tsv", required=True, help="Path to the input TSV file.")
parser.add_argument("-o", "--output_pdf", required=True, help="Path to the output PDF file.")
args = parser.parse_args()
input_tsv = args.input_tsv
output_pdf = args.output_pdf
# Read the TSV data
with open(input_tsv, 'r', encoding='utf-8-sig') as f:
reader = csv.DictReader(f, delimiter='\t')
rows = sorted(rows, key=lambda r: r.get("Sending locality:", "").strip())
pdf = FPDF(format='Letter', unit='pt')
pdf.set_auto_page_break(auto=True, margin=50)
# Add all required font styles
pdf.add_font("DejaVu", "", "DejaVuSansCondensed.ttf")
pdf.add_font("DejaVu", "B", "DejaVuSansCondensed-Bold.ttf")
pdf.add_font("DejaVu", "I", "DejaVuSansCondensed-Oblique.ttf")
pdf.add_font("DejaVu", "BI", "DejaVuSansCondensed-BoldOblique.ttf")
pdf.set_font("DejaVu", size=12)
skip_cols = {'Timestamp', 'Email Address', 'Name (last, first):', 'Sending locality:'}
for row in rows:
pdf.add_page()
name = row.get('Name (last, first):', '').strip()
sending_locality = row.get('Sending locality:', '').strip()
# Header section
pdf.set_font("DejaVu", '', 14)
pdf.cell(0, 20, name, new_x="LMARGIN", new_y="NEXT")
pdf.set_font("DejaVu", '', 12)
pdf.cell(0, 20, f"Sending Locality: {sending_locality}", new_x="LMARGIN", new_y="NEXT")
for col in row.keys():
if col in skip_cols:
continue
col_title = col.strip()
value = row[col].strip()
if not value:
continue
comment_match = re.match(r'^Comments\s\d+$', col_title, re.IGNORECASE)
if comment_match:
# Comments
pdf.set_font("DejaVu", 'I', 12)
pdf.write(16, "Comments: ")
pdf.set_font("DejaVu", '', 12)
pdf.multi_cell(0, 16, value, new_x="LMARGIN", new_y="NEXT")
pdf.ln(10)
continue
# Non-comment columns
pdf.set_font("DejaVu", 'B', 12)
if is_number(value):
# Numeric value
col_title = ensure_colon(col_title)
pdf.multi_cell(0, 16, f"{col_title} {value}", new_x="LMARGIN", new_y="NEXT")
pdf.ln(10)
else:
# Non-numeric value
col_title = ensure_colon(col_title)
pdf.multi_cell(0, 16, col_title, new_x="LMARGIN", new_y="NEXT")
pdf.set_font("DejaVu", '', 12)
pdf.multi_cell(0, 16, value, new_x="LMARGIN", new_y="NEXT")
pdf.ln(10)
pdf.set_font("DejaVu", 'B', 12)
pdf.output(output_pdf)
if __name__ == "__main__":
main()

@ -0,0 +1,236 @@
import argparse
import csv
import re
import tempfile
import jinja2
import os
from playwright.sync_api import sync_playwright
def is_number(s):
"""Returns True if s can be interpreted as a float."""
try:
float(s)
return True
except ValueError:
return False
def ensure_colon(heading: str) -> str:
"""Append ':' only if heading does not already end with one."""
heading = heading.strip()
if not heading.endswith(":"):
heading += ":"
return heading
def main():
parser = argparse.ArgumentParser(description="Generate a PDF report from a TSV input using Playwright.")
parser.add_argument("-i", "--input_tsv", required=True, help="Path to the input TSV file.")
parser.add_argument("-o", "--output_pdf", required=True, help="Path to the output PDF file.")
parser.add_argument("--browser", choices=["chromium", "firefox", "webkit"], default="chromium",
help="Which browser engine to use (default: chromium).")
args = parser.parse_args()
input_tsv = args.input_tsv
output_pdf = args.output_pdf
# 1. Read the TSV data
with open(input_tsv, 'r', encoding='utf-8-sig') as f:
reader = csv.DictReader(f, delimiter='\t')
rows = list(reader)
# 2. Sort rows by sending locality
rows = sorted(rows, key=lambda r: r.get("Sending locality:", "").strip())
# Known columns to skip
skip_cols = {
'Timestamp',
'Email Address',
'Name (last, first):',
'Sending locality:'
}
# 3. Process each row into a data structure for Jinja2
processed_rows = []
for row in rows:
name = row.get('Name (last, first):', '').strip()
locality = row.get('Sending locality:', '').strip()
q_and_a = []
for col in row.keys():
if col in skip_cols:
continue
col_title = col.strip()
value = row[col].strip()
if not value:
continue
# Check if "Comments" column
if re.match(r'^Comments\s\d+$', col_title, re.IGNORECASE):
# e.g. "Comments 1" => italic "Comments:"
q_and_a.append({
"type": "comments",
"label": "Comments",
"value": value
})
else:
# Normal question
if is_number(value):
# Numeric => "Truth: 4" on one line
q_and_a.append({
"type": "inline",
"label": ensure_colon(col_title),
"value": value
})
else:
# Non-numeric => label on one line, answer on next
q_and_a.append({
"type": "block",
"label": col_title,
"value": value
})
processed_rows.append({
"name": name,
"locality": locality,
"entries": q_and_a
})
# 4. Create an HTML template (Jinja2)
# We'll use page-break-after so each row is on a new PDF page.
html_template_str = r"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
""" + rf"""
<title>{os.path.basename(output_pdf)}</title>
""" + r"""
<style>
@page {
size: Letter;
margin: 50px;
}
body {
font-family: sans-serif;
font-size: 12pt;
line-height: 1.3; /* Slightly tighter than 1.4 */
margin: 0;
padding: 0;
}
.trainee-page {
margin-bottom: 1em;
page-break-after: always; /* Force new page for each trainee */
}
h1 {
font-size: 16pt;
margin: 0 0 0.3em 0; /* reduced bottom margin */
}
h2 {
font-size: 14pt;
margin: 0 0 0.7em 0; /* reduced bottom margin */
}
/* General paragraph spacing reduced */
p {
margin: 0.2em 0; /* Very small vertical margin */
white-space: pre-wrap; /* preserve any line breaks from the data */
}
.comments-label {
font-style: italic;
margin-top: 0.4em; /* smaller gap before comments label */
}
.bold {
font-weight: bold;
}
.italic {
font-style: italic;
}
.inline-answer {
margin-top: 0.4em; /* smaller top margin */
margin-bottom: 0.4em; /* smaller bottom margin */
}
.block-label {
font-weight: bold;
margin-top: 0.4em; /* smaller top margin */
margin-bottom: 0.1em; /* smaller bottom margin */
}
.block-value {
margin-left: 1em;
margin-bottom: 0.2em; /* smaller bottom margin */
}
</style>
</head>
<body>
{% for row in rows %}
<div class="trainee-page">
<!-- Name first -->
<h1>{{ row.name }}</h1>
<!-- Locality second -->
<h2>Sending Locality: {{ row.locality }}</h2>
{% for entry in row.entries %}
{% if entry.type == "comments" %}
<!-- Comments label in italic, value in normal font -->
<p class="comments-label">Comments:</p>
<p>{{ entry.value }}</p>
{% elif entry.type == "inline" %}
<!-- Numeric: label + value on one line -->
<p class="inline-answer">
<span class="bold">{{ entry.label }}</span> {{ entry.value }}
</p>
{% else %}
<!-- label on one line, value on next -->
<p class="block-label">{{ entry.label }}</p>
<p class="block-value">{{ entry.value }}</p>
{% endif %}
{% endfor %}
</div>
{% endfor %}
</body>
</html>
"""
# 5. Render the template with Jinja2
template = jinja2.Template(html_template_str)
rendered_html = template.render(rows=processed_rows)
# 6. Convert the HTML to PDF using Playwright
# We'll create a temporary HTML file, open it in a headless browser, and save as PDF.
with tempfile.NamedTemporaryFile(suffix=".html", delete=False) as tmp_file:
tmp_html_path = tmp_file.name
tmp_file.write(rendered_html.encode("utf-8"))
tmp_file.flush()
with sync_playwright() as p:
# launch the selected browser (chromium/firefox/webkit)
browser = p.__getattribute__(args.browser).launch()
context = browser.new_context()
page = context.new_page()
# Load the local HTML file
page.goto(f"file://{tmp_html_path}")
# PDF Options: letter format, etc.
# For more options, see: https://playwright.dev/python/docs/api/class-page#pagepdfoptions
page.pdf(
path=output_pdf,
format="letter",
margin={"top": "0.75in", "right": "0.75in", "bottom": "0.75in", "left": "0.75in"}
)
browser.close()
if __name__ == "__main__":
main()
Loading…
Cancel
Save