nicoaspra commited on
Commit
e840a71
·
1 Parent(s): 11fa0df

initial commit

Browse files
Files changed (8) hide show
  1. .gitignore +1 -0
  2. README.md +4 -2
  3. index.html +0 -19
  4. index_card_merger.py +30 -0
  5. style.css +0 -28
  6. test.ipynb +117 -0
  7. ui_PDF-booklet-tiler.py +320 -0
  8. 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: static
 
 
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
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
+