awacke1 commited on
Commit
6d888b2
ยท
verified ยท
1 Parent(s): d8f01e5

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +225 -419
app.py CHANGED
@@ -5,475 +5,281 @@ import re
5
  import os
6
  import shutil
7
  import fitz # PyMuPDF
8
- from PIL import Image, ImageDraw, ImageFont
9
- from collections import defaultdict
10
  import io
11
- from pypdf import PdfWriter
12
- import random
13
 
14
- # New imports for the SVG Emoji Engine
15
- from lxml import etree
16
- from svglib.svglib import svg2rlg
17
-
18
- from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, PageBreak, BaseDocTemplate, Frame, PageTemplate, Image as ReportLabImage, Flowable
19
  from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
20
- from reportlab.lib.pagesizes import letter, A4, legal, landscape
21
  from reportlab.lib.units import inch
22
  from reportlab.lib import colors
23
  from reportlab.pdfbase import pdfmetrics
24
  from reportlab.pdfbase.ttfonts import TTFont
25
- from functools import partial
26
 
27
- # --- Configuration & Setup ---
28
  CWD = Path.cwd()
29
- TEMP_DIR = CWD / "temp_icons"
30
- LAYOUTS = {
31
- "A4 Portrait": {"size": A4},
32
- "A4 Landscape": {"size": landscape(A4)},
33
- "Letter Portrait": {"size": letter},
34
- "Letter Landscape": {"size": landscape(letter)},
35
- "Legal Portrait": {"size": legal},
36
- "Legal Landscape": {"size": landscape(legal)},
37
- }
38
- OUTPUT_DIR = CWD / "generated_pdfs"
39
- PREVIEW_DIR = CWD / "previews"
40
- FONT_DIR = CWD # Assumes fonts are in the same directory as the script
41
-
42
- # Create necessary directories
43
- OUTPUT_DIR.mkdir(exist_ok=True)
44
- PREVIEW_DIR.mkdir(exist_ok=True)
45
- TEMP_DIR.mkdir(exist_ok=True)
46
 
 
 
 
47
 
48
  # --- Font & Emoji Handling ---
49
- EMOJI_IMAGE_CACHE, EMOJI_SVG_CACHE = {}, {}
50
- EMOJI_FONT_PATH, UI_FONT_PATH = None, None
51
 
52
- def register_local_fonts():
53
- """Finds and registers all .ttf files from the application's base directory."""
54
- global EMOJI_FONT_PATH, UI_FONT_PATH
55
- print("--- Font Registration Process Starting ---")
56
- text_font_names = []
57
 
 
58
  noto_emoji_path = FONT_DIR / "NotoColorEmoji-Regular.ttf"
59
  if noto_emoji_path.exists():
60
  EMOJI_FONT_PATH = str(noto_emoji_path)
 
61
  else:
62
- print("CRITICAL: 'NotoColorEmoji-Regular.ttf' not found. Color emojis will not work.")
63
-
64
- print(f"Scanning for fonts in: {FONT_DIR.absolute()}")
65
- font_files = list(FONT_DIR.glob("*.ttf"))
66
-
67
- for font_path in font_files:
68
- try:
69
- font_name = font_path.stem
70
- if not UI_FONT_PATH: UI_FONT_PATH = str(font_path) # Grab first available font for UI icons
71
-
72
- pdfmetrics.registerFont(TTFont(font_name, str(font_path)))
73
- pdfmetrics.registerFontFamily(font_name, normal=font_name, bold=font_name, italic=font_name, boldItalic=font_name)
74
-
75
- if "notocoloremoji-regular" not in font_name.lower() and "notoemoji" not in font_name.lower():
76
- text_font_names.append(font_name)
77
- except Exception as e:
78
- print(f"Could not register font {font_path.name}: {e}")
79
-
80
- if not text_font_names:
81
- text_font_names.append('Helvetica')
82
- print(f"Successfully registered user-selectable fonts: {text_font_names}")
83
- return sorted(text_font_names)
84
-
85
- def render_emoji_as_image(emoji_char, size_pt):
86
- if not EMOJI_FONT_PATH: return None
87
- if (emoji_char, size_pt) in EMOJI_IMAGE_CACHE: return EMOJI_IMAGE_CACHE[(emoji_char, size_pt)]
88
- try:
89
- rect, doc = fitz.Rect(0, 0, size_pt*1.5, size_pt*1.5), fitz.open()
90
- page = doc.new_page(width=rect.width, height=rect.height)
91
- page.insert_font(fontname="emoji", fontfile=EMOJI_FONT_PATH)
92
- page.insert_text(fitz.Point(0, size_pt * 1.1), emoji_char, fontname="emoji", fontsize=size_pt)
93
- pix, img_buffer = page.get_pixmap(alpha=True, dpi=300), io.BytesIO(pix.tobytes("png"))
94
- doc.close(); img_buffer.seek(0)
95
- EMOJI_IMAGE_CACHE[(emoji_char, size_pt)] = img_buffer
96
- return img_buffer
97
- except Exception as e: print(f"Could not render emoji {emoji_char} as image: {e}"); return None
98
-
99
- def render_emoji_as_svg(emoji_char, size_pt):
100
- if not EMOJI_FONT_PATH: return None
101
- if (emoji_char, size_pt) in EMOJI_SVG_CACHE: return EMOJI_SVG_CACHE[(emoji_char, size_pt)]
102
  try:
103
- doc = fitz.open(); page = doc.new_page(width=1000, height=1000)
104
- page.insert_font(fontname="emoji", fontfile=EMOJI_FONT_PATH)
105
- svg_image = page.get_svg_image(page.rect, text=emoji_char, fontname="emoji")
106
- doc.close()
107
- drawing = svg2rlg(io.BytesIO(svg_image.encode('utf-8')))
108
- scale_factor = (size_pt * 1.2) / drawing.height
109
- drawing.width, drawing.height = drawing.width*scale_factor, drawing.height*scale_factor
110
- drawing.scale(scale_factor, scale_factor)
111
- EMOJI_SVG_CACHE[(emoji_char, size_pt)] = drawing
112
- return drawing
113
- except Exception as e: print(f"Could not render emoji {emoji_char} as SVG: {e}"); return None
114
 
115
- # --- AI Content Generation (Simulation) ---
116
- def generate_ai_content_api(prompt):
117
  """
118
- Simulates a call to an LLM to generate markdown content.
 
119
  """
120
- if not prompt:
121
- 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! โœจ"
122
-
123
- import time
124
- time.sleep(1.5)
125
-
126
- sample_story = f"""
127
- # The Quest for the Sunstone โ˜€๏ธ
128
-
129
- 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.
130
-
131
- ## The Prophecy ๐Ÿ“œ
132
-
133
- An ancient prophecy spoke of a hero who would rekindle the stone. It read:
134
- > When darkness drapes the verdant ground,
135
- > A soul of courage shall be found.
136
- > Through trials of fire ๐Ÿ”ฅ, wit, and might,
137
- > They'll bring once more the sacred light.
138
-
139
- ## The Chosen One ๐Ÿฆธโ€โ™€๏ธ
140
-
141
- 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.
142
- """
143
- return f"# Golem's Vision for: '{prompt}'\n\n{sample_story}"
144
-
145
-
146
- # --- UI & File Handling ---
147
- FILE_ICONS = defaultdict(lambda: 'โ“', {
148
- '.md': '๐Ÿ“œ', '.txt': '๐Ÿ“„', '.py': '๐Ÿ', '.js': 'โšก', '.html': '๐ŸŒ', '.css': '๐ŸŽจ', '.json': '๏ฟฝ',
149
- '.png': '๐Ÿ–ผ๏ธ', '.jpg': '๐Ÿ–ผ๏ธ', '.jpeg': '๐Ÿ–ผ๏ธ', '.gif': '๐Ÿ–ผ๏ธ', '.bmp': '๐Ÿ–ผ๏ธ', '.tiff': '๐Ÿ–ผ๏ธ',
150
- '.pdf': '๐Ÿ“š'
151
- })
152
 
153
- def create_file_icon_image(file_path: Path):
154
- """Creates a preview image for a file with an icon and its name."""
155
- icon = FILE_ICONS[file_path.suffix.lower()]
156
- img = Image.new('RGB', (150, 100), color='#1f2937')
157
- draw = ImageDraw.Draw(img)
158
  try:
159
- icon_font = ImageFont.truetype(EMOJI_FONT_PATH, 40) if EMOJI_FONT_PATH else ImageFont.load_default()
160
- text_font = ImageFont.truetype(UI_FONT_PATH, 10) if UI_FONT_PATH else ImageFont.load_default()
161
- except:
162
- icon_font, text_font = ImageFont.load_default(), ImageFont.load_default()
163
 
164
- draw.text((75, 40), icon, font=icon_font, anchor="mm", fill="#a855f7")
165
- draw.text((75, 80), file_path.name, font=text_font, anchor="mm", fill="#d1d5db")
166
-
167
- out_path = TEMP_DIR / f"{file_path.stem}_{file_path.suffix[1:]}.png"
168
- img.save(out_path)
169
- return out_path
170
-
171
- def update_staging_and_manuscript(files, current_manuscript):
172
- """Processes uploaded files, updates staging galleries, and consolidates text content."""
173
- shutil.rmtree(TEMP_DIR, ignore_errors=True); TEMP_DIR.mkdir(exist_ok=True)
174
-
175
- text_gallery, image_gallery, pdf_gallery = [], [], []
176
- text_content_to_add = []
177
- has_text_files = False
178
-
179
- if not files:
180
- return text_gallery, image_gallery, pdf_gallery, current_manuscript
181
-
182
- sorted_files = sorted(files, key=lambda x: Path(x.name).suffix)
183
-
184
- for file_obj in sorted_files:
185
- file_path = Path(file_obj.name)
186
- ext = file_path.suffix.lower()
187
 
188
- if ext in ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.tiff']:
189
- image_gallery.append((file_path, file_path.name))
190
- elif ext == '.pdf':
191
- pdf_gallery.append((create_file_icon_image(file_path), file_path.name))
192
- else: # Treat everything else as a text file
193
- has_text_files = True
194
- text_gallery.append((create_file_icon_image(file_path), file_path.name))
195
- try:
196
- with open(file_path, 'r', encoding='utf-8') as f:
197
- text_content_to_add.append(f.read())
198
- except Exception as e:
199
- print(f"Could not read {file_path.name}: {e}")
200
 
201
- # If any text-based files were uploaded, their content replaces the manuscript.
202
- # Otherwise, the manuscript is left untouched.
203
- if has_text_files:
204
- updated_manuscript = "\n\n---\n\n".join(text_content_to_add)
205
- else:
206
- updated_manuscript = current_manuscript
207
 
208
- return text_gallery, image_gallery, pdf_gallery, updated_manuscript
 
 
 
 
 
209
 
210
- # --- Main PDF Generation Logic (heavily adapted from before) ---
211
- def _draw_header_footer(canvas, doc, header_text, footer_text, title):
212
- """Draws the header and footer on each page."""
213
- canvas.saveState()
214
- page_num = canvas.getPageNumber()
 
 
215
 
216
- final_footer_text = footer_text.replace("[Page #]", str(page_num)).replace("[Total Pages]", str(doc.page))
217
- final_header_text = header_text.replace("[Page #]", str(page_num)).replace("[Title]", title)
218
-
219
- if final_header_text:
220
- canvas.setFont('Helvetica', 9)
221
- canvas.setFillColor(colors.grey)
222
- canvas.drawRightString(doc.width + doc.leftMargin, doc.height + doc.topMargin + 0.25*inch, final_header_text)
223
-
224
- if final_footer_text:
225
- canvas.setFont('Helvetica', 9)
226
- canvas.setFillColor(colors.grey)
227
- canvas.drawString(doc.leftMargin, doc.bottomMargin - 0.25*inch, final_footer_text)
228
-
229
- canvas.restoreState()
230
-
231
- def markdown_to_story(markdown_text, font_name, font_sizes, use_svg_engine):
232
  styles = getSampleStyleSheet()
233
- font_size_body, font_size_h1, font_size_h2, font_size_h3 = font_sizes
234
- style_normal = ParagraphStyle('BodyText', fontName=font_name, fontSize=font_size_body, leading=font_size_body*1.4, spaceAfter=6)
235
- 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"))
236
- 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"))
237
- 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"))
238
- 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)
239
- style_table_header = ParagraphStyle('TableHeader', parent=style_normal, fontName=font_name) # Bold is handled in text
240
-
241
  emoji_pattern = re.compile(f"([{re.escape(''.join(map(chr, range(0x1f600, 0x1f650))))}"
242
  f"{re.escape(''.join(map(chr, range(0x1f300, 0x1f5ff))))}"
243
  f"{re.escape(''.join(map(chr, range(0x1f900, 0x1f9ff))))}"
244
- f"{re.escape(''.join(map(chr, range(0x2600, 0x26ff))))}"
245
- f"{re.escape(''.join(map(chr, range(0x2700, 0x27bf))))}]+)")
246
-
247
- def create_flowables_for_line(text, style):
248
- parts, flowables = emoji_pattern.split(text), []
 
 
 
 
249
  for part in parts:
250
  if not part: continue
 
251
  if emoji_pattern.match(part):
 
252
  for emoji_char in part:
253
- 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))
254
- if emoji_flowable: flowables.append(emoji_flowable)
 
 
 
255
  else:
256
- formatted_part = re.sub(r'_(.*?)_', r'<i>\1</i>', re.sub(r'\*\*(.*?)\*\*', r'<b>\1</b>', part))
257
- flowables.append(Paragraph(formatted_part, style))
258
- if not flowables: return [Spacer(0, 0)]
259
- table = Table([flowables], colWidths=[None]*len(flowables))
260
- table.setStyle(TableStyle([('VALIGN', (0,0), (-1,-1), 'MIDDLE'), ('LEFTPADDING', (0,0), (-1,-1), 0), ('RIGHTPADDING', (0,0), (-1,-1), 0)]))
261
- return [table]
262
-
263
- story, lines = [], markdown_text.split('\n')
264
- in_code_block, in_table, first_heading, document_title = False, False, True, "Untitled Document"
265
- code_block_text, table_data = "", []
266
 
 
 
 
 
 
 
 
 
 
 
267
  for line in lines:
268
  stripped_line = line.strip()
269
- if stripped_line.startswith("```"):
270
- if in_code_block:
271
- escaped_code = code_block_text.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
272
- story.extend([Paragraph(escaped_code.replace('\n', '<br/>'), style_code), Spacer(1, 0.1*inch)])
273
- in_code_block, code_block_text = False, ""
274
- else: in_code_block = True
275
- continue
276
- if in_code_block: code_block_text += line + '\n'; continue
277
- if stripped_line.startswith('|'):
278
- if not in_table: in_table = True
279
- if all(c in '-|: ' for c in stripped_line): continue
280
- table_data.append([cell.strip() for cell in stripped_line.strip('|').split('|')])
281
- continue
282
- if in_table:
283
- in_table = False
284
- if table_data:
285
- processed_table_data = [[create_flowables_for_line(cell, style_normal)[0] for cell in row] for row in table_data[1:]]
286
- header_row = [create_flowables_for_line(f"<b>{cell}</b>", style_table_header)[0] for cell in table_data[0]]
287
- table = Table([header_row] + processed_table_data, hAlign='LEFT', repeatRows=1)
288
- 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)]))
289
- story.extend([table, Spacer(1, 0.2*inch)])
290
- table_data = []
291
- continue
292
-
293
- if not stripped_line: continue
294
- content, style, bullet_text = stripped_line, style_normal, None
295
- if stripped_line.startswith("# "):
296
- if not first_heading: story.append(PageBreak())
297
- content, style, first_heading = stripped_line.lstrip('# '), style_h1, False
298
- if document_title == "Untitled Document": document_title = content
299
- elif stripped_line.startswith("## "): content, style = stripped_line.lstrip('## '), style_h2
300
- elif stripped_line.startswith("### "): content, style = stripped_line.lstrip('### '), style_h3
301
- elif stripped_line.startswith(("- ", "* ")): content, bullet_text = stripped_line[2:], 'โ€ข '
302
 
303
- line_flowables = create_flowables_for_line(content, style)
304
- if bullet_text:
305
- list_item_table = Table([[Paragraph(bullet_text, style)] + line_flowables], colWidths=[style.fontSize*1.5] + [None]*len(line_flowables))
306
- list_item_table.setStyle(TableStyle([('VALIGN', (0,0), (-1,-1), 'TOP'), ('LEFTPADDING', (0,0), (-1,-1), 0), ('RIGHTPADDING', (0,0), (-1,-1), 0)]))
307
- story.append(list_item_table)
308
- else: story.extend(line_flowables)
309
- return story, document_title
310
-
311
- 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)):
312
- 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!")
313
- if not layouts: raise gr.Error("You must select a scroll (page layout)!")
314
- if not fonts: raise gr.Error("A scribe needs a font! Please choose one.")
315
- if not EMOJI_FONT_PATH: raise gr.Error("CRITICAL: Cannot generate PDFs. 'NotoColorEmoji-Regular.ttf' not found.")
316
 
317
- shutil.rmtree(OUTPUT_DIR, ignore_errors=True); OUTPUT_DIR.mkdir()
318
- shutil.rmtree(PREVIEW_DIR, ignore_errors=True); PREVIEW_DIR.mkdir()
319
-
320
- image_files, pdf_files, txt_files = [], [], []
321
- if files:
322
- for f in files:
323
- p = Path(f.name); ext = p.suffix.lower()
324
- if ext in ['.png','.jpg','.jpeg','.gif','.bmp','.tiff']: image_files.append(p)
325
- elif ext == '.pdf': pdf_files.append(p)
326
- else: txt_files.append(p)
327
 
328
- log, all_text = "", []
329
- if ai_content and "Golem awaits" not in ai_content: all_text.append(ai_content)
330
- # The uploaded text content is now part of ai_content, so we don't need to re-read it here.
331
- # This was part of the original bug.
332
- md_content = ai_content
 
 
 
 
 
 
 
 
 
 
 
 
333
 
334
- generated_pdfs = []
335
- EMOJI_IMAGE_CACHE.clear(); EMOJI_SVG_CACHE.clear()
336
-
337
- for layout in progress.tqdm(layouts, desc="brewing potions..."):
338
- for font in progress.tqdm(fonts, desc=f"enchanting scrolls with {layout}..."):
339
- merger = PdfWriter()
340
- if md_content:
341
- buffer, (story, title) = io.BytesIO(), markdown_to_story(md_content, font, font_sizes, use_svg_engine)
342
- doc = BaseDocTemplate(buffer, pagesize=LAYOUTS[layout]["size"], leftMargin=margins[2]*inch, rightMargin=margins[3]*inch, topMargin=margins[0]*inch, bottomMargin=margins[1]*inch)
343
- frame_w = (doc.width / num_columns) - (num_columns - 1)*0.1*inch
344
- 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)]
345
- doc.addPageTemplates([PageTemplate(id='main', frames=frames, onPage=partial(_draw_header_footer, header_text=header_text, footer_text=footer_text, title=title))])
346
- doc.build(story); buffer.seek(0); merger.append(fileobj=buffer)
347
-
348
- for p in image_files:
349
- try:
350
- with Image.open(p) as img: w, h = img.size
351
- w_pt, h_pt = w * (inch/72), h * (inch/72)
352
- buffer = io.BytesIO()
353
- SimpleDocTemplate(buffer, pagesize=(w_pt, h_pt), leftMargin=0,rightMargin=0,topMargin=0,bottomMargin=0).build([ReportLabImage(p, width=w_pt, height=h_pt)])
354
- buffer.seek(0); merger.append(fileobj=buffer)
355
- except Exception as e: log += f"โš ๏ธ Failed to process image {p.name}: {e}\n"
356
- for p in pdf_files:
357
- try: merger.append(str(p))
358
- except Exception as e: log += f"โš ๏ธ Failed to merge PDF {p.name}: {e}\n"
359
-
360
- if len(merger.pages) > 0:
361
- filename = f"Scroll_{layout.replace(' ','')}_{font}_x{num_columns}_{datetime.datetime.now().strftime('%H%M%S')}.pdf"
362
- out_path = OUTPUT_DIR / filename
363
- with open(out_path, "wb") as f: merger.write(f)
364
- generated_pdfs.append(out_path); log += f"โœ… Alchemized: {filename}\n"
365
 
366
- previews = [p for p in [create_pdf_preview(pdf) for pdf in generated_pdfs] if p]
367
- return previews, log if log else "โœจ All scrolls alchemized successfully! โœจ", [str(p) for p in generated_pdfs]
368
-
369
- # --- Gradio UI Definition ---
370
- AVAILABLE_FONTS = register_local_fonts()
371
-
372
- def get_theme():
373
- """Dynamically selects a font for the theme to avoid warnings for missing fonts."""
374
- desired = "MedievalSharp"
375
- fallback = ("ui-sans-serif", "system-ui", "sans-serif")
376
- font_family = fallback
377
-
378
- # Check if the desired font is in the list of registered fonts
379
- if any(desired in s for s in AVAILABLE_FONTS):
380
- font_family = (gr.themes.GoogleFont(desired),) + fallback
381
- elif AVAILABLE_FONTS:
382
- # If desired font is not found, use the first available font as a better fallback
383
- first_font = AVAILABLE_FONTS[0]
384
- print(f"WARNING: '{desired}' font not found. Using '{first_font}' for UI theme instead.")
385
- font_family = (gr.themes.GoogleFont(first_font),) + fallback
386
- else:
387
- # Absolute fallback if no custom fonts are found
388
- print(f"WARNING: No custom fonts found. Using system default for UI.")
389
-
390
- return gr.themes.Base(
391
- primary_hue=gr.themes.colors.purple,
392
- secondary_hue=gr.themes.colors.indigo,
393
- neutral_hue=gr.themes.colors.gray,
394
- font=font_family
395
- ).set(
396
- body_background_fill="#111827",
397
- body_text_color="#d1d5db",
398
- button_primary_background_fill="#a855f7",
399
- button_primary_text_color="#ffffff",
400
- button_secondary_background_fill="#6366f1",
401
- button_secondary_text_color="#ffffff",
402
- block_background_fill="#1f2937",
403
- block_label_background_fill="#1f2937",
404
- block_title_text_color="#a855f7",
405
- input_background_fill="#374151"
406
- )
407
-
408
- with gr.Blocks(theme=get_theme(), title="The PDF Alchemist") as demo:
409
- gr.Markdown("# โœจ The PDF Alchemist โœจ")
410
- gr.Markdown("A single-page grimoire to turn your ideas into beautifully crafted PDF scrolls. Use the power of AI or upload your own treasures.")
411
-
412
- with gr.Row(equal_height=False):
413
- # --- LEFT COLUMN: INPUTS & CONTROLS ---
414
- with gr.Column(scale=2):
415
- with gr.Group():
416
- with gr.Accordion("๐Ÿ“œ Content Crucible (Your Ingredients)", open=True):
417
- gr.Markdown("### ๐Ÿค– Command Your Idea Golem")
418
- ai_prompt = gr.Textbox(label="Incantation (Prompt)", placeholder="e.g., 'A recipe for a dragon's breath chili...'")
419
- generate_ai_btn = gr.Button("๐Ÿง  Animate Golem!")
420
- ai_content_output = gr.Textbox(label="Golem's Manuscript (Editable)", lines=10, interactive=True, value="# The Golem awaits your command!\n\n")
421
- gr.Markdown("<hr style='border-color: #374151; margin-top: 20px; margin-bottom: 20px;'>")
422
- gr.Markdown("### ๐Ÿ“ค Add Your Physical Treasures")
423
- uploaded_files = gr.File(label="Upload Files (TXT, MD, Code, Images, PDFs)", file_count="multiple", file_types=['text', '.md', 'image', '.pdf', '.py', '.js', '.html', '.css', '.json'])
424
-
425
- with gr.Accordion("๐Ÿ“ Arcane Blueprints (Layout & Structure)", open=True):
426
- selected_layouts = gr.CheckboxGroup(choices=list(LAYOUTS.keys()), label="Page Layouts", value=["A4 Portrait"])
427
- num_columns_slider = gr.Slider(label="Number of Text Columns", minimum=1, maximum=4, step=1, value=1)
428
- header_input = gr.Textbox(label="Header Inscription", value="[Title]", placeholder="e.g., Arcane Folio - [Page #]")
429
- footer_input = gr.Textbox(label="Footer Inscription", value="Page [Page #] of [Total Pages]", placeholder="e.g., Top Secret - Page [Page #]")
430
 
431
- with gr.Accordion("๐Ÿ’… Stylist's Sanctum (Fonts & Margins)", open=True):
432
- use_svg_engine_toggle = gr.Checkbox(label="Use SVG Emoji Engine (Vector Quality)", value=True, info="Toggle for higher quality, resolution-independent emojis. May be slower.")
433
- selected_fonts = gr.CheckboxGroup(choices=AVAILABLE_FONTS, label="Fonts", value=[AVAILABLE_FONTS[0]] if AVAILABLE_FONTS else [])
434
- with gr.Row():
435
- font_size_body_slider = gr.Slider(label="Body (pt)", minimum=8, maximum=16, step=1, value=10)
436
- font_size_h1_slider = gr.Slider(label="H1 (pt)", minimum=16, maximum=32, step=1, value=24)
437
- with gr.Row():
438
- font_size_h2_slider = gr.Slider(label="H2 (pt)", minimum=14, maximum=28, step=1, value=18)
439
- font_size_h3_slider = gr.Slider(label="H3 (pt)", minimum=12, maximum=24, step=1, value=14)
440
- with gr.Row():
441
- margin_top_slider = gr.Slider(label="Margin Top (in)", minimum=0.25, maximum=1.5, step=0.05, value=0.75)
442
- margin_bottom_slider = gr.Slider(label="Margin Bottom (in)", minimum=0.25, maximum=1.5, step=0.05, value=0.75)
443
- with gr.Row():
444
- margin_left_slider = gr.Slider(label="Margin Left (in)", minimum=0.25, maximum=1.5, step=0.05, value=0.75)
445
- margin_right_slider = gr.Slider(label="Margin Right (in)", minimum=0.25, maximum=1.5, step=0.05, value=0.75)
446
-
447
- generate_pdfs_btn = gr.Button("๐Ÿ”ฎ Alchemize PDF!", variant="primary", size="lg")
448
-
449
- # --- RIGHT COLUMN: STAGING & OUTPUTS ---
450
- with gr.Column(scale=3):
451
- with gr.Group():
452
- with gr.Accordion("๐Ÿ” Staging Area (Your Uploaded Treasures)", open=True):
453
- text_gallery = gr.Gallery(label="๐Ÿ“œ Manuscripts & Code", show_label=True, columns=4, height=120, object_fit="contain")
454
- image_gallery = gr.Gallery(label="๐Ÿ–ผ๏ธ Images & Glyphs", show_label=True, columns=4, height=120, object_fit="contain")
455
- pdf_gallery = gr.Gallery(label="๐Ÿ“š Imported Scrolls", show_label=True, columns=4, height=120, object_fit="contain")
456
-
457
- with gr.Group():
458
- gr.Markdown("### โš—๏ธ Transmuted Scrolls (Final PDFs)")
459
- final_gallery_output = gr.Gallery(label="PDF Previews", show_label=False, elem_id="gallery", columns=2, height=400, object_fit="contain")
460
- log_output = gr.Markdown(label="Alchemist's Log", value="Your log of successful transmutations will appear here...")
461
- downloadable_files_output = gr.Files(label="Collect Your Scrolls")
462
 
463
- # --- API Calls & Examples ---
464
- font_size_inputs = [font_size_body_slider, font_size_h1_slider, font_size_h2_slider, font_size_h3_slider]
465
- margin_inputs = [margin_top_slider, margin_bottom_slider, margin_left_slider, margin_right_slider]
466
-
467
- uploaded_files.upload(update_staging_and_manuscript, inputs=[uploaded_files, ai_content_output], outputs=[text_gallery, image_gallery, pdf_gallery, ai_content_output])
468
- generate_ai_btn.click(generate_ai_content_api, inputs=[ai_prompt], outputs=[ai_content_output])
469
 
470
- generate_pdfs_btn.click(generate_pdfs_api,
471
- inputs=[uploaded_files, ai_content_output, selected_layouts, selected_fonts, num_columns_slider, header_input, footer_input] + font_size_inputs + margin_inputs + [use_svg_engine_toggle],
472
- outputs=[final_gallery_output, log_output, downloadable_files_output])
473
 
474
- gr.Examples(examples=[["A technical summary of how alchemy works"],["A short poem about a grumpy gnome"]], inputs=[ai_prompt], outputs=[ai_content_output], fn=generate_ai_content_api, cache_examples=False)
 
 
475
 
 
476
  if __name__ == "__main__":
477
- if not EMOJI_FONT_PATH:
478
- print("\n" + "="*80 + "\nCRITICAL WARNING: 'NotoColorEmoji-Regular.ttf' not found.\nThe application will fail to generate PDFs with color emojis.\n" + "="*80 + "\n")
479
- demo.launch(debug=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  import os
6
  import shutil
7
  import fitz # PyMuPDF
8
+ from PIL import Image
 
9
  import io
 
 
10
 
11
+ from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, PageBreak, BaseDocTemplate, Frame, PageTemplate, Image as ReportLabImage
 
 
 
 
12
  from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
13
+ from reportlab.lib.pagesizes import letter
14
  from reportlab.lib.units import inch
15
  from reportlab.lib import colors
16
  from reportlab.pdfbase import pdfmetrics
17
  from reportlab.pdfbase.ttfonts import TTFont
 
18
 
19
+ # --- Configuration ---
20
  CWD = Path.cwd()
21
+ # Create dedicated directories for clarity
22
+ INPUT_DIR = CWD / "uploaded_files"
23
+ OUTPUT_DIR = CWD / "output_pdfs"
24
+ TEMP_DIR = CWD / "temp_emoji_images"
25
+ FONT_DIR = CWD # Assumes fonts are in the same directory as the script
 
 
 
 
 
 
 
 
 
 
 
 
26
 
27
+ # Ensure all directories exist
28
+ for d in [INPUT_DIR, OUTPUT_DIR, TEMP_DIR]:
29
+ d.mkdir(exist_ok=True)
30
 
31
  # --- Font & Emoji Handling ---
32
+ EMOJI_FONT_PATH = None
33
+ EMOJI_IMAGE_CACHE = {}
34
 
35
+ def setup_fonts():
36
+ """Finds the NotoColorEmoji font, which is critical for this process."""
37
+ global EMOJI_FONT_PATH
38
+ print("--- Setting up fonts ---")
 
39
 
40
+ # Locate the essential NotoColorEmoji font
41
  noto_emoji_path = FONT_DIR / "NotoColorEmoji-Regular.ttf"
42
  if noto_emoji_path.exists():
43
  EMOJI_FONT_PATH = str(noto_emoji_path)
44
+ print(f"Found emoji font: {EMOJI_FONT_PATH}")
45
  else:
46
+ print("CRITICAL ERROR: 'NotoColorEmoji-Regular.ttf' not found in the application directory.")
47
+ print("This file is required to render emojis as images. Please add it to the directory.")
48
+ # Raise an error to stop the app from running in a broken state
49
+ raise FileNotFoundError("Could not find NotoColorEmoji-Regular.ttf. The application cannot proceed.")
50
+
51
+ # Register a basic font for ReportLab
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  try:
53
+ pdfmetrics.registerFont(TTFont('DejaVuSans', 'DejaVuSans.ttf'))
54
+ pdfmetrics.registerFontFamily('DejaVuSans', normal='DejaVuSans', bold='DejaVuSans-Bold', italic='DejaVuSans-Oblique', boldItalic='DejaVuSans-BoldOblique')
55
+ except:
56
+ print("Warning: DejaVuSans font not found. Falling back to Helvetica. Please add DejaVuSans.ttf for better Unicode support.")
 
 
 
 
 
 
 
57
 
58
+ def render_emoji_as_image(emoji_char, size_pt):
 
59
  """
60
+ Takes a single emoji character and renders it as a transparent PNG image in memory.
61
+ This is the core of the solution to ensure emojis appear in color in any PDF viewer.
62
  """
63
+ if not EMOJI_FONT_PATH:
64
+ print("Cannot render emoji: Emoji font path not set.")
65
+ return None
66
+
67
+ # Use a cache to avoid re-rendering the same emoji multiple times
68
+ if (emoji_char, size_pt) in EMOJI_IMAGE_CACHE:
69
+ return EMOJI_IMAGE_CACHE[(emoji_char, size_pt)]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
 
 
 
 
 
 
71
  try:
72
+ # Use PyMuPDF (fitz) to draw the emoji onto a temporary, transparent canvas
73
+ rect = fitz.Rect(0, 0, size_pt * 1.5, size_pt * 1.5)
74
+ doc = fitz.open()
75
+ page = doc.new_page(width=rect.width, height=rect.height)
76
 
77
+ # Load the color emoji font
78
+ page.insert_font(fontname="emoji", fontfile=EMOJI_FONT_PATH)
79
+
80
+ # Insert the emoji character. The vertical alignment may need tweaking.
81
+ page.insert_text(fitz.Point(0, size_pt * 1.1), emoji_char, fontname="emoji", fontsize=size_pt)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
 
83
+ # Get a high-resolution PNG of the emoji with a transparent background
84
+ pix = page.get_pixmap(alpha=True, dpi=300)
85
+ doc.close()
 
 
 
 
 
 
 
 
 
86
 
87
+ # Save the PNG to an in-memory buffer
88
+ img_buffer = io.BytesIO(pix.tobytes("png"))
89
+ img_buffer.seek(0)
 
 
 
90
 
91
+ # Add the buffer to the cache and return it
92
+ EMOJI_IMAGE_CACHE[(emoji_char, size_pt)] = img_buffer
93
+ return img_buffer
94
+ except Exception as e:
95
+ print(f"ERROR: Could not render emoji '{emoji_char}': {e}")
96
+ return None
97
 
98
+ # --- PDF Generation ---
99
+ def create_pdf_from_markdown(md_filepath: Path):
100
+ """
101
+ The main function to convert a single Markdown file into a PDF.
102
+ It reads the text, processes it line by line, and replaces emojis with images.
103
+ """
104
+ print(f"--- Starting PDF conversion for: {md_filepath.name} ---")
105
 
106
+ # Define styles for the PDF document
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  styles = getSampleStyleSheet()
108
+ # Use a font that supports a wide range of characters, if available
109
+ body_font = 'DejaVuSans' if 'DejaVuSans' in pdfmetrics.getRegisteredFontNames() else 'Helvetica'
110
+ style_body = ParagraphStyle('Body', fontName=body_font, fontSize=11, leading=14)
111
+ style_h1 = ParagraphStyle('H1', fontName=body_font, fontSize=24, leading=28, spaceAfter=12, textColor=colors.darkblue)
112
+ style_h2 = ParagraphStyle('H2', fontName=body_font, fontSize=18, leading=22, spaceAfter=10)
113
+
114
+ # Regex to find all emojis in a string
 
115
  emoji_pattern = re.compile(f"([{re.escape(''.join(map(chr, range(0x1f600, 0x1f650))))}"
116
  f"{re.escape(''.join(map(chr, range(0x1f300, 0x1f5ff))))}"
117
  f"{re.escape(''.join(map(chr, range(0x1f900, 0x1f9ff))))}"
118
+ f"{re.escape(''.join(map(chr, range(0x2600, 0x26ff))))}]+)")
119
+
120
+ def text_to_flowables(text, style):
121
+ """
122
+ Splits a line of text into a list of Paragraphs and Images.
123
+ This allows text and emoji-images to flow together on the same line.
124
+ """
125
+ parts = emoji_pattern.split(text)
126
+ flowables = []
127
  for part in parts:
128
  if not part: continue
129
+
130
  if emoji_pattern.match(part):
131
+ # This part is an emoji or a sequence of them
132
  for emoji_char in part:
133
+ img_buffer = render_emoji_as_image(emoji_char, style.fontSize)
134
+ if img_buffer:
135
+ # Create an Image object, slightly larger than the text for better spacing
136
+ img = ReportLabImage(img_buffer, height=style.fontSize * 1.2, width=style.fontSize * 1.2)
137
+ flowables.append(img)
138
  else:
139
+ # This part is plain text
140
+ flowables.append(Paragraph(part.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;'), style))
141
+
142
+ # Use a Table to keep all parts on the same line. This is a common ReportLab technique.
143
+ if flowables:
144
+ return Table([flowables], colWidths=[None] * len(flowables), style=[('VALIGN', (0,0), (-1,-1), 'MIDDLE')])
145
+ return None
 
 
 
146
 
147
+ # Read the markdown file
148
+ try:
149
+ with open(md_filepath, 'r', encoding='utf-8') as f:
150
+ lines = f.readlines()
151
+ except Exception as e:
152
+ print(f"ERROR: Could not read file {md_filepath.name}: {e}")
153
+ return None
154
+
155
+ # The "story" is ReportLab's list of things to draw in the PDF
156
+ story = []
157
  for line in lines:
158
  stripped_line = line.strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
 
160
+ # Simple Markdown parsing
161
+ if stripped_line.startswith('# '):
162
+ flowable = text_to_flowables(stripped_line[2:], style_h1)
163
+ elif stripped_line.startswith('## '):
164
+ flowable = text_to_flowables(stripped_line[2:], style_h2)
165
+ elif stripped_line:
166
+ flowable = text_to_flowables(stripped_line, style_body)
167
+ else:
168
+ flowable = Spacer(1, 0.2 * inch)
169
+
170
+ if flowable:
171
+ story.append(flowable)
 
172
 
173
+ # Generate a unique filename and path for the output PDF
174
+ timestamp = datetime.datetime.now().strftime("%Y-%m-%d_%H%M")
175
+ output_filename = f"{md_filepath.stem}_{timestamp}.pdf"
176
+ output_filepath = OUTPUT_DIR / output_filename
 
 
 
 
 
 
177
 
178
+ # Build the PDF document
179
+ doc = SimpleDocTemplate(str(output_filepath), pagesize=letter)
180
+ try:
181
+ doc.build(story)
182
+ print(f"SUCCESS: Successfully created PDF: {output_filename}")
183
+ return output_filepath
184
+ except Exception as e:
185
+ print(f"ERROR: Failed to build PDF for {md_filepath.name}. Reason: {e}")
186
+ return None
187
+
188
+ # --- Gradio UI and Logic ---
189
+ def process_uploads(files):
190
+ """
191
+ Takes uploaded files, processes each one into a PDF, and returns a list of generated filepaths.
192
+ """
193
+ if not files:
194
+ raise gr.Error("Please upload at least one Markdown (.md) file.")
195
 
196
+ # Clear caches and temp directories for a clean run
197
+ EMOJI_IMAGE_CACHE.clear()
198
+ shutil.rmtree(TEMP_DIR, ignore_errors=True); TEMP_DIR.mkdir(exist_ok=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
199
 
200
+ log_messages = []
201
+ generated_pdf_paths = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
202
 
203
+ for file_obj in files:
204
+ input_path = Path(file_obj.name)
205
+ log_messages.append(f"Processing '{input_path.name}'...")
206
+
207
+ # Core PDF creation step
208
+ output_path = create_pdf_from_markdown(input_path)
209
+
210
+ if output_path:
211
+ generated_pdf_paths.append(str(output_path))
212
+ log_messages.append(f"โœ… Success! PDF saved to '{output_path.name}'.")
213
+ else:
214
+ log_messages.append(f"โŒ Failed to process '{input_path.name}'. Check console for errors.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
215
 
216
+ # After processing, get the full list of all PDFs in the output directory for the gallery
217
+ all_pdfs_in_gallery = sorted([str(p) for p in OUTPUT_DIR.glob("*.pdf")], reverse=True)
 
 
 
 
218
 
219
+ return "\n".join(log_messages), all_pdfs_in_gallery
 
 
220
 
221
+ def refresh_gallery():
222
+ """Scans the output directory and returns a list of all PDFs found."""
223
+ return sorted([str(p) for p in OUTPUT_DIR.glob("*.pdf")], reverse=True)
224
 
225
+ # Main execution block
226
  if __name__ == "__main__":
227
+ # This must run once at startup to check for the required font
228
+ try:
229
+ setup_fonts()
230
+ except FileNotFoundError as e:
231
+ # If the font is missing, we stop the app from launching.
232
+ print("\n" + "="*60)
233
+ print(e)
234
+ print("The application cannot start without this font file.")
235
+ print("Please add 'NotoColorEmoji-Regular.ttf' and 'DejaVuSans.ttf' to your project directory.")
236
+ print("="*60)
237
+ exit() # Stop the script
238
+
239
+ # Define the Gradio Interface
240
+ with gr.Blocks(theme=gr.themes.Soft(primary_hue="blue"), title="Markdown-to-PDF Alchemist") as demo:
241
+ gr.Markdown("# ๐Ÿ“œ Markdown-to-PDF Alchemist")
242
+ gr.Markdown("Upload one or more `.md` files. This tool will convert them into PDFs, preserving emojis by rendering them as high-quality images. All generated PDFs will appear in the library below.")
243
+
244
+ with gr.Row():
245
+ with gr.Column(scale=1):
246
+ upload_button = gr.File(
247
+ label="Upload Markdown Files (.md)",
248
+ file_count="multiple",
249
+ file_types=[".md"],
250
+ )
251
+ generate_button = gr.Button("๐Ÿ”ฎ Alchemize to PDF", variant="primary")
252
+ log_output = gr.Textbox(label="Alchemist's Log", lines=8, interactive=False)
253
+
254
+ with gr.Column(scale=2):
255
+ gr.Markdown("### ๐Ÿ“š Generated PDF Library")
256
+ # The gallery will show the first page of the PDF as a preview
257
+ pdf_gallery = gr.Gallery(
258
+ label="Generated PDFs",
259
+ show_label=False,
260
+ elem_id="gallery",
261
+ columns=3,
262
+ object_fit="contain",
263
+ height="auto"
264
+ )
265
+ # This button allows manual refreshing of the gallery
266
+ refresh_button = gr.Button("๐Ÿ”„ Refresh Library")
267
+
268
+ # Define the actions when buttons are clicked
269
+ generate_button.click(
270
+ fn=process_uploads,
271
+ inputs=[upload_button],
272
+ outputs=[log_output, pdf_gallery]
273
+ )
274
+
275
+ refresh_button.click(
276
+ fn=refresh_gallery,
277
+ inputs=None,
278
+ outputs=[pdf_gallery]
279
+ )
280
+
281
+ # Load the gallery with existing PDFs when the app starts
282
+ demo.load(refresh_gallery, None, pdf_gallery)
283
+
284
+ # Launch the application
285
+ demo.launch(debug=True)