Spaces:
Sleeping
Sleeping
nicoaspra commited on
Commit ·
e840a71
1
Parent(s): 11fa0df
initial commit
Browse files- .gitignore +1 -0
- README.md +4 -2
- index.html +0 -19
- index_card_merger.py +30 -0
- style.css +0 -28
- test.ipynb +117 -0
- ui_PDF-booklet-tiler.py +320 -0
- ui_index_card_merger.py +320 -0
.gitignore
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
.DS_Store
|
README.md
CHANGED
|
@@ -3,8 +3,10 @@ title: PDF Page Tiler
|
|
| 3 |
emoji: ⚡
|
| 4 |
colorFrom: yellow
|
| 5 |
colorTo: red
|
| 6 |
-
sdk:
|
|
|
|
|
|
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
| 3 |
emoji: ⚡
|
| 4 |
colorFrom: yellow
|
| 5 |
colorTo: red
|
| 6 |
+
sdk: streamlit
|
| 7 |
+
sdk_version: 1.40.1
|
| 8 |
+
app_file: ui_PDF_booklet.py
|
| 9 |
pinned: false
|
| 10 |
---
|
| 11 |
|
| 12 |
+
|
index.html
DELETED
|
@@ -1,19 +0,0 @@
|
|
| 1 |
-
<!doctype html>
|
| 2 |
-
<html>
|
| 3 |
-
<head>
|
| 4 |
-
<meta charset="utf-8" />
|
| 5 |
-
<meta name="viewport" content="width=device-width" />
|
| 6 |
-
<title>My static Space</title>
|
| 7 |
-
<link rel="stylesheet" href="style.css" />
|
| 8 |
-
</head>
|
| 9 |
-
<body>
|
| 10 |
-
<div class="card">
|
| 11 |
-
<h1>Welcome to your static Space!</h1>
|
| 12 |
-
<p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
|
| 13 |
-
<p>
|
| 14 |
-
Also don't forget to check the
|
| 15 |
-
<a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
|
| 16 |
-
</p>
|
| 17 |
-
</div>
|
| 18 |
-
</body>
|
| 19 |
-
</html>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
index_card_merger.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import PyPDF2
|
| 2 |
+
from pdf2image import convert_from_path
|
| 3 |
+
from PIL import Image
|
| 4 |
+
|
| 5 |
+
# Step 1: Convert PDF pages to images
|
| 6 |
+
pdf_path = '2425A.pdf'
|
| 7 |
+
# pdf_path = 'index_cards.pdf'
|
| 8 |
+
images = convert_from_path(pdf_path)
|
| 9 |
+
|
| 10 |
+
# Step 2: Define the layout for the images (2x4 grid for 8 pages)
|
| 11 |
+
rows, cols = 2, 4 # 2 rows, 4 columns
|
| 12 |
+
thumbnail_width, thumbnail_height = 200, 300 # Thumbnail size for each page
|
| 13 |
+
|
| 14 |
+
# Step 3: Create a blank canvas for the final merged image
|
| 15 |
+
merged_image_width = thumbnail_width * cols
|
| 16 |
+
merged_image_height = thumbnail_height * rows
|
| 17 |
+
merged_image = Image.new('RGB', (merged_image_width, merged_image_height))
|
| 18 |
+
|
| 19 |
+
# Step 4: Resize and paste each image onto the merged image
|
| 20 |
+
for i, img in enumerate(images[:8]): # Only process first 8 pages
|
| 21 |
+
img.thumbnail((thumbnail_width, thumbnail_height))
|
| 22 |
+
x = (i % cols) * thumbnail_width
|
| 23 |
+
y = (i // cols) * thumbnail_height
|
| 24 |
+
merged_image.paste(img, (x, y))
|
| 25 |
+
|
| 26 |
+
# Step 5: Save the merged image
|
| 27 |
+
merged_image.save('merged_image.png')
|
| 28 |
+
|
| 29 |
+
# If you want to save the result back to PDF
|
| 30 |
+
merged_image.save('merged_image.pdf', 'PDF', resolution=100.0)
|
style.css
DELETED
|
@@ -1,28 +0,0 @@
|
|
| 1 |
-
body {
|
| 2 |
-
padding: 2rem;
|
| 3 |
-
font-family: -apple-system, BlinkMacSystemFont, "Arial", sans-serif;
|
| 4 |
-
}
|
| 5 |
-
|
| 6 |
-
h1 {
|
| 7 |
-
font-size: 16px;
|
| 8 |
-
margin-top: 0;
|
| 9 |
-
}
|
| 10 |
-
|
| 11 |
-
p {
|
| 12 |
-
color: rgb(107, 114, 128);
|
| 13 |
-
font-size: 15px;
|
| 14 |
-
margin-bottom: 10px;
|
| 15 |
-
margin-top: 5px;
|
| 16 |
-
}
|
| 17 |
-
|
| 18 |
-
.card {
|
| 19 |
-
max-width: 620px;
|
| 20 |
-
margin: 0 auto;
|
| 21 |
-
padding: 16px;
|
| 22 |
-
border: 1px solid lightgray;
|
| 23 |
-
border-radius: 16px;
|
| 24 |
-
}
|
| 25 |
-
|
| 26 |
-
.card p:last-child {
|
| 27 |
-
margin-bottom: 0;
|
| 28 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
test.ipynb
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"cells": [
|
| 3 |
+
{
|
| 4 |
+
"cell_type": "code",
|
| 5 |
+
"execution_count": 4,
|
| 6 |
+
"metadata": {},
|
| 7 |
+
"outputs": [
|
| 8 |
+
{
|
| 9 |
+
"name": "stdout",
|
| 10 |
+
"output_type": "stream",
|
| 11 |
+
"text": [
|
| 12 |
+
"All pages merged into PDFs/final_merged_document_with_blank_pages.pdf\n"
|
| 13 |
+
]
|
| 14 |
+
}
|
| 15 |
+
],
|
| 16 |
+
"source": [
|
| 17 |
+
"import math\n",
|
| 18 |
+
"import io\n",
|
| 19 |
+
"from pdf2image import convert_from_path\n",
|
| 20 |
+
"from PIL import Image\n",
|
| 21 |
+
"from PyPDF2 import PdfMerger\n",
|
| 22 |
+
"\n",
|
| 23 |
+
"# Path to the blank page PDF\n",
|
| 24 |
+
"blank_page_path = 'PDFs/blank.pdf'\n",
|
| 25 |
+
"\n",
|
| 26 |
+
"# Step 1: Convert PDF pages to high-quality images (set a higher DPI)\n",
|
| 27 |
+
"pdf_path = 'PDFs/index_cards.pdf'\n",
|
| 28 |
+
"images = convert_from_path(pdf_path, dpi=300) # Increase DPI for better image quality\n",
|
| 29 |
+
"\n",
|
| 30 |
+
"# Step 2: Define the layout for the images (2x4 grid for 8 pages in landscape)\n",
|
| 31 |
+
"rows, cols = 2, 4 # 2 rows, 4 columns for landscape orientation\n",
|
| 32 |
+
"thumbnail_width, thumbnail_height = 3600 // cols, 2550 // rows # Adjust the thumbnail size for landscape\n",
|
| 33 |
+
"\n",
|
| 34 |
+
"# Step 3: Prepare for multiple merged pages if there are more than 8 pages\n",
|
| 35 |
+
"num_pages = len(images)\n",
|
| 36 |
+
"pages_per_output = rows * cols # 8 images per merged page (2x4 grid)\n",
|
| 37 |
+
"\n",
|
| 38 |
+
"# Calculate how many blank pages are needed\n",
|
| 39 |
+
"remainder = num_pages % pages_per_output\n",
|
| 40 |
+
"if remainder > 0:\n",
|
| 41 |
+
" blank_pages_needed = pages_per_output - remainder # How many blank pages are required\n",
|
| 42 |
+
"else:\n",
|
| 43 |
+
" blank_pages_needed = 0\n",
|
| 44 |
+
"\n",
|
| 45 |
+
"# Step 4: Add the necessary number of blank images (blank pages)\n",
|
| 46 |
+
"if blank_pages_needed > 0:\n",
|
| 47 |
+
" blank_image = convert_from_path(blank_page_path, dpi=300)[0] # Convert blank.pdf to an image\n",
|
| 48 |
+
" images.extend([blank_image] * blank_pages_needed) # Add blank images to fill the remaining slots\n",
|
| 49 |
+
"\n",
|
| 50 |
+
"# Step 5: Process images in groups of 8 and create separate merged pages\n",
|
| 51 |
+
"num_output_pages = math.ceil(len(images) / pages_per_output) # Total number of output PDF pages\n",
|
| 52 |
+
"\n",
|
| 53 |
+
"# Create a PdfMerger instance\n",
|
| 54 |
+
"merger = PdfMerger()\n",
|
| 55 |
+
"\n",
|
| 56 |
+
"for page_num in range(num_output_pages):\n",
|
| 57 |
+
" # Create a new blank canvas for each output page\n",
|
| 58 |
+
" merged_image = Image.new('RGB', (3600, 2550)) # 12 inches by 8.5 inches at 300 DPI\n",
|
| 59 |
+
"\n",
|
| 60 |
+
" # Calculate the starting and ending image index for this output page\n",
|
| 61 |
+
" start_index = page_num * pages_per_output\n",
|
| 62 |
+
" end_index = min(start_index + pages_per_output, len(images)) # Ensure we don't exceed the image count\n",
|
| 63 |
+
"\n",
|
| 64 |
+
" # # Top to Bottom\n",
|
| 65 |
+
" # for i, img in enumerate(images[start_index:end_index]):\n",
|
| 66 |
+
" # img = img.resize((thumbnail_width, thumbnail_height), Image.Resampling.LANCZOS) # High-quality resizing\n",
|
| 67 |
+
" # x = (i % cols) * thumbnail_width\n",
|
| 68 |
+
" # y = (i // cols) * thumbnail_height\n",
|
| 69 |
+
" # merged_image.paste(img, (x, y))\n",
|
| 70 |
+
"\n",
|
| 71 |
+
" # Left to Right\n",
|
| 72 |
+
" for i, img in enumerate(images[start_index:end_index]):\n",
|
| 73 |
+
" img = img.resize((thumbnail_width, thumbnail_height), Image.Resampling.LANCZOS) # High-quality resizing\n",
|
| 74 |
+
" x = (i // rows) * thumbnail_width\n",
|
| 75 |
+
" y = (i % rows) * thumbnail_height\n",
|
| 76 |
+
" merged_image.paste(img, (x, y))\n",
|
| 77 |
+
"\n",
|
| 78 |
+
" # Save the merged image to an in-memory buffer\n",
|
| 79 |
+
" pdf_bytes = io.BytesIO()\n",
|
| 80 |
+
" merged_image.save(pdf_bytes, format='PDF', resolution=300)\n",
|
| 81 |
+
" pdf_bytes.seek(0) # Move the pointer to the start of the BytesIO buffer\n",
|
| 82 |
+
"\n",
|
| 83 |
+
" # Append the in-memory PDF to the PdfMerger\n",
|
| 84 |
+
" merger.append(pdf_bytes)\n",
|
| 85 |
+
"\n",
|
| 86 |
+
"# Step 6: Save the final combined PDF\n",
|
| 87 |
+
"final_output_path = \"PDFs/final_merged_document_with_blank_pages.pdf\"\n",
|
| 88 |
+
"with open(final_output_path, 'wb') as f:\n",
|
| 89 |
+
" merger.write(f)\n",
|
| 90 |
+
"merger.close()\n",
|
| 91 |
+
"\n",
|
| 92 |
+
"print(f\"All pages merged into {final_output_path}\")"
|
| 93 |
+
]
|
| 94 |
+
}
|
| 95 |
+
],
|
| 96 |
+
"metadata": {
|
| 97 |
+
"kernelspec": {
|
| 98 |
+
"display_name": "3.11.11",
|
| 99 |
+
"language": "python",
|
| 100 |
+
"name": "python3"
|
| 101 |
+
},
|
| 102 |
+
"language_info": {
|
| 103 |
+
"codemirror_mode": {
|
| 104 |
+
"name": "ipython",
|
| 105 |
+
"version": 3
|
| 106 |
+
},
|
| 107 |
+
"file_extension": ".py",
|
| 108 |
+
"mimetype": "text/x-python",
|
| 109 |
+
"name": "python",
|
| 110 |
+
"nbconvert_exporter": "python",
|
| 111 |
+
"pygments_lexer": "ipython3",
|
| 112 |
+
"version": "3.11.11"
|
| 113 |
+
}
|
| 114 |
+
},
|
| 115 |
+
"nbformat": 4,
|
| 116 |
+
"nbformat_minor": 2
|
| 117 |
+
}
|
ui_PDF-booklet-tiler.py
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# app.py
|
| 2 |
+
from io import BytesIO
|
| 3 |
+
from itertools import islice
|
| 4 |
+
from typing import Iterable, List, Tuple
|
| 5 |
+
|
| 6 |
+
import streamlit as st
|
| 7 |
+
from PIL import Image, ImageOps, ImageDraw
|
| 8 |
+
|
| 9 |
+
from pdf2image import convert_from_bytes, pdfinfo_from_bytes
|
| 10 |
+
from pdf2image.exceptions import PDFInfoNotInstalledError, PDFPageCountError, PDFSyntaxError
|
| 11 |
+
|
| 12 |
+
# ─────────────────────────────
|
| 13 |
+
# Page config
|
| 14 |
+
# ─────────────────────────────
|
| 15 |
+
st.set_page_config(page_title="Class Card Layout Generator: PDF Page Tiler", page_icon="🧩", layout="wide")
|
| 16 |
+
st.title("Class Card Layout Generator: PDF Page Tiler")
|
| 17 |
+
# st.caption("Designed for faculty: simplify class card printing with customizable layouts that let you arrange, preview, and export class cards in seconds.")
|
| 18 |
+
|
| 19 |
+
st.markdown("""
|
| 20 |
+
**This tool streamlines the process of preparing class cards for printing.**
|
| 21 |
+
It takes a single PDF of class cards and arranges the pages into customizable sheet layouts. With options for paper size, rows × columns, and margins, you can easily generate print-ready files tailored to your needs.
|
| 22 |
+
|
| 23 |
+
**Ideal for faculty and educators,** this tool makes class card preparation faster, more efficient, and professional-looking. You can preview the layout instantly and export the results as PDFs or images, ready for distribution.
|
| 24 |
+
""")
|
| 25 |
+
|
| 26 |
+
st.markdown("### Instructions")
|
| 27 |
+
st.markdown("""
|
| 28 |
+
1. **Upload** a single PDF document containing your class cards.
|
| 29 |
+
2. **Set** your preferred layout in the sidebar (paper size, rows × columns, margins, gutter).
|
| 30 |
+
3. **Adjust** render DPI for print quality (300 recommended, 600 for extra sharp output).
|
| 31 |
+
4. **Choose** which pages to include (all pages, first *n* pages, or a range).
|
| 32 |
+
5. **Preview** the sheet using the preview slider.
|
| 33 |
+
6. **Download** your output as a multi-page PDF (best for printing), a single PNG, or a ZIP of PNGs.
|
| 34 |
+
""")
|
| 35 |
+
|
| 36 |
+
# ─────────────────────────────
|
| 37 |
+
# Sidebar
|
| 38 |
+
# ─────────────────────────────
|
| 39 |
+
with st.sidebar:
|
| 40 |
+
st.header("1) File")
|
| 41 |
+
pdf_file = st.file_uploader("Upload PDF", type=["pdf"])
|
| 42 |
+
|
| 43 |
+
st.header("2) Render")
|
| 44 |
+
render_dpi = st.slider("Render DPI (PDF ➜ images and sheet canvas DPI)", 96, 600, 300, step=12)
|
| 45 |
+
|
| 46 |
+
st.header("3) Sheet layout")
|
| 47 |
+
paper_preset = st.selectbox(
|
| 48 |
+
"Paper size preset",
|
| 49 |
+
[
|
| 50 |
+
"Short (8.5 × 11 in)",
|
| 51 |
+
"Folio / Legal (8.5 × 13 in)",
|
| 52 |
+
"A4 (8.27 × 11.69 in)",
|
| 53 |
+
"A5 (5.83 × 8.27 in)",
|
| 54 |
+
"A6 (4.13 × 5.83 in)",
|
| 55 |
+
"Custom Size (in)",
|
| 56 |
+
],
|
| 57 |
+
index=0,
|
| 58 |
+
)
|
| 59 |
+
orientation = st.radio("Orientation", ["Portrait", "Landscape"], horizontal=True)
|
| 60 |
+
|
| 61 |
+
# Custom size fields side-by-side
|
| 62 |
+
custom_w_in = custom_h_in = None
|
| 63 |
+
if paper_preset == "Custom Size (in)":
|
| 64 |
+
cw, ch = st.columns(2)
|
| 65 |
+
with cw:
|
| 66 |
+
custom_w_in = st.number_input("Width (in)", 1.0, 30.0, 8.5, step=0.01)
|
| 67 |
+
with ch:
|
| 68 |
+
custom_h_in = st.number_input("Height (in)", 1.0, 30.0, 11.0, step=0.01)
|
| 69 |
+
|
| 70 |
+
# Margins & gutter side-by-side
|
| 71 |
+
mg, gt = st.columns(2)
|
| 72 |
+
with mg:
|
| 73 |
+
margin_in = st.number_input("Margins (in)", 0.0, 2.0, 0.00, step=0.05)
|
| 74 |
+
with gt:
|
| 75 |
+
gutter_in = st.number_input("Gutter (in)", 0.0, 1.0, 0.00, step=0.02)
|
| 76 |
+
|
| 77 |
+
# Rows & columns side-by-side
|
| 78 |
+
rc1, rc2 = st.columns(2)
|
| 79 |
+
with rc1:
|
| 80 |
+
rows = st.number_input("Rows", 1, 12, 2, step=1)
|
| 81 |
+
with rc2:
|
| 82 |
+
cols = st.number_input("Columns", 1, 12, 4, step=1)
|
| 83 |
+
|
| 84 |
+
st.header("4) Fit mode")
|
| 85 |
+
fit_mode = st.selectbox("Fit page into each cell", ["Contain (no crop)", "Cover (fill, may crop)"])
|
| 86 |
+
draw_cell_borders = st.checkbox("Debug: show cell borders", value=False)
|
| 87 |
+
|
| 88 |
+
status = st.empty()
|
| 89 |
+
controls = st.container()
|
| 90 |
+
preview_slot = st.container()
|
| 91 |
+
|
| 92 |
+
# ─────────────────────────────
|
| 93 |
+
# Helpers
|
| 94 |
+
# ─────────────────────────────
|
| 95 |
+
PRESETS_INCHES: dict[str, Tuple[float, float]] = {
|
| 96 |
+
"Short (8.5 × 11 in)": (8.5, 11.0),
|
| 97 |
+
"Folio / Legal (8.5 × 13 in)": (8.5, 13.0),
|
| 98 |
+
"A4 (8.27 × 11.69 in)": (8.27, 11.69),
|
| 99 |
+
"A5 (5.83 × 8.27 in)": (5.83, 8.27),
|
| 100 |
+
"A6 (4.13 × 5.83 in)": (4.13, 5.83),
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
def resolve_paper_inches(preset: str, orientation: str, custom_w: float | None, custom_h: float | None) -> Tuple[float, float]:
|
| 104 |
+
if preset == "Custom Size (in)":
|
| 105 |
+
w, h = float(custom_w), float(custom_h)
|
| 106 |
+
else:
|
| 107 |
+
w, h = PRESETS_INCHES[preset]
|
| 108 |
+
if orientation == "Landscape":
|
| 109 |
+
w, h = h, w
|
| 110 |
+
return w, h
|
| 111 |
+
|
| 112 |
+
def px(value_in_inches: float, dpi: int) -> int:
|
| 113 |
+
return max(1, int(round(value_in_inches * dpi)))
|
| 114 |
+
|
| 115 |
+
def make_canvas_px(w_px: int, h_px: int, color=(255, 255, 255)) -> Image.Image:
|
| 116 |
+
return Image.new("RGB", (w_px, h_px), color=color)
|
| 117 |
+
|
| 118 |
+
def fit_image(img: Image.Image, w: int, h: int, mode: str) -> Image.Image:
|
| 119 |
+
if mode.startswith("Contain"):
|
| 120 |
+
return ImageOps.contain(img, (w, h), method=Image.LANCZOS)
|
| 121 |
+
ratio = max(w / img.width, h / img.height)
|
| 122 |
+
new_w, new_h = int(img.width * ratio), int(img.height * ratio)
|
| 123 |
+
resized = img.resize((new_w, new_h), Image.LANCZOS)
|
| 124 |
+
x0 = (new_w - w) // 2
|
| 125 |
+
y0 = (new_h - h) // 2
|
| 126 |
+
return resized.crop((x0, y0, x0 + w, y0 + h))
|
| 127 |
+
|
| 128 |
+
def chunk_iterable(iterable: Iterable, size: int):
|
| 129 |
+
it = iter(iterable)
|
| 130 |
+
while True:
|
| 131 |
+
chunk = list(islice(it, size))
|
| 132 |
+
if not chunk:
|
| 133 |
+
break
|
| 134 |
+
yield chunk
|
| 135 |
+
|
| 136 |
+
def compose_sheet(page_imgs: List[Image.Image], rows: int, cols: int,
|
| 137 |
+
sheet_w_px: int, sheet_h_px: int, margin_px: int, gutter_px: int,
|
| 138 |
+
fit_mode: str, show_borders: bool = False) -> Image.Image:
|
| 139 |
+
inner_w = sheet_w_px - 2 * margin_px
|
| 140 |
+
inner_h = sheet_h_px - 2 * margin_px
|
| 141 |
+
cell_w = (inner_w - (cols - 1) * gutter_px) // cols
|
| 142 |
+
cell_h = (inner_h - (rows - 1) * gutter_px) // rows
|
| 143 |
+
|
| 144 |
+
canvas = make_canvas_px(sheet_w_px, sheet_h_px, color=(255, 255, 255))
|
| 145 |
+
draw = ImageDraw.Draw(canvas)
|
| 146 |
+
|
| 147 |
+
for idx, img in enumerate(page_imgs[: rows * cols]):
|
| 148 |
+
r, c = divmod(idx, cols)
|
| 149 |
+
x = margin_px + c * (cell_w + gutter_px)
|
| 150 |
+
y = margin_px + r * (cell_h + gutter_px)
|
| 151 |
+
fitted = fit_image(img, cell_w, cell_h, fit_mode)
|
| 152 |
+
canvas.paste(fitted, (x, y))
|
| 153 |
+
if show_borders:
|
| 154 |
+
draw.rectangle([x, y, x + cell_w, y + cell_h], outline=(0, 0, 0), width=2)
|
| 155 |
+
|
| 156 |
+
return canvas
|
| 157 |
+
|
| 158 |
+
def build_sheets(images: List[Image.Image], rows: int, cols: int,
|
| 159 |
+
sheet_w_px: int, sheet_h_px: int, margin_px: int, gutter_px: int,
|
| 160 |
+
fit_mode: str, show_borders: bool = False) -> List[Image.Image]:
|
| 161 |
+
per_sheet = rows * cols
|
| 162 |
+
sheets: List[Image.Image] = []
|
| 163 |
+
for group in chunk_iterable(images, per_sheet):
|
| 164 |
+
sheet = compose_sheet(group, rows, cols, sheet_w_px, sheet_h_px,
|
| 165 |
+
margin_px, gutter_px, fit_mode, show_borders)
|
| 166 |
+
if sheet.mode != "RGB":
|
| 167 |
+
sheet = sheet.convert("RGB")
|
| 168 |
+
sheets.append(sheet)
|
| 169 |
+
return sheets
|
| 170 |
+
|
| 171 |
+
def sheets_to_pdf_bytes(sheets: List[Image.Image], dpi: int = 300) -> bytes:
|
| 172 |
+
buf = BytesIO()
|
| 173 |
+
if len(sheets) == 1:
|
| 174 |
+
sheets[0].save(buf, format="PDF", resolution=dpi)
|
| 175 |
+
else:
|
| 176 |
+
sheets[0].save(buf, format="PDF", save_all=True, append_images=sheets[1:], resolution=dpi)
|
| 177 |
+
buf.seek(0)
|
| 178 |
+
return buf.getvalue()
|
| 179 |
+
|
| 180 |
+
def to_png_bytes(img: Image.Image) -> bytes:
|
| 181 |
+
buf = BytesIO()
|
| 182 |
+
img.save(buf, "PNG")
|
| 183 |
+
buf.seek(0)
|
| 184 |
+
return buf.getvalue()
|
| 185 |
+
|
| 186 |
+
def build_zip_bytes(imgs: List[Image.Image]) -> bytes:
|
| 187 |
+
from zipfile import ZipFile, ZIP_DEFLATED
|
| 188 |
+
bio = BytesIO()
|
| 189 |
+
with ZipFile(bio, "w", ZIP_DEFLATED) as zf:
|
| 190 |
+
for i, im in enumerate(imgs, start=1):
|
| 191 |
+
buf = BytesIO()
|
| 192 |
+
im.save(buf, "PNG")
|
| 193 |
+
buf.seek(0)
|
| 194 |
+
zf.writestr(f"sheet_{i}.png", buf.read())
|
| 195 |
+
bio.seek(0)
|
| 196 |
+
return bio.getvalue()
|
| 197 |
+
|
| 198 |
+
# ─────────────────────────────
|
| 199 |
+
# Main
|
| 200 |
+
# ─────────────────────────────
|
| 201 |
+
if not pdf_file:
|
| 202 |
+
status.info("Upload a PDF in the sidebar to begin.")
|
| 203 |
+
st.stop()
|
| 204 |
+
|
| 205 |
+
try:
|
| 206 |
+
pdf_bytes = pdf_file.read()
|
| 207 |
+
status.info("Inspecting PDF and environment…")
|
| 208 |
+
try:
|
| 209 |
+
info = pdfinfo_from_bytes(pdf_bytes, poppler_path=None)
|
| 210 |
+
n_pages = int(info.get("Pages", 0))
|
| 211 |
+
st.write(f"**Detected {n_pages} page(s).**")
|
| 212 |
+
except PDFInfoNotInstalledError:
|
| 213 |
+
st.error(
|
| 214 |
+
"Poppler is not installed or not found on PATH. "
|
| 215 |
+
"Install it (macOS: `brew install poppler`, Ubuntu: `sudo apt-get install poppler-utils`, "
|
| 216 |
+
"Windows: download Poppler and add its `bin` folder to PATH)."
|
| 217 |
+
)
|
| 218 |
+
st.stop()
|
| 219 |
+
|
| 220 |
+
# Page selection
|
| 221 |
+
with controls:
|
| 222 |
+
st.subheader("Page selection")
|
| 223 |
+
mode = st.radio("Choose pages", ["All", "First n Pages", "Range"], horizontal=True, key="mode_radio")
|
| 224 |
+
first_n = None
|
| 225 |
+
fp = None
|
| 226 |
+
lp = None
|
| 227 |
+
if mode == "First n Pages":
|
| 228 |
+
first_n = st.number_input("First N pages", 1, n_pages, n_pages, step=1, key="firstn")
|
| 229 |
+
elif mode == "Range":
|
| 230 |
+
fp = st.number_input("First page (1-based)", 1, n_pages, 1, step=1, key="fp")
|
| 231 |
+
lp = st.number_input("Last page (inclusive)", 1, n_pages, n_pages, step=1, key="lp")
|
| 232 |
+
|
| 233 |
+
first_page, last_page = 1, None
|
| 234 |
+
if mode == "First n Pages" and first_n:
|
| 235 |
+
first_page, last_page = 1, int(first_n)
|
| 236 |
+
elif mode == "Range" and fp and lp:
|
| 237 |
+
first_page, last_page = int(fp), int(lp)
|
| 238 |
+
if last_page < first_page:
|
| 239 |
+
first_page, last_page = last_page, first_page
|
| 240 |
+
st.warning("Swapped page range so First ≤ Last.")
|
| 241 |
+
|
| 242 |
+
status.info("Rendering PDF pages ➜ images…")
|
| 243 |
+
images = convert_from_bytes(
|
| 244 |
+
pdf_bytes,
|
| 245 |
+
dpi=render_dpi,
|
| 246 |
+
first_page=first_page,
|
| 247 |
+
last_page=last_page,
|
| 248 |
+
poppler_path=None,
|
| 249 |
+
)
|
| 250 |
+
if not images:
|
| 251 |
+
st.error("No pages were rendered — check your page selection.")
|
| 252 |
+
st.stop()
|
| 253 |
+
|
| 254 |
+
# Resolve paper inches
|
| 255 |
+
w_in, h_in = resolve_paper_inches(paper_preset, orientation, custom_w_in, custom_h_in)
|
| 256 |
+
|
| 257 |
+
# Compute sheet pixel size
|
| 258 |
+
sheet_w_px = px(w_in, render_dpi)
|
| 259 |
+
sheet_h_px = px(h_in, render_dpi)
|
| 260 |
+
margin_px = px(margin_in, render_dpi)
|
| 261 |
+
gutter_px = px(gutter_in, render_dpi)
|
| 262 |
+
|
| 263 |
+
status.info("Composing sheets…")
|
| 264 |
+
sheets = build_sheets(
|
| 265 |
+
images=images,
|
| 266 |
+
rows=int(rows),
|
| 267 |
+
cols=int(cols),
|
| 268 |
+
sheet_w_px=sheet_w_px,
|
| 269 |
+
sheet_h_px=sheet_h_px,
|
| 270 |
+
margin_px=margin_px,
|
| 271 |
+
gutter_px=gutter_px,
|
| 272 |
+
fit_mode=fit_mode,
|
| 273 |
+
show_borders=draw_cell_borders,
|
| 274 |
+
)
|
| 275 |
+
status.success(f"Built {len(sheets)} sheet(s) at {render_dpi} DPI.")
|
| 276 |
+
|
| 277 |
+
# Preview slider
|
| 278 |
+
sel = st.slider("Preview sheet", 1, len(sheets), 1, step=1) if len(sheets) > 1 else 1
|
| 279 |
+
preview = sheets[sel - 1]
|
| 280 |
+
|
| 281 |
+
with preview_slot:
|
| 282 |
+
st.image(preview, caption=f"Preview (sheet {sel} of {len(sheets)})", use_container_width=True)
|
| 283 |
+
|
| 284 |
+
# Downloads
|
| 285 |
+
c1, c2, c3 = st.columns(3)
|
| 286 |
+
|
| 287 |
+
with c1:
|
| 288 |
+
pdf_bytes_all = sheets_to_pdf_bytes(sheets, dpi=render_dpi)
|
| 289 |
+
st.download_button(
|
| 290 |
+
"Download PDF (all sheets)",
|
| 291 |
+
data=pdf_bytes_all,
|
| 292 |
+
file_name="nup_all_sheets.pdf",
|
| 293 |
+
mime="application/pdf",
|
| 294 |
+
type="primary", # highlight main action
|
| 295 |
+
)
|
| 296 |
+
|
| 297 |
+
with c2:
|
| 298 |
+
st.download_button(
|
| 299 |
+
f"Download PNG (sheet {sel})",
|
| 300 |
+
data=to_png_bytes(preview),
|
| 301 |
+
file_name=f"sheet_{sel}.png",
|
| 302 |
+
mime="image/png",
|
| 303 |
+
)
|
| 304 |
+
|
| 305 |
+
with c3:
|
| 306 |
+
zip_bytes = build_zip_bytes(sheets)
|
| 307 |
+
st.download_button(
|
| 308 |
+
"Download ZIP (all sheets as PNG)",
|
| 309 |
+
data=zip_bytes,
|
| 310 |
+
file_name="nup_all_sheets_png.zip",
|
| 311 |
+
mime="application/zip",
|
| 312 |
+
)
|
| 313 |
+
|
| 314 |
+
except (PDFPageCountError, PDFSyntaxError):
|
| 315 |
+
st.error("The PDF seems corrupted or password-protected.")
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
# streamlit run 240818_index_card_merger/ui_index_card_merger.py
|
| 320 |
+
|
ui_index_card_merger.py
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# app.py
|
| 2 |
+
from io import BytesIO
|
| 3 |
+
from itertools import islice
|
| 4 |
+
from typing import Iterable, List, Tuple
|
| 5 |
+
|
| 6 |
+
import streamlit as st
|
| 7 |
+
from PIL import Image, ImageOps, ImageDraw
|
| 8 |
+
|
| 9 |
+
from pdf2image import convert_from_bytes, pdfinfo_from_bytes
|
| 10 |
+
from pdf2image.exceptions import PDFInfoNotInstalledError, PDFPageCountError, PDFSyntaxError
|
| 11 |
+
|
| 12 |
+
# ─────────────────────────────
|
| 13 |
+
# Page config
|
| 14 |
+
# ─────────────────────────────
|
| 15 |
+
st.set_page_config(page_title="Class Card Layout Generator: PDF Page Tiler", page_icon="🧩", layout="wide")
|
| 16 |
+
st.title("Class Card Layout Generator: PDF Page Tiler")
|
| 17 |
+
# st.caption("Designed for faculty: simplify class card printing with customizable layouts that let you arrange, preview, and export class cards in seconds.")
|
| 18 |
+
|
| 19 |
+
st.markdown("""
|
| 20 |
+
**This tool streamlines the process of preparing class cards for printing.**
|
| 21 |
+
It takes a single PDF of class cards and arranges the pages into customizable sheet layouts. With options for paper size, rows × columns, and margins, you can easily generate print-ready files tailored to your needs.
|
| 22 |
+
|
| 23 |
+
**Ideal for faculty and educators,** this tool makes class card preparation faster, more efficient, and professional-looking. You can preview the layout instantly and export the results as PDFs or images, ready for distribution.
|
| 24 |
+
""")
|
| 25 |
+
|
| 26 |
+
st.markdown("### Instructions")
|
| 27 |
+
st.markdown("""
|
| 28 |
+
1. **Upload** a single PDF document containing your class cards.
|
| 29 |
+
2. **Set** your preferred layout in the sidebar (paper size, rows × columns, margins, gutter).
|
| 30 |
+
3. **Adjust** render DPI for print quality (300 recommended, 600 for extra sharp output).
|
| 31 |
+
4. **Choose** which pages to include (all pages, first *n* pages, or a range).
|
| 32 |
+
5. **Preview** the sheet using the preview slider.
|
| 33 |
+
6. **Download** your output as a multi-page PDF (best for printing), a single PNG, or a ZIP of PNGs.
|
| 34 |
+
""")
|
| 35 |
+
|
| 36 |
+
# ─────────────────────────────
|
| 37 |
+
# Sidebar
|
| 38 |
+
# ─────────────────────────────
|
| 39 |
+
with st.sidebar:
|
| 40 |
+
st.header("1) File")
|
| 41 |
+
pdf_file = st.file_uploader("Upload PDF", type=["pdf"])
|
| 42 |
+
|
| 43 |
+
st.header("2) Render")
|
| 44 |
+
render_dpi = st.slider("Render DPI (PDF ➜ images and sheet canvas DPI)", 96, 600, 300, step=12)
|
| 45 |
+
|
| 46 |
+
st.header("3) Sheet layout")
|
| 47 |
+
paper_preset = st.selectbox(
|
| 48 |
+
"Paper size preset",
|
| 49 |
+
[
|
| 50 |
+
"Short (8.5 × 11 in)",
|
| 51 |
+
"Folio / Legal (8.5 × 13 in)",
|
| 52 |
+
"A4 (8.27 × 11.69 in)",
|
| 53 |
+
"A5 (5.83 × 8.27 in)",
|
| 54 |
+
"A6 (4.13 × 5.83 in)",
|
| 55 |
+
"Custom Size (in)",
|
| 56 |
+
],
|
| 57 |
+
index=0,
|
| 58 |
+
)
|
| 59 |
+
orientation = st.radio("Orientation", ["Portrait", "Landscape"], horizontal=True)
|
| 60 |
+
|
| 61 |
+
# Custom size fields side-by-side
|
| 62 |
+
custom_w_in = custom_h_in = None
|
| 63 |
+
if paper_preset == "Custom Size (in)":
|
| 64 |
+
cw, ch = st.columns(2)
|
| 65 |
+
with cw:
|
| 66 |
+
custom_w_in = st.number_input("Width (in)", 1.0, 30.0, 8.5, step=0.01)
|
| 67 |
+
with ch:
|
| 68 |
+
custom_h_in = st.number_input("Height (in)", 1.0, 30.0, 11.0, step=0.01)
|
| 69 |
+
|
| 70 |
+
# Margins & gutter side-by-side
|
| 71 |
+
mg, gt = st.columns(2)
|
| 72 |
+
with mg:
|
| 73 |
+
margin_in = st.number_input("Margins (in)", 0.0, 2.0, 0.00, step=0.05)
|
| 74 |
+
with gt:
|
| 75 |
+
gutter_in = st.number_input("Gutter (in)", 0.0, 1.0, 0.00, step=0.02)
|
| 76 |
+
|
| 77 |
+
# Rows & columns side-by-side
|
| 78 |
+
rc1, rc2 = st.columns(2)
|
| 79 |
+
with rc1:
|
| 80 |
+
rows = st.number_input("Rows", 1, 12, 2, step=1)
|
| 81 |
+
with rc2:
|
| 82 |
+
cols = st.number_input("Columns", 1, 12, 4, step=1)
|
| 83 |
+
|
| 84 |
+
st.header("4) Fit mode")
|
| 85 |
+
fit_mode = st.selectbox("Fit page into each cell", ["Contain (no crop)", "Cover (fill, may crop)"])
|
| 86 |
+
draw_cell_borders = st.checkbox("Debug: show cell borders", value=False)
|
| 87 |
+
|
| 88 |
+
status = st.empty()
|
| 89 |
+
controls = st.container()
|
| 90 |
+
preview_slot = st.container()
|
| 91 |
+
|
| 92 |
+
# ─────────────────────────────
|
| 93 |
+
# Helpers
|
| 94 |
+
# ─────────────────────────────
|
| 95 |
+
PRESETS_INCHES: dict[str, Tuple[float, float]] = {
|
| 96 |
+
"Short (8.5 × 11 in)": (8.5, 11.0),
|
| 97 |
+
"Folio / Legal (8.5 × 13 in)": (8.5, 13.0),
|
| 98 |
+
"A4 (8.27 × 11.69 in)": (8.27, 11.69),
|
| 99 |
+
"A5 (5.83 × 8.27 in)": (5.83, 8.27),
|
| 100 |
+
"A6 (4.13 × 5.83 in)": (4.13, 5.83),
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
def resolve_paper_inches(preset: str, orientation: str, custom_w: float | None, custom_h: float | None) -> Tuple[float, float]:
|
| 104 |
+
if preset == "Custom Size (in)":
|
| 105 |
+
w, h = float(custom_w), float(custom_h)
|
| 106 |
+
else:
|
| 107 |
+
w, h = PRESETS_INCHES[preset]
|
| 108 |
+
if orientation == "Landscape":
|
| 109 |
+
w, h = h, w
|
| 110 |
+
return w, h
|
| 111 |
+
|
| 112 |
+
def px(value_in_inches: float, dpi: int) -> int:
|
| 113 |
+
return max(1, int(round(value_in_inches * dpi)))
|
| 114 |
+
|
| 115 |
+
def make_canvas_px(w_px: int, h_px: int, color=(255, 255, 255)) -> Image.Image:
|
| 116 |
+
return Image.new("RGB", (w_px, h_px), color=color)
|
| 117 |
+
|
| 118 |
+
def fit_image(img: Image.Image, w: int, h: int, mode: str) -> Image.Image:
|
| 119 |
+
if mode.startswith("Contain"):
|
| 120 |
+
return ImageOps.contain(img, (w, h), method=Image.LANCZOS)
|
| 121 |
+
ratio = max(w / img.width, h / img.height)
|
| 122 |
+
new_w, new_h = int(img.width * ratio), int(img.height * ratio)
|
| 123 |
+
resized = img.resize((new_w, new_h), Image.LANCZOS)
|
| 124 |
+
x0 = (new_w - w) // 2
|
| 125 |
+
y0 = (new_h - h) // 2
|
| 126 |
+
return resized.crop((x0, y0, x0 + w, y0 + h))
|
| 127 |
+
|
| 128 |
+
def chunk_iterable(iterable: Iterable, size: int):
|
| 129 |
+
it = iter(iterable)
|
| 130 |
+
while True:
|
| 131 |
+
chunk = list(islice(it, size))
|
| 132 |
+
if not chunk:
|
| 133 |
+
break
|
| 134 |
+
yield chunk
|
| 135 |
+
|
| 136 |
+
def compose_sheet(page_imgs: List[Image.Image], rows: int, cols: int,
|
| 137 |
+
sheet_w_px: int, sheet_h_px: int, margin_px: int, gutter_px: int,
|
| 138 |
+
fit_mode: str, show_borders: bool = False) -> Image.Image:
|
| 139 |
+
inner_w = sheet_w_px - 2 * margin_px
|
| 140 |
+
inner_h = sheet_h_px - 2 * margin_px
|
| 141 |
+
cell_w = (inner_w - (cols - 1) * gutter_px) // cols
|
| 142 |
+
cell_h = (inner_h - (rows - 1) * gutter_px) // rows
|
| 143 |
+
|
| 144 |
+
canvas = make_canvas_px(sheet_w_px, sheet_h_px, color=(255, 255, 255))
|
| 145 |
+
draw = ImageDraw.Draw(canvas)
|
| 146 |
+
|
| 147 |
+
for idx, img in enumerate(page_imgs[: rows * cols]):
|
| 148 |
+
r, c = divmod(idx, cols)
|
| 149 |
+
x = margin_px + c * (cell_w + gutter_px)
|
| 150 |
+
y = margin_px + r * (cell_h + gutter_px)
|
| 151 |
+
fitted = fit_image(img, cell_w, cell_h, fit_mode)
|
| 152 |
+
canvas.paste(fitted, (x, y))
|
| 153 |
+
if show_borders:
|
| 154 |
+
draw.rectangle([x, y, x + cell_w, y + cell_h], outline=(0, 0, 0), width=2)
|
| 155 |
+
|
| 156 |
+
return canvas
|
| 157 |
+
|
| 158 |
+
def build_sheets(images: List[Image.Image], rows: int, cols: int,
|
| 159 |
+
sheet_w_px: int, sheet_h_px: int, margin_px: int, gutter_px: int,
|
| 160 |
+
fit_mode: str, show_borders: bool = False) -> List[Image.Image]:
|
| 161 |
+
per_sheet = rows * cols
|
| 162 |
+
sheets: List[Image.Image] = []
|
| 163 |
+
for group in chunk_iterable(images, per_sheet):
|
| 164 |
+
sheet = compose_sheet(group, rows, cols, sheet_w_px, sheet_h_px,
|
| 165 |
+
margin_px, gutter_px, fit_mode, show_borders)
|
| 166 |
+
if sheet.mode != "RGB":
|
| 167 |
+
sheet = sheet.convert("RGB")
|
| 168 |
+
sheets.append(sheet)
|
| 169 |
+
return sheets
|
| 170 |
+
|
| 171 |
+
def sheets_to_pdf_bytes(sheets: List[Image.Image], dpi: int = 300) -> bytes:
|
| 172 |
+
buf = BytesIO()
|
| 173 |
+
if len(sheets) == 1:
|
| 174 |
+
sheets[0].save(buf, format="PDF", resolution=dpi)
|
| 175 |
+
else:
|
| 176 |
+
sheets[0].save(buf, format="PDF", save_all=True, append_images=sheets[1:], resolution=dpi)
|
| 177 |
+
buf.seek(0)
|
| 178 |
+
return buf.getvalue()
|
| 179 |
+
|
| 180 |
+
def to_png_bytes(img: Image.Image) -> bytes:
|
| 181 |
+
buf = BytesIO()
|
| 182 |
+
img.save(buf, "PNG")
|
| 183 |
+
buf.seek(0)
|
| 184 |
+
return buf.getvalue()
|
| 185 |
+
|
| 186 |
+
def build_zip_bytes(imgs: List[Image.Image]) -> bytes:
|
| 187 |
+
from zipfile import ZipFile, ZIP_DEFLATED
|
| 188 |
+
bio = BytesIO()
|
| 189 |
+
with ZipFile(bio, "w", ZIP_DEFLATED) as zf:
|
| 190 |
+
for i, im in enumerate(imgs, start=1):
|
| 191 |
+
buf = BytesIO()
|
| 192 |
+
im.save(buf, "PNG")
|
| 193 |
+
buf.seek(0)
|
| 194 |
+
zf.writestr(f"sheet_{i}.png", buf.read())
|
| 195 |
+
bio.seek(0)
|
| 196 |
+
return bio.getvalue()
|
| 197 |
+
|
| 198 |
+
# ─────────────────────────────
|
| 199 |
+
# Main
|
| 200 |
+
# ─────────────────────────────
|
| 201 |
+
if not pdf_file:
|
| 202 |
+
status.info("Upload a PDF in the sidebar to begin.")
|
| 203 |
+
st.stop()
|
| 204 |
+
|
| 205 |
+
try:
|
| 206 |
+
pdf_bytes = pdf_file.read()
|
| 207 |
+
status.info("Inspecting PDF and environment…")
|
| 208 |
+
try:
|
| 209 |
+
info = pdfinfo_from_bytes(pdf_bytes, poppler_path=None)
|
| 210 |
+
n_pages = int(info.get("Pages", 0))
|
| 211 |
+
st.write(f"**Detected {n_pages} page(s).**")
|
| 212 |
+
except PDFInfoNotInstalledError:
|
| 213 |
+
st.error(
|
| 214 |
+
"Poppler is not installed or not found on PATH. "
|
| 215 |
+
"Install it (macOS: `brew install poppler`, Ubuntu: `sudo apt-get install poppler-utils`, "
|
| 216 |
+
"Windows: download Poppler and add its `bin` folder to PATH)."
|
| 217 |
+
)
|
| 218 |
+
st.stop()
|
| 219 |
+
|
| 220 |
+
# Page selection
|
| 221 |
+
with controls:
|
| 222 |
+
st.subheader("Page selection")
|
| 223 |
+
mode = st.radio("Choose pages", ["All", "First n Pages", "Range"], horizontal=True, key="mode_radio")
|
| 224 |
+
first_n = None
|
| 225 |
+
fp = None
|
| 226 |
+
lp = None
|
| 227 |
+
if mode == "First n Pages":
|
| 228 |
+
first_n = st.number_input("First N pages", 1, n_pages, n_pages, step=1, key="firstn")
|
| 229 |
+
elif mode == "Range":
|
| 230 |
+
fp = st.number_input("First page (1-based)", 1, n_pages, 1, step=1, key="fp")
|
| 231 |
+
lp = st.number_input("Last page (inclusive)", 1, n_pages, n_pages, step=1, key="lp")
|
| 232 |
+
|
| 233 |
+
first_page, last_page = 1, None
|
| 234 |
+
if mode == "First n Pages" and first_n:
|
| 235 |
+
first_page, last_page = 1, int(first_n)
|
| 236 |
+
elif mode == "Range" and fp and lp:
|
| 237 |
+
first_page, last_page = int(fp), int(lp)
|
| 238 |
+
if last_page < first_page:
|
| 239 |
+
first_page, last_page = last_page, first_page
|
| 240 |
+
st.warning("Swapped page range so First ≤ Last.")
|
| 241 |
+
|
| 242 |
+
status.info("Rendering PDF pages ➜ images…")
|
| 243 |
+
images = convert_from_bytes(
|
| 244 |
+
pdf_bytes,
|
| 245 |
+
dpi=render_dpi,
|
| 246 |
+
first_page=first_page,
|
| 247 |
+
last_page=last_page,
|
| 248 |
+
poppler_path=None,
|
| 249 |
+
)
|
| 250 |
+
if not images:
|
| 251 |
+
st.error("No pages were rendered — check your page selection.")
|
| 252 |
+
st.stop()
|
| 253 |
+
|
| 254 |
+
# Resolve paper inches
|
| 255 |
+
w_in, h_in = resolve_paper_inches(paper_preset, orientation, custom_w_in, custom_h_in)
|
| 256 |
+
|
| 257 |
+
# Compute sheet pixel size
|
| 258 |
+
sheet_w_px = px(w_in, render_dpi)
|
| 259 |
+
sheet_h_px = px(h_in, render_dpi)
|
| 260 |
+
margin_px = px(margin_in, render_dpi)
|
| 261 |
+
gutter_px = px(gutter_in, render_dpi)
|
| 262 |
+
|
| 263 |
+
status.info("Composing sheets…")
|
| 264 |
+
sheets = build_sheets(
|
| 265 |
+
images=images,
|
| 266 |
+
rows=int(rows),
|
| 267 |
+
cols=int(cols),
|
| 268 |
+
sheet_w_px=sheet_w_px,
|
| 269 |
+
sheet_h_px=sheet_h_px,
|
| 270 |
+
margin_px=margin_px,
|
| 271 |
+
gutter_px=gutter_px,
|
| 272 |
+
fit_mode=fit_mode,
|
| 273 |
+
show_borders=draw_cell_borders,
|
| 274 |
+
)
|
| 275 |
+
status.success(f"Built {len(sheets)} sheet(s) at {render_dpi} DPI.")
|
| 276 |
+
|
| 277 |
+
# Preview slider
|
| 278 |
+
sel = st.slider("Preview sheet", 1, len(sheets), 1, step=1) if len(sheets) > 1 else 1
|
| 279 |
+
preview = sheets[sel - 1]
|
| 280 |
+
|
| 281 |
+
with preview_slot:
|
| 282 |
+
st.image(preview, caption=f"Preview (sheet {sel} of {len(sheets)})", use_container_width=True)
|
| 283 |
+
|
| 284 |
+
# Downloads
|
| 285 |
+
c1, c2, c3 = st.columns(3)
|
| 286 |
+
|
| 287 |
+
with c1:
|
| 288 |
+
pdf_bytes_all = sheets_to_pdf_bytes(sheets, dpi=render_dpi)
|
| 289 |
+
st.download_button(
|
| 290 |
+
"Download PDF (all sheets)",
|
| 291 |
+
data=pdf_bytes_all,
|
| 292 |
+
file_name="nup_all_sheets.pdf",
|
| 293 |
+
mime="application/pdf",
|
| 294 |
+
type="primary", # highlight main action
|
| 295 |
+
)
|
| 296 |
+
|
| 297 |
+
with c2:
|
| 298 |
+
st.download_button(
|
| 299 |
+
f"Download PNG (sheet {sel})",
|
| 300 |
+
data=to_png_bytes(preview),
|
| 301 |
+
file_name=f"sheet_{sel}.png",
|
| 302 |
+
mime="image/png",
|
| 303 |
+
)
|
| 304 |
+
|
| 305 |
+
with c3:
|
| 306 |
+
zip_bytes = build_zip_bytes(sheets)
|
| 307 |
+
st.download_button(
|
| 308 |
+
"Download ZIP (all sheets as PNG)",
|
| 309 |
+
data=zip_bytes,
|
| 310 |
+
file_name="nup_all_sheets_png.zip",
|
| 311 |
+
mime="application/zip",
|
| 312 |
+
)
|
| 313 |
+
|
| 314 |
+
except (PDFPageCountError, PDFSyntaxError):
|
| 315 |
+
st.error("The PDF seems corrupted or password-protected.")
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
# streamlit run 240818_index_card_merger/ui_index_card_merger.py
|
| 320 |
+
|