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, ImageDraw, ImageFont | |
from collections import defaultdict | |
import io | |
from pypdf import PdfWriter | |
import random | |
# New imports for the SVG Emoji Engine | |
from lxml import etree | |
from svglib.svglib import svg2rlg | |
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, PageBreak, BaseDocTemplate, Frame, PageTemplate, Image as ReportLabImage, Flowable | |
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() | |
TEMP_DIR = CWD / "temp_icons" | |
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) | |
TEMP_DIR.mkdir(exist_ok=True) | |
# --- Font & Emoji Handling --- | |
EMOJI_IMAGE_CACHE, EMOJI_SVG_CACHE = {}, {} | |
EMOJI_FONT_PATH, UI_FONT_PATH = None, None | |
def register_local_fonts(): | |
"""Finds and registers all .ttf files from the application's base directory.""" | |
global EMOJI_FONT_PATH, UI_FONT_PATH | |
print("--- Font Registration Process Starting ---") | |
text_font_names = [] | |
noto_emoji_path = FONT_DIR / "NotoColorEmoji-Regular.ttf" | |
if noto_emoji_path.exists(): | |
EMOJI_FONT_PATH = str(noto_emoji_path) | |
else: | |
print("CRITICAL: 'NotoColorEmoji-Regular.ttf' not found. Color emojis will not work.") | |
print(f"Scanning for fonts in: {FONT_DIR.absolute()}") | |
font_files = list(FONT_DIR.glob("*.ttf")) | |
for font_path in font_files: | |
try: | |
font_name = font_path.stem | |
if not UI_FONT_PATH: UI_FONT_PATH = str(font_path) # Grab first available font for UI icons | |
pdfmetrics.registerFont(TTFont(font_name, str(font_path))) | |
pdfmetrics.registerFontFamily(font_name, normal=font_name, bold=font_name, italic=font_name, boldItalic=font_name) | |
if "notocoloremoji-regular" not in font_name.lower() and "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: | |
text_font_names.append('Helvetica') | |
print(f"Successfully registered user-selectable fonts: {text_font_names}") | |
return sorted(text_font_names) | |
def render_emoji_as_image(emoji_char, size_pt): | |
if not EMOJI_FONT_PATH: return None | |
if (emoji_char, size_pt) in EMOJI_IMAGE_CACHE: return EMOJI_IMAGE_CACHE[(emoji_char, size_pt)] | |
try: | |
rect, doc = fitz.Rect(0, 0, size_pt*1.5, size_pt*1.5), fitz.open() | |
page = doc.new_page(width=rect.width, height=rect.height) | |
page.insert_font(fontname="emoji", fontfile=EMOJI_FONT_PATH) | |
page.insert_text(fitz.Point(0, size_pt * 1.1), emoji_char, fontname="emoji", fontsize=size_pt) | |
pix, img_buffer = page.get_pixmap(alpha=True, dpi=300), io.BytesIO(pix.tobytes("png")) | |
doc.close(); img_buffer.seek(0) | |
EMOJI_IMAGE_CACHE[(emoji_char, size_pt)] = img_buffer | |
return img_buffer | |
except Exception as e: print(f"Could not render emoji {emoji_char} as image: {e}"); return None | |
def render_emoji_as_svg(emoji_char, size_pt): | |
if not EMOJI_FONT_PATH: return None | |
if (emoji_char, size_pt) in EMOJI_SVG_CACHE: return EMOJI_SVG_CACHE[(emoji_char, size_pt)] | |
try: | |
doc = fitz.open(); page = doc.new_page(width=1000, height=1000) | |
page.insert_font(fontname="emoji", fontfile=EMOJI_FONT_PATH) | |
svg_image = page.get_svg_image(page.rect, text=emoji_char, fontname="emoji") | |
doc.close() | |
drawing = svg2rlg(io.BytesIO(svg_image.encode('utf-8'))) | |
scale_factor = (size_pt * 1.2) / drawing.height | |
drawing.width, drawing.height = drawing.width*scale_factor, drawing.height*scale_factor | |
drawing.scale(scale_factor, scale_factor) | |
EMOJI_SVG_CACHE[(emoji_char, size_pt)] = drawing | |
return drawing | |
except Exception as e: print(f"Could not render emoji {emoji_char} as SVG: {e}"); return None | |
# --- UI & File Handling --- | |
FILE_ICONS = defaultdict(lambda: 'โ', { | |
'.md': '๐', '.txt': '๐', '.py': '๐', '.js': 'โก', '.html': '๐', '.css': '๐จ', '.json': '๐ฎ', | |
'.png': '๐ผ๏ธ', '.jpg': '๐ผ๏ธ', '.jpeg': '๐ผ๏ธ', '.gif': '๐ผ๏ธ', '.bmp': '๐ผ๏ธ', '.tiff': '๐ผ๏ธ', | |
'.pdf': '๐' | |
}) | |
def create_file_icon_image(file_path: Path): | |
"""Creates a preview image for a file with an icon and its name.""" | |
icon = FILE_ICONS[file_path.suffix.lower()] | |
img = Image.new('RGB', (150, 100), color='#1f2937') | |
draw = ImageDraw.Draw(img) | |
try: | |
icon_font = ImageFont.truetype(EMOJI_FONT_PATH, 40) if EMOJI_FONT_PATH else ImageFont.load_default() | |
text_font = ImageFont.truetype(UI_FONT_PATH, 10) if UI_FONT_PATH else ImageFont.load_default() | |
except: | |
icon_font, text_font = ImageFont.load_default(), ImageFont.load_default() | |
draw.text((75, 40), icon, font=icon_font, anchor="mm", fill="#a855f7") | |
draw.text((75, 80), file_path.name, font=text_font, anchor="mm", fill="#d1d5db") | |
out_path = TEMP_DIR / f"{file_path.stem}_{file_path.suffix[1:]}.png" | |
img.save(out_path) | |
return out_path | |
def update_staging_and_manuscript(files, current_manuscript): | |
"""Processes uploaded files, updates staging galleries, and consolidates text content.""" | |
shutil.rmtree(TEMP_DIR, ignore_errors=True); TEMP_DIR.mkdir(exist_ok=True) | |
text_gallery, image_gallery, pdf_gallery = [], [], [] | |
text_content_to_add = [] | |
sorted_files = sorted(files, key=lambda x: Path(x.name).suffix) | |
for file_obj in sorted_files: | |
file_path = Path(file_obj.name) | |
ext = file_path.suffix.lower() | |
if ext in ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.tiff']: | |
image_gallery.append((file_path, file_path.name)) | |
elif ext == '.pdf': | |
pdf_gallery.append((create_file_icon_image(file_path), file_path.name)) | |
else: # Treat everything else as a text file | |
text_gallery.append((create_file_icon_image(file_path), file_path.name)) | |
try: | |
with open(file_path, 'r', encoding='utf-8') as f: | |
text_content_to_add.append(f.read()) | |
except Exception as e: | |
print(f"Could not read {file_path.name}: {e}") | |
# Update manuscript | |
updated_manuscript = current_manuscript | |
if "Golem awaits" in updated_manuscript: | |
updated_manuscript = "" # Clear placeholder if it exists | |
if text_content_to_add: | |
separator = "\n\n---\n\n" | |
if not updated_manuscript.strip(): separator = "" # Don't add separator if manuscript is empty | |
updated_manuscript += separator + "\n\n".join(text_content_to_add) | |
return text_gallery, image_gallery, pdf_gallery, updated_manuscript | |
# --- Main PDF Generation Logic (heavily adapted from before) --- | |
def markdown_to_story(markdown_text, font_name, font_sizes, use_svg_engine): | |
styles = getSampleStyleSheet() | |
font_size_body, font_size_h1, font_size_h2, font_size_h3 = font_sizes | |
style_normal = ParagraphStyle('BodyText', fontName=font_name, fontSize=font_size_body, leading=font_size_body*1.4, 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=font_name) # Bold is handled in 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))))}]+)") | |
def create_flowables_for_line(text, style): | |
parts, flowables = emoji_pattern.split(text), [] | |
for part in parts: | |
if not part: continue | |
if emoji_pattern.match(part): | |
for emoji_char in part: | |
emoji_flowable = render_emoji_as_svg(emoji_char, style.fontSize) if use_svg_engine else (lambda b: ReportLabImage(b, height=style.fontSize*1.2, width=style.fontSize*1.2) if b else None)(render_emoji_as_image(emoji_char, style.fontSize)) | |
if emoji_flowable: flowables.append(emoji_flowable) | |
else: | |
formatted_part = re.sub(r'_(.*?)_', r'<i>\1</i>', re.sub(r'\*\*(.*?)\*\*', r'<b>\1</b>', part)) | |
flowables.append(Paragraph(formatted_part, style)) | |
if not flowables: return [Spacer(0, 0)] | |
table = Table([flowables], colWidths=[None]*len(flowables)) | |
table.setStyle(TableStyle([('VALIGN', (0,0), (-1,-1), 'MIDDLE'), ('LEFTPADDING', (0,0), (-1,-1), 0), ('RIGHTPADDING', (0,0), (-1,-1), 0)])) | |
return [table] | |
story, lines = [], markdown_text.split('\n') | |
in_code_block, in_table, first_heading, document_title = False, False, True, "Untitled Document" | |
code_block_text, table_data = "", [] | |
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.extend([Paragraph(escaped_code.replace('\n', '<br/>'), style_code), 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 | |
table_data.append([cell.strip() for cell in stripped_line.strip('|').split('|')]) | |
continue | |
if in_table: | |
in_table = False | |
if table_data: | |
processed_table_data = [[create_flowables_for_line(cell, style_normal)[0] for cell in row] for row in table_data[1:]] | |
header_row = [create_flowables_for_line(f"<b>{cell}</b>", style_table_header)[0] for cell in table_data[0]] | |
table = Table([header_row] + processed_table_data, hAlign='LEFT', repeatRows=1) | |
table.setStyle(TableStyle([('BACKGROUND', (0,0), (-1,0), colors.HexColor("#4a044e")), ('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.extend([table, Spacer(1, 0.2*inch)]) | |
table_data = [] | |
continue | |
if not stripped_line: continue | |
content, style, bullet_text = stripped_line, style_normal, None | |
if stripped_line.startswith("# "): | |
if not first_heading: story.append(PageBreak()) | |
content, style, first_heading = stripped_line.lstrip('# '), style_h1, False | |
if document_title == "Untitled Document": document_title = content | |
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, bullet_text = stripped_line[2:], 'โข ' | |
line_flowables = create_flowables_for_line(content, style) | |
if bullet_text: | |
list_item_table = Table([[Paragraph(bullet_text, style)] + line_flowables], colWidths=[style.fontSize*1.5] + [None]*len(line_flowables)) | |
list_item_table.setStyle(TableStyle([('VALIGN', (0,0), (-1,-1), 'TOP'), ('LEFTPADDING', (0,0), (-1,-1), 0), ('RIGHTPADDING', (0,0), (-1,-1), 0)])) | |
story.append(list_item_table) | |
else: story.extend(line_flowables) | |
return story, document_title | |
def generate_pdfs_api(files, ai_content, layouts, fonts, num_columns, header_text, footer_text, font_sizes, margins, use_svg_engine, progress=gr.Progress(track_tqdm=True)): | |
if not files and (not ai_content or "Golem awaits" in ai_content): raise gr.Error("Please conjure some content or upload a file 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.") | |
if not EMOJI_FONT_PATH: raise gr.Error("CRITICAL: Cannot generate PDFs. 'NotoColorEmoji-Regular.ttf' not found.") | |
shutil.rmtree(OUTPUT_DIR, ignore_errors=True); OUTPUT_DIR.mkdir() | |
shutil.rmtree(PREVIEW_DIR, ignore_errors=True); PREVIEW_DIR.mkdir() | |
image_files, pdf_files, txt_files = [], [], [] | |
if files: | |
for f in files: | |
p = Path(f.name); ext = p.suffix.lower() | |
if ext in ['.png','.jpg','.jpeg','.gif','.bmp','.tiff']: image_files.append(p) | |
elif ext == '.pdf': pdf_files.append(p) | |
else: txt_files.append(p) | |
log, all_text = "", [] | |
if ai_content and "Golem awaits" not in ai_content: all_text.append(ai_content) | |
for p in txt_files: | |
try: all_text.append(p.read_text(encoding='utf-8')) | |
except Exception as e: log += f"โ ๏ธ Failed to read {p.name}: {e}\n" | |
md_content = "\n\n---\n\n".join(all_text) | |
generated_pdfs = [] | |
EMOJI_IMAGE_CACHE.clear(); EMOJI_SVG_CACHE.clear() | |
for layout in progress.tqdm(layouts, desc="brewing potions..."): | |
for font in progress.tqdm(fonts, desc=f"enchanting scrolls with {layout}..."): | |
merger = PdfWriter() | |
if md_content: | |
buffer, (story, title) = io.BytesIO(), markdown_to_story(md_content, font, font_sizes, use_svg_engine) | |
doc = BaseDocTemplate(buffer, pagesize=LAYOUTS[layout]["size"], leftMargin=margins[2]*inch, rightMargin=margins[3]*inch, topMargin=margins[0]*inch, bottomMargin=margins[1]*inch) | |
frame_w = (doc.width / num_columns) - (num_columns - 1)*0.1*inch | |
frames = [Frame(doc.leftMargin + i*(frame_w+0.2*inch), doc.bottomMargin, frame_w, doc.height, id=f'col_{i}') for i in range(num_columns)] | |
doc.addPageTemplates([PageTemplate(id='main', frames=frames, onPage=partial(_draw_header_footer, header_text=header_text, footer_text=footer_text, title=title))]) | |
doc.build(story); buffer.seek(0); merger.append(fileobj=buffer) | |
for p in image_files: | |
try: | |
with Image.open(p) as img: w, h = img.size | |
w_pt, h_pt = w * (inch/72), h * (inch/72) | |
buffer = io.BytesIO() | |
SimpleDocTemplate(buffer, pagesize=(w_pt, h_pt), leftMargin=0,rightMargin=0,topMargin=0,bottomMargin=0).build([ReportLabImage(p, width=w_pt, height=h_pt)]) | |
buffer.seek(0); merger.append(fileobj=buffer) | |
except Exception as e: log += f"โ ๏ธ Failed to process image {p.name}: {e}\n" | |
for p in pdf_files: | |
try: merger.append(str(p)) | |
except Exception as e: log += f"โ ๏ธ Failed to merge PDF {p.name}: {e}\n" | |
if len(merger.pages) > 0: | |
filename = f"Scroll_{layout.replace(' ','')}_{font}_x{num_columns}_{datetime.datetime.now().strftime('%H%M%S')}.pdf" | |
out_path = OUTPUT_DIR / filename | |
with open(out_path, "wb") as f: merger.write(f) | |
generated_pdfs.append(out_path); log += f"โ Alchemized: {filename}\n" | |
previews = [p for p in [create_pdf_preview(pdf) for pdf in generated_pdfs] if p] | |
return previews, log if log else "โจ All scrolls alchemized successfully! โจ", [str(p) for p in generated_pdfs] | |
# --- Gradio UI Definition --- | |
AVAILABLE_FONTS = register_local_fonts() | |
def get_theme(): | |
desired, fallback = "MedievalSharp", ("ui-sans-serif", "system-ui", "sans-serif") | |
font_family = fallback | |
if any(desired in s for s in AVAILABLE_FONTS): | |
font_family = (gr.themes.GoogleFont(desired |