first commit
commit
79ca7ab62a
@ -0,0 +1,3 @@
|
|||||||
|
*.pdf
|
||||||
|
*.tsv
|
||||||
|
*.csv
|
Binary file not shown.
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.
|
||||||
|
|
||||||
|
|
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…
Reference in New Issue