|
import io |
|
import os |
|
import re |
|
from datetime import datetime |
|
from collections import Counter |
|
|
|
import pandas as pd |
|
import pytz |
|
import streamlit as st |
|
from PIL import Image |
|
from reportlab.pdfgen import canvas |
|
from reportlab.lib.utils import ImageReader |
|
|
|
|
|
st.set_page_config( |
|
page_title="Image → PDF Comic Layout", |
|
layout="wide", |
|
initial_sidebar_state="expanded", |
|
) |
|
|
|
st.title("🖼️ Image → PDF • Scan, Reorder & Caption Generator") |
|
st.markdown( |
|
"Scan docs or upload images, filter by orientation, reorder, then generate a captioned PDF matching each image’s dimensions." |
|
) |
|
|
|
|
|
st.session_state.setdefault('snapshots', []) |
|
|
|
|
|
st.sidebar.header("1️⃣ Page Aspect Ratio & Size") |
|
ratio_map = { |
|
"4:3 (Landscape)": (4, 3), |
|
"16:9 (Landscape)": (16, 9), |
|
"1:1 (Square)": (1, 1), |
|
"2:3 (Portrait)": (2, 3), |
|
"9:16 (Portrait)": (9, 16), |
|
} |
|
ratio_choice = st.sidebar.selectbox( |
|
"Preset Ratio", list(ratio_map.keys()) + ["Custom…"] |
|
) |
|
if ratio_choice != "Custom…": |
|
rw, rh = ratio_map[ratio_choice] |
|
else: |
|
rw = st.sidebar.number_input("Custom Width Ratio", min_value=1, value=4) |
|
rh = st.sidebar.number_input("Custom Height Ratio", min_value=1, value=3) |
|
BASE_WIDTH_PT = st.sidebar.slider( |
|
"Base Page Width (pt)", min_value=400, max_value=1200, value=800, step=100 |
|
) |
|
page_width = BASE_WIDTH_PT |
|
page_height = int(BASE_WIDTH_PT * (rh / rw)) |
|
st.sidebar.markdown(f"**Preview page size:** {page_width}×{page_height} pt") |
|
|
|
|
|
st.header("2️⃣ Document Scan & Image Upload") |
|
|
|
cam = st.camera_input("📸 Scan Document") |
|
if cam: |
|
central = pytz.timezone("US/Central") |
|
now = datetime.now(central) |
|
prefix = now.strftime("%Y-%m%d-%I%M%p") + "-" + now.strftime("%a").upper() |
|
scan_name = f"{prefix}-scan.png" |
|
with open(scan_name, "wb") as f: |
|
f.write(cam.getvalue()) |
|
st.image(Image.open(scan_name), caption=scan_name, use_container_width=True) |
|
if scan_name not in st.session_state['snapshots']: |
|
st.session_state['snapshots'].append(scan_name) |
|
|
|
|
|
uploads = st.file_uploader( |
|
"📂 Upload PNG/JPG images", type=["png","jpg","jpeg"], accept_multiple_files=True |
|
) |
|
|
|
|
|
all_records = [] |
|
|
|
for idx, path in enumerate(st.session_state['snapshots']): |
|
im = Image.open(path) |
|
w, h = im.size |
|
ar = round(w / h, 2) |
|
orient = "Square" if 0.9 <= ar <= 1.1 else ("Landscape" if ar > 1.1 else "Portrait") |
|
all_records.append({ |
|
"filename": os.path.basename(path), |
|
"source": path, |
|
"width": w, |
|
"height": h, |
|
"aspect_ratio": ar, |
|
"orientation": orient, |
|
"order": idx, |
|
}) |
|
|
|
if uploads: |
|
for jdx, f in enumerate(uploads, start=len(all_records)): |
|
im = Image.open(f) |
|
w, h = im.size |
|
ar = round(w / h, 2) |
|
orient = "Square" if 0.9 <= ar <= 1.1 else ("Landscape" if ar > 1.1 else "Portrait") |
|
all_records.append({ |
|
"filename": f.name, |
|
"source": f, |
|
"width": w, |
|
"height": h, |
|
"aspect_ratio": ar, |
|
"orientation": orient, |
|
"order": jdx, |
|
}) |
|
|
|
df = pd.DataFrame(all_records) |
|
|
|
dims = st.sidebar.multiselect( |
|
"Include orientations:", options=["Landscape","Portrait","Square"], |
|
default=["Landscape","Portrait","Square"] |
|
) |
|
df = df[df['orientation'].isin(dims)].reset_index(drop=True) |
|
|
|
st.markdown("#### Images & Scan Metadata (drag/order)") |
|
st.dataframe(df.style.format({"aspect_ratio":"{:.2f}"}), use_container_width=True) |
|
st.markdown("*Drag rows or edit `order` to set PDF page sequence.*") |
|
|
|
try: |
|
edited = st.experimental_data_editor(df, num_rows="fixed", use_container_width=True) |
|
ordered_df = edited |
|
except Exception: |
|
edited = st.data_editor( |
|
df, |
|
column_config={"order": st.column_config.NumberColumn("Order", min_value=0, max_value=len(df)-1)}, |
|
hide_index=True, |
|
use_container_width=True, |
|
) |
|
ordered_df = edited.sort_values('order').reset_index(drop=True) |
|
|
|
|
|
ordered_sources = [row['source'] for _, row in ordered_df.iterrows()] |
|
|
|
|
|
def clean_stem(fn: str) -> str: |
|
return os.path.splitext(fn)[0].replace('-', ' ').replace('_', ' ') |
|
|
|
|
|
def make_image_sized_pdf(sources): |
|
buf = io.BytesIO() |
|
c = canvas.Canvas(buf) |
|
for idx, src in enumerate(sources, start=1): |
|
im = Image.open(src) |
|
iw, ih = im.size |
|
cap_h = 20 |
|
pw, ph = iw, ih + cap_h |
|
c.setPageSize((pw, ph)) |
|
c.drawImage(ImageReader(im), 0, cap_h, iw, ih, preserveAspectRatio=True, mask='auto') |
|
caption = clean_stem(os.path.basename(src)) |
|
c.setFont('Helvetica', 12) |
|
c.drawCentredString(pw/2, cap_h/2, caption) |
|
c.setFont('Helvetica', 8) |
|
c.drawRightString(pw-10, 10, str(idx)) |
|
c.showPage() |
|
c.save(); buf.seek(0) |
|
return buf.getvalue() |
|
|
|
|
|
st.header("3️⃣ Generate & Download PDF") |
|
if st.button("🖋️ Generate Captioned PDF"): |
|
if not ordered_sources: |
|
st.warning("No images or scans to include.") |
|
else: |
|
central = pytz.timezone("US/Central") |
|
now = datetime.now(central) |
|
prefix = now.strftime("%Y-%m%d-%I%M%p") + "-" + now.strftime("%a").upper() |
|
stems = [clean_stem(os.path.basename(s)) for s in ordered_sources] |
|
|
|
stems = stems[:4] |
|
basename = " - ".join(stems) |
|
fname = f"{prefix}-{basename}.pdf" |
|
pdf_bytes = make_image_sized_pdf(ordered_sources) |
|
st.success(f"✅ PDF ready: **{fname}**") |
|
st.download_button("⬇️ Download PDF", data=pdf_bytes, file_name=fname, mime="application/pdf") |
|
st.markdown("#### Preview Page 1") |
|
try: |
|
import fitz |
|
doc = fitz.open(stream=pdf_bytes, filetype='pdf') |
|
pix = doc.load_page(0).get_pixmap(matrix=fitz.Matrix(1.5,1.5)) |
|
st.image(pix.tobytes(), use_container_width=True) |
|
except: |
|
st.info("Install `pymupdf` for preview.") |
|
|
|
|
|
st.sidebar.markdown("---") |
|
st.sidebar.markdown("Built by Aaron C. Wacker • Senior AI Engineer") |
|
|