Spaces:
Build error
Build error
import gradio as gr | |
from pathlib import Path | |
import datetime | |
import re | |
import os | |
import shutil | |
import fitz # PyMuPDF | |
from PIL import Image | |
from collections import defaultdict | |
import io | |
from pypdf import PdfWriter | |
import random | |
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, PageBreak, BaseDocTemplate, Frame, PageTemplate, Image as ReportLabImage | |
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle | |
from reportlab.lib.pagesizes import letter, A4, legal, landscape | |
from reportlab.lib.units import inch | |
from reportlab.lib import colors | |
from reportlab.pdfbase import pdfmetrics | |
from reportlab.pdfbase.ttfonts import TTFont | |
from functools import partial | |
# --- Configuration & Setup --- | |
CWD = Path.cwd() | |
LAYOUTS = { | |
"A4 Portrait": {"size": A4}, | |
"A4 Landscape": {"size": landscape(A4)}, | |
"Letter Portrait": {"size": letter}, | |
"Letter Landscape": {"size": landscape(letter)}, | |
"Legal Portrait": {"size": legal}, | |
"Legal Landscape": {"size": landscape(legal)}, | |
} | |
OUTPUT_DIR = CWD / "generated_pdfs" | |
PREVIEW_DIR = CWD / "previews" | |
FONT_DIR = CWD # Assumes fonts are in the same directory as the script | |
# Create necessary directories | |
OUTPUT_DIR.mkdir(exist_ok=True) | |
PREVIEW_DIR.mkdir(exist_ok=True) | |
# --- Font & Emoji Handling --- | |
def register_local_fonts(): | |
"""Finds and registers all .ttf files from the application's base directory.""" | |
print("--- Font Registration Process Starting ---") | |
text_font_names = [] | |
emoji_font_name = None | |
noto_emoji_path = FONT_DIR / "NotoColorEmoji-Regular.ttf" | |
if not noto_emoji_path.exists(): | |
print(f"Warning: Emoji font not found at {noto_emoji_path}. Emojis may not render correctly.") | |
print("Please download 'NotoColorEmoji-Regular.ttf' and place it in the application directory.") | |
print(f"Scanning for fonts in: {FONT_DIR.absolute()}") | |
font_files = list(FONT_DIR.glob("*.ttf")) | |
print(f"Found {len(font_files)} .ttf files: {[f.name for f in font_files]}") | |
for font_path in font_files: | |
try: | |
font_name = font_path.stem | |
pdfmetrics.registerFont(TTFont(font_name, str(font_path))) | |
pdfmetrics.registerFont(TTFont(f"{font_name}-Bold", str(font_path))) | |
pdfmetrics.registerFont(TTFont(f"{font_name}-Italic", str(font_path))) | |
pdfmetrics.registerFont(TTFont(f"{font_name}-BoldItalic", str(font_path))) | |
pdfmetrics.registerFontFamily(font_name, normal=font_name, bold=f"{font_name}-Bold", italic=f"{font_name}-Italic", boldItalic=f"{font_name}-BoldItalic") | |
if "notocoloremoji-regular" in font_name.lower(): | |
emoji_font_name = font_name | |
elif "notoemoji" not in font_name.lower(): | |
text_font_names.append(font_name) | |
except Exception as e: | |
print(f"Could not register font {font_path.name}: {e}") | |
if not text_font_names: | |
print("WARNING: No text fonts found. Adding 'Helvetica' as a default.") | |
text_font_names.append('Helvetica') | |
print(f"Successfully registered user-selectable fonts: {text_font_names}") | |
print(f"Emoji font set to: {emoji_font_name}") | |
print("--- Font Registration Process Finished ---") | |
return sorted(text_font_names), emoji_font_name | |
def apply_emoji_font(text: str, emoji_font_name: str) -> str: | |
"""Intelligently wraps emoji characters in a <font> tag to ensure they render correctly.""" | |
if not emoji_font_name: | |
return text | |
emoji_pattern = re.compile(f"([{re.escape(''.join(map(chr, range(0x1f600, 0x1f650))))}" | |
f"{re.escape(''.join(map(chr, range(0x1f300, 0x1f5ff))))}" | |
f"{re.escape(''.join(map(chr, range(0x1f900, 0x1f9ff))))}" | |
f"{re.escape(''.join(map(chr, range(0x2600, 0x26ff))))}" | |
f"{re.escape(''.join(map(chr, range(0x2700, 0x27bf))))}]+)") | |
return emoji_pattern.sub(fr'<font name="{emoji_font_name}">\1</font>', text) | |
# --- AI Content Generation (Simulation) --- | |
def generate_ai_content_api(prompt): | |
""" | |
Simulates a call to an LLM to generate markdown content. | |
In a real application, this would contain `fetch` calls to a generative AI API. | |
""" | |
if not prompt: | |
return "# The Golem awaits your command!\n\nPlease enter a prompt in the box above and click '๐ง Animate Golem!' to get started. I can help you write reports, stories, poems, and more! โจ" | |
import time | |
time.sleep(1.5) | |
sample_story = f""" | |
# The Quest for the Sunstone โ๏ธ | |
Long ago, in the mystical land of Aerthos, a creeping shadow began to fall across the valleys. The once-vibrant flowers ๐ธ drooped, and the rivers ran slow. The elders knew the cause: the light of the Sunstone, hidden deep within Mount Cinder, was fading. | |
## The Prophecy ๐ | |
An ancient prophecy spoke of a hero who would rekindle the stone. It read: | |
> When darkness drapes the verdant ground, | |
> A soul of courage shall be found. | |
> Through trials of fire ๐ฅ, wit, and might, | |
> They'll bring once more the sacred light. | |
## The Chosen One ๐ฆธโโ๏ธ | |
A young woman named Elara, known for her kindness and bravery, was chosen. She accepted the quest, her only companions a loyal wolf ๐บ and a map gifted by the village shaman. | |
### The Journey Begins | |
Elara's journey was fraught with peril. She navigated enchanted forests and crossed treacherous chasms. | |
| Trial | Location | Challenge | | |
|------------------|-------------------|----------------------------------------| | |
| The Whispering | The Gloomwood | Resist maddening whispers of despair | | |
| The Riddle of | Sphinx Gate | Answer three impossible questions | | |
| The Fiery Path | The Magma Caverns | Walk barefoot across burning embers | | |
Finally, she reached the heart of the mountain. There, resting on a pedestal, was the dim Sunstone. Pouring her own hope and courage into it, the stone blazed with renewed life, banishing the shadows from Aerthos forever. The people rejoiced, and Elara became a legend. | |
""" | |
return f"# Golem's Vision for: '{prompt}'\n\n{sample_story}" | |
# --- PDF Generation & Handling --- | |
def _draw_header_footer(canvas, doc, header_text, footer_text, title): | |
"""Draws the header and footer on each page.""" | |
canvas.saveState() | |
page_num = canvas.getPageNumber() | |
# Replace variables for header and footer. Note: [Total Pages] is only accurate at the end of the build. | |
final_footer_text = footer_text.replace("[Page #]", str(page_num)).replace("[Total Pages]", str(doc.page)) | |
final_header_text = header_text.replace("[Page #]", str(page_num)).replace("[Title]", title) | |
# Header | |
if final_header_text: | |
canvas.setFont('Helvetica', 9) | |
canvas.setFillColor(colors.grey) | |
canvas.drawRightString(doc.width + doc.leftMargin, doc.height + doc.topMargin + 0.25*inch, final_header_text) | |
# Footer | |
if final_footer_text: | |
canvas.setFont('Helvetica', 9) | |
canvas.setFillColor(colors.grey) | |
canvas.drawString(doc.leftMargin, doc.bottomMargin - 0.25*inch, final_footer_text) | |
canvas.restoreState() | |
def markdown_to_story(markdown_text: str, font_name: str, emoji_font: str, font_size_body: int, font_size_h1: int, font_size_h2: int, font_size_h3: int): | |
"""Converts markdown to a ReportLab story with enhanced, user-configurable styling.""" | |
styles = getSampleStyleSheet() | |
leading_body = font_size_body * 1.4 | |
style_normal = ParagraphStyle('BodyText', fontName=font_name, fontSize=font_size_body, leading=leading_body, spaceAfter=6) | |
style_h1 = ParagraphStyle('h1', parent=styles['h1'], fontName=font_name, fontSize=font_size_h1, leading=font_size_h1*1.2, spaceBefore=12, textColor=colors.HexColor("#a855f7")) | |
style_h2 = ParagraphStyle('h2', parent=styles['h2'], fontName=font_name, fontSize=font_size_h2, leading=font_size_h2*1.2, spaceBefore=10, textColor=colors.HexColor("#6366f1")) | |
style_h3 = ParagraphStyle('h3', parent=styles['h3'], fontName=font_name, fontSize=font_size_h3, leading=font_size_h3*1.2, spaceBefore=8, textColor=colors.HexColor("#3b82f6")) | |
style_code = ParagraphStyle('Code', fontName='Courier', backColor=colors.HexColor("#333333"), textColor=colors.HexColor("#f472b6"), borderWidth=1, borderColor=colors.HexColor("#444444"), padding=8, leading=12, fontSize=9) | |
style_table_header = ParagraphStyle('TableHeader', parent=style_normal, fontName=f"{font_name}-Bold" if font_name != 'Helvetica' else 'Helvetica-Bold') | |
story = [] | |
lines = markdown_text.split('\n') | |
in_code_block, in_table = False, False | |
code_block_text, table_data = "", [] | |
first_heading = True | |
document_title = "Untitled Document" | |
for line in lines: | |
stripped_line = line.strip() | |
if stripped_line.startswith("```"): | |
if in_code_block: | |
escaped_code = code_block_text.replace('&', '&').replace('<', '<').replace('>', '>') | |
story.append(Paragraph(escaped_code.replace('\n', '<br/>'), style_code)) | |
story.append(Spacer(1, 0.1 * inch)) | |
in_code_block, code_block_text = False, "" | |
else: in_code_block = True | |
continue | |
if in_code_block: | |
code_block_text += line + '\n'; continue | |
if stripped_line.startswith('|'): | |
if not in_table: in_table = True | |
if all(c in '-|: ' for c in stripped_line): continue | |
cells = [cell.strip() for cell in stripped_line.strip('|').split('|')] | |
table_data.append(cells) | |
continue | |
if in_table: | |
in_table = False | |
if table_data: | |
header_content = [apply_emoji_font(re.sub(r'\*\*(.*?)\*\*', r'<b>\1</b>', cell), emoji_font) for cell in table_data[0]] | |
header = [Paragraph(cell, style_table_header) for cell in header_content] | |
formatted_rows = [ | |
[Paragraph(apply_emoji_font(re.sub(r'\*\*(.*?)\*\*', r'<b>\1</b>', cell), emoji_font), style_normal) for cell in row] | |
for row in table_data[1:] | |
] | |
table = Table([header] + formatted_rows, hAlign='LEFT', repeatRows=1) | |
table.setStyle(TableStyle([ | |
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor("#4a044e")), | |
('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke), | |
('GRID', (0, 0), (-1, -1), 1, colors.HexColor("#6b21a8")), | |
('VALIGN', (0,0), (-1,-1), 'MIDDLE'), | |
('TOPPADDING', (0,0), (-1,-1), 6), | |
('BOTTOMPADDING', (0,0), (-1,-1), 6), | |
])) | |
story.append(table); story.append(Spacer(1, 0.2 * inch)) | |
table_data = [] | |
if not stripped_line: continue | |
content, style, extra_args = stripped_line, style_normal, {} | |
if stripped_line.startswith("# "): | |
if not first_heading: story.append(PageBreak()) | |
content, style = stripped_line.lstrip('# '), style_h1 | |
if first_heading: document_title = content | |
first_heading = False | |
elif stripped_line.startswith("## "): content, style = stripped_line.lstrip('## '), style_h2 | |
elif stripped_line.startswith("### "): content, style = stripped_line.lstrip('### '), style_h3 | |
elif stripped_line.startswith(("- ", "* ")): content, extra_args['bulletText'] = stripped_line[2:], 'โข' | |
formatted_content = re.sub(r'_(.*?)_', r'<i>\1</i>', re.sub(r'\*\*(.*?)\*\*', r'<b>\1</b>', content)) | |
final_content = apply_emoji_font(formatted_content, emoji_font) | |
story.append(Paragraph(final_content, style, **extra_args)) | |
return story, document_title | |
def create_pdf_preview(pdf_path: Path): | |
"""Generates a PNG preview of the first page of a PDF.""" | |
preview_path = PREVIEW_DIR / f"{pdf_path.stem}.png" | |
try: | |
doc = fitz.open(pdf_path) | |
page = doc.load_page(0) | |
pix = page.get_pixmap(dpi=150) | |
pix.save(str(preview_path)) | |
doc.close() | |
return str(preview_path) | |
except Exception as e: | |
print(f"Could not create preview for {pdf_path.name}: {e}") | |
try: | |
img = Image.new('RGB', (400, 500), color = '#111827') | |
img.save(str(preview_path)) | |
return str(preview_path) | |
except: return None | |
# --- Main API Function --- | |
def generate_pdfs_api(files, ai_content, layouts, fonts, num_columns, header_text, footer_text, font_size_body, font_size_h1, font_size_h2, font_size_h3, margin_top, margin_bottom, margin_left, margin_right, progress=gr.Progress(track_tqdm=True)): | |
if not files and not ai_content.strip(): raise gr.Error("Please conjure some content or upload an image before alchemizing!") | |
if not layouts: raise gr.Error("You must select a scroll (page layout)!") | |
if not fonts: raise gr.Error("A scribe needs a font! Please choose one.") | |
shutil.rmtree(OUTPUT_DIR, ignore_errors=True); shutil.rmtree(PREVIEW_DIR, ignore_errors=True) | |
OUTPUT_DIR.mkdir(); PREVIEW_DIR.mkdir() | |
md_content = ai_content if ai_content and ai_content.strip() else "" | |
image_files = [Path(f.name) for f in files if Path(f.name).suffix.lower() in ['.png', '.jpg', '.jpeg']] if files else [] | |
log_updates, generated_pdf_paths = "", [] | |
for layout_name in progress.tqdm(layouts, desc=" brewing potions..."): | |
for font_name in progress.tqdm(fonts, desc=f" enchanting scrolls with {layout_name}..."): | |
merger = PdfWriter() | |
if md_content: | |
md_buffer = io.BytesIO() | |
story, title = markdown_to_story(md_content, font_name, EMOJI_FONT_NAME, font_size_body, font_size_h1, font_size_h2, font_size_h3) | |
pagesize = LAYOUTS[layout_name]["size"] | |
doc = BaseDocTemplate(md_buffer, pagesize=pagesize, | |
leftMargin=margin_left*inch, rightMargin=margin_right*inch, | |
topMargin=margin_top*inch, bottomMargin=margin_bottom*inch) | |
frame_width = (doc.width / num_columns) - (num_columns - 1) * 0.1*inch | |
frames = [Frame(doc.leftMargin + i * (frame_width + 0.2*inch), doc.bottomMargin, frame_width, doc.height, id=f'col_{i}') for i in range(num_columns)] | |
header_footer_callback = partial(_draw_header_footer, header_text=header_text, footer_text=footer_text, title=title) | |
page_template = PageTemplate(id='main_template', frames=frames, onPage=header_footer_callback) | |
doc.addPageTemplates([page_template]) | |
doc.build(story) | |
md_buffer.seek(0) | |
merger.append(fileobj=md_buffer) | |
for img_path in image_files: | |
try: | |
with Image.open(img_path) as img: img_width_px, img_height_px = img.size | |
img_width_pt, img_height_pt = img_width_px * (inch / 72), img_height_px * (inch / 72) | |
img_buffer = io.BytesIO() | |
img_doc = SimpleDocTemplate(img_buffer, pagesize=(img_width_pt, img_height_pt), leftMargin=0, rightMargin=0, topMargin=0, bottomMargin=0) | |
img_doc.build([ReportLabImage(img_path, width=img_width_pt, height=img_height_pt)]) | |
img_buffer.seek(0) | |
merger.append(fileobj=img_buffer) | |
except Exception as e: log_updates += f"โ ๏ธ Failed to process image {img_path.name}: {e}\n" | |
if len(merger.pages) > 0: | |
time_str = datetime.datetime.now().strftime('%H%M%S') | |
clean_layout = layout_name.replace(' ', '') | |
filename = f"Scroll_{clean_layout}_{font_name}_x{num_columns}_{time_str}.pdf" | |
output_path = OUTPUT_DIR / filename | |
with open(output_path, "wb") as f: merger.write(f) | |
generated_pdf_paths.append(output_path) | |
log_updates += f"โ Successfully alchemized: {filename}\n" | |
gallery_previews = [create_pdf_preview(p) for p in generated_pdf_paths] | |
final_gallery = [g for g in gallery_previews if g is not None] | |
return final_gallery, log_updates if log_updates else "โจ All scrolls alchemized successfully! โจ", [str(p) for p in generated_pdf_paths] | |
# --- Gradio UI Definition --- | |
AVAILABLE_FONTS, EMOJI_FONT_NAME = register_local_fonts() | |
# A custom, magical theme | |
theme = gr.themes.Base( | |
primary_hue=gr.themes.colors.purple, | |
secondary_hue=gr.themes.colors.indigo, | |
neutral_hue=gr.themes.colors.gray, | |
font=(gr.themes.GoogleFont("MedievalSharp"), "ui-sans-serif", "system-ui", "sans-serif"), | |
).set( | |
body_background_fill="#111827", | |
body_background_fill_dark="#111827", | |
body_text_color="#d1d5db", | |
body_text_color_dark="#d1d5db", | |
button_primary_background_fill="#a855f7", | |
button_primary_background_fill_dark="#a855f7", | |
button_primary_text_color="#ffffff", | |
button_primary_text_color_dark="#ffffff", | |
button_secondary_background_fill="#6366f1", | |
button_secondary_background_fill_dark="#6366f1", | |
button_secondary_text_color="#ffffff", | |
button_secondary_text_color_dark="#ffffff", | |
block_background_fill="#1f2937", | |
block_background_fill_dark="#1f2937", | |
block_label_background_fill="#1f2937", | |
block_label_background_fill_dark="#1f2937", | |
block_title_text_color="#a855f7", | |
block_title_text_color_dark="#a855f7", | |
input_background_fill="#374151", | |
input_background_fill_dark="#374151", | |
) | |
with gr.Blocks(theme=theme, title="The PDF Alchemist") as demo: | |
gr.Markdown("# โจ The PDF Alchemist โจ") | |
gr.Markdown("Turn your ideas into beautifully crafted PDF scrolls. Use the power of AI or upload your own treasures.") | |
with gr.Row(equal_height=False): | |
with gr.Column(scale=2): | |
with gr.Tabs(): | |
with gr.TabItem("โ Conjure Your Content ๐"): | |
gr.Markdown("### ๐ค The Idea Golem") | |
ai_prompt = gr.Textbox(label="Command the Golem", placeholder="e.g., 'A recipe for a dragon's breath chili...'") | |
generate_ai_btn = gr.Button("๐ง Animate Golem!") | |
ai_content_output = gr.Textbox(label="Golem's Manuscript (Editable)", lines=15, interactive=True) | |
gr.Markdown("<hr style='border-color: #374151;'>") | |
gr.Markdown("### ๐ค Or, Upload Your Treasures") | |
uploaded_files = gr.File(label="Upload Images (.png, .jpg)", file_count="multiple", file_types=[".png", ".jpg", ".jpeg"]) | |
gr.Markdown("*<p style='font-size:0.8rem; color: #9ca3af;'>Note: The Golem's manuscript takes precedence. Images are always added.</p>*") | |
with gr.TabItem("โก Define Your Canvas ๐จ"): | |
gr.Markdown("### ๐ Choose Your Scroll") | |
selected_layouts = gr.CheckboxGroup(choices=list(LAYOUTS.keys()), label="Page Layouts", value=["A4 Portrait"]) | |
num_columns_slider = gr.Slider(label="Number of Text Columns", minimum=1, maximum=4, step=1, value=1) | |
gr.Markdown("### โ๏ธ Scribe Your Margins") | |
gr.Markdown("<p style='font-size:0.8rem; color: #9ca3af;'>Use `[Page #]` and `[Title]`. `[Total Pages]` works best in the footer.</p>") | |
header_input = gr.Textbox(label="Header Inscription", value="[Title]", placeholder="e.g., Arcane Folio - [Page #]") | |
footer_input = gr.Textbox(label="Footer Inscription", value="Page [Page #] of [Total Pages]", placeholder="e.g., Top Secret - Page [Page #]") | |
with gr.TabItem("โข Perfect Your Style ๐ "): | |
gr.Markdown("### ๐ค Master the Glyphs") | |
selected_fonts = gr.CheckboxGroup(choices=AVAILABLE_FONTS, label="Fonts", value=[AVAILABLE_FONTS[0]] if AVAILABLE_FONTS else []) | |
font_size_body_slider = gr.Slider(label="Body Text Size (pt)", minimum=8, maximum=16, step=1, value=10) | |
with gr.Row(): | |
font_size_h1_slider = gr.Slider(label="H1 Size", minimum=16, maximum=32, step=1, value=24) | |
font_size_h2_slider = gr.Slider(label="H2 Size", minimum=14, maximum=28, step=1, value=18) | |
font_size_h3_slider = gr.Slider(label="H3 Size", minimum=12, maximum=24, step=1, value=14) | |
gr.Markdown("### ๐ Set Your Boundaries (inches)") | |
with gr.Row(): | |
margin_top_slider = gr.Slider(label="Top", minimum=0.25, maximum=1.5, step=0.05, value=0.75) | |
margin_bottom_slider = gr.Slider(label="Bottom", minimum=0.25, maximum=1.5, step=0.05, value=0.75) | |
with gr.Row(): | |
margin_left_slider = gr.Slider(label="Left", minimum=0.25, maximum=1.5, step=0.05, value=0.75) | |
margin_right_slider = gr.Slider(label="Right", minimum=0.25, maximum=1.5, step=0.05, value=0.75) | |
generate_pdfs_btn = gr.Button("๐ฎ Alchemize PDF!", variant="primary", size="lg") | |
with gr.Column(scale=3): | |
gr.Markdown("### ๐ผ๏ธ The Scrying Pool (Previews)") | |
gallery_output = gr.Gallery(label="Generated PDF Previews", show_label=False, elem_id="gallery", columns=2, height=700, object_fit="contain") | |
log_output = gr.Markdown(label="Alchemist's Log", value="Your log of successful transmutations will appear here...") | |
downloadable_files_output = gr.Files(label="Collect Your Scrolls") | |
# --- API Calls & Examples --- | |
generate_ai_btn.click(fn=generate_ai_content_api, inputs=[ai_prompt], outputs=[ai_content_output]) | |
inputs_list = [uploaded_files, ai_content_output, selected_layouts, selected_fonts, num_columns_slider, header_input, footer_input, font_size_body_slider, font_size_h1_slider, font_size_h2_slider, font_size_h3_slider, margin_top_slider, margin_bottom_slider, margin_left_slider, margin_right_slider] | |
outputs_list = [gallery_output, log_output, downloadable_files_output] | |
generate_pdfs_btn.click(fn=generate_pdfs_api, inputs=inputs_list, outputs=outputs_list) | |
gr.Examples( | |
examples=[["A technical summary of how alchemy works"], ["A short poem about a grumpy gnome"], ["A sample agenda for a wizard's council meeting"]], | |
inputs=[ai_prompt], outputs=[ai_content_output], fn=generate_ai_content_api, cache_examples=False | |
) | |
if __name__ == "__main__": | |
if not (FONT_DIR / "NotoColorEmoji-Regular.ttf").exists(): | |
print("\n" + "="*80) | |
print("WARNING: 'NotoColorEmoji-Regular.ttf' not found.") | |
print("Please download it from Google Fonts and place it in the script's directory for emojis to render correctly.") | |
print("="*80 + "\n") | |
if not any("MedievalSharp" in s for s in AVAILABLE_FONTS): | |
print("\n" + "="*80) | |
print("WARNING: 'MedievalSharp' font not found for the default theme.") | |
print("Please download it from Google Fonts to get the intended 'alchemist' look.") | |
print("The app will fall back to another font.") | |
print("="*80 + "\n") | |
demo.launch(debug=True) | |