Spaces:
Sleeping
Sleeping
import os | |
import re | |
import sys | |
import glob | |
import logging | |
import gradio as gr | |
from typing import List, Dict, Any, Optional, Tuple | |
from bs4 import BeautifulSoup | |
import markdown | |
from markdown.extensions.tables import TableExtension | |
from markdown.extensions.fenced_code import FencedCodeExtension | |
from markdown.extensions.toc import TocExtension | |
from reportlab.lib.pagesizes import letter, A4 | |
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle | |
from reportlab.lib.units import inch | |
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, Image, PageBreak, Preformatted, ListFlowable, ListItem | |
from reportlab.lib.colors import HexColor, black, grey | |
from reportlab.lib import colors | |
from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_RIGHT | |
import html | |
import base64 | |
import requests | |
from PIL import Image as PilImage | |
import io | |
import tempfile | |
from datetime import datetime | |
# Set up logging | |
logging.basicConfig( | |
level=logging.INFO, | |
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' | |
) | |
logger = logging.getLogger(__name__) | |
class MarkdownToPDFConverter: | |
""" | |
Class to convert Markdown content to PDF using ReportLab. | |
""" | |
def __init__( | |
self, | |
output_path: str = "output.pdf", | |
page_size: str = "A4", | |
margins: Tuple[float, float, float, float] = (0.75, 0.75, 0.75, 0.75), | |
font_name: str = "Helvetica", | |
base_font_size: int = 10, | |
heading_scale: Dict[int, float] = None, | |
include_toc: bool = True, | |
code_style: str = "github" | |
): | |
""" | |
Initialize the converter with configuration options. | |
Args: | |
output_path: Path to save the PDF | |
page_size: Page size ("A4" or "letter") | |
margins: Tuple of margins (left, right, top, bottom) in inches | |
font_name: Base font name to use | |
base_font_size: Base font size in points | |
heading_scale: Dictionary of heading levels to font size multipliers | |
include_toc: Whether to include a table of contents | |
code_style: Style to use for code blocks | |
""" | |
self.output_path = output_path | |
self.page_size = A4 if page_size.upper() == "A4" else letter | |
self.margins = margins | |
self.font_name = font_name | |
self.base_font_size = base_font_size | |
self.heading_scale = heading_scale or { | |
1: 2.0, # H1 is 2.0x base font size | |
2: 1.7, # H2 is 1.7x base font size | |
3: 1.4, # H3 is 1.4x base font size | |
4: 1.2, # H4 is 1.2x base font size | |
5: 1.1, # H5 is 1.1x base font size | |
6: 1.0 # H6 is 1.0x base font size | |
} | |
self.include_toc = include_toc | |
self.code_style = code_style | |
# Initialize styles | |
self.styles = getSampleStyleSheet() | |
self._setup_styles() | |
# Initialize document elements | |
self.elements = [] | |
self.toc_entries = [] | |
def _setup_styles(self) -> None: | |
"""Set up custom paragraph styles for the document.""" | |
# Modify existing Normal style | |
self.styles['Normal'].fontName = self.font_name | |
self.styles['Normal'].fontSize = self.base_font_size | |
self.styles['Normal'].leading = self.base_font_size * 1.2 | |
self.styles['Normal'].spaceAfter = self.base_font_size * 0.8 | |
# Heading styles | |
for level in range(1, 7): | |
size_multiplier = self.heading_scale.get(level, 1.0) | |
heading_name = f'Heading{level}' | |
# Check if the heading style already exists | |
if heading_name in self.styles: | |
# Modify existing style | |
self.styles[heading_name].parent = self.styles['Normal'] | |
self.styles[heading_name].fontName = f'{self.font_name}-Bold' | |
self.styles[heading_name].fontSize = int(self.base_font_size * size_multiplier) | |
self.styles[heading_name].leading = int(self.base_font_size * size_multiplier * 1.2) | |
self.styles[heading_name].spaceAfter = self.base_font_size | |
self.styles[heading_name].spaceBefore = self.base_font_size * (1 + (0.2 * (7 - level))) | |
else: | |
# Create new style | |
self.styles.add( | |
ParagraphStyle( | |
name=heading_name, | |
parent=self.styles['Normal'], | |
fontName=f'{self.font_name}-Bold', | |
fontSize=int(self.base_font_size * size_multiplier), | |
leading=int(self.base_font_size * size_multiplier * 1.2), | |
spaceAfter=self.base_font_size, | |
spaceBefore=self.base_font_size * (1 + (0.2 * (7 - level))), | |
) | |
) | |
# Code block style | |
self.styles.add( | |
ParagraphStyle( | |
name='CodeBlock', | |
fontName='Courier', | |
fontSize=self.base_font_size * 0.9, | |
leading=self.base_font_size * 1.1, | |
spaceAfter=self.base_font_size, | |
spaceBefore=self.base_font_size, | |
leftIndent=self.base_font_size, | |
backgroundColor=HexColor('#EEEEEE'), | |
borderWidth=0, | |
borderPadding=self.base_font_size * 0.5, | |
) | |
) | |
# List item style | |
self.styles.add( | |
ParagraphStyle( | |
name='ListItem', | |
parent=self.styles['Normal'], | |
leftIndent=self.base_font_size * 2, | |
firstLineIndent=-self.base_font_size, | |
) | |
) | |
# Table of contents styles | |
self.styles.add( | |
ParagraphStyle( | |
name='TOCHeading', | |
parent=self.styles['Heading1'], | |
fontSize=int(self.base_font_size * 1.5), | |
spaceAfter=self.base_font_size * 1.5, | |
) | |
) | |
for level in range(1, 4): # Create styles for TOC levels | |
self.styles.add( | |
ParagraphStyle( | |
name=f'TOC{level}', | |
parent=self.styles['Normal'], | |
leftIndent=self.base_font_size * (level - 1) * 2, | |
fontSize=self.base_font_size - (level - 1), | |
leading=self.base_font_size * 1.4, | |
) | |
) | |
def convert_file(self, md_file_path: str) -> None: | |
""" | |
Convert a single markdown file to PDF. | |
Args: | |
md_file_path: Path to the markdown file | |
""" | |
# Read markdown content | |
with open(md_file_path, 'r', encoding='utf-8') as f: | |
md_content = f.read() | |
# Convert markdown to PDF | |
self.convert_content(md_content) | |
def convert_content(self, md_content: str) -> None: | |
""" | |
Convert markdown content string to PDF. | |
Args: | |
md_content: Markdown content as a string | |
""" | |
# Convert markdown to HTML | |
html_content = self._md_to_html(md_content) | |
# Convert HTML to ReportLab elements | |
self._html_to_elements(html_content) | |
# Generate the PDF | |
self._generate_pdf() | |
logger.info(f"PDF created at {self.output_path}") | |
def convert_multiple_files(self, md_file_paths: List[str], | |
merge: bool = True, | |
separate_toc: bool = False) -> None: | |
""" | |
Convert multiple markdown files to PDF. | |
Args: | |
md_file_paths: List of paths to markdown files | |
merge: Whether to merge all files into a single PDF | |
separate_toc: Whether to include a separate TOC for each file | |
""" | |
if merge: | |
all_content = [] | |
for file_path in md_file_paths: | |
logger.info(f"Processing {file_path}") | |
with open(file_path, 'r', encoding='utf-8') as f: | |
content = f.read() | |
# Add file name as heading if more than one file | |
if len(md_file_paths) > 1: | |
file_name = os.path.splitext(os.path.basename(file_path))[0] | |
content = f"# {file_name}\n\n{content}" | |
# Add page break between files | |
if all_content: | |
all_content.append("\n\n<div class='page-break'></div>\n\n") | |
all_content.append(content) | |
combined_content = "\n".join(all_content) | |
self.convert_content(combined_content) | |
else: | |
# Process each file separately | |
for i, file_path in enumerate(md_file_paths): | |
converter = MarkdownToPDFConverter( | |
output_path=f"{os.path.splitext(file_path)[0]}.pdf", | |
page_size=self.page_size, | |
margins=self.margins, | |
font_name=self.font_name, | |
base_font_size=self.base_font_size, | |
heading_scale=self.heading_scale, | |
include_toc=separate_toc, | |
code_style=self.code_style | |
) | |
converter.convert_file(file_path) | |
def _md_to_html(self, md_content: str) -> str: | |
""" | |
Convert markdown content to HTML. | |
Args: | |
md_content: Markdown content | |
Returns: | |
HTML content | |
""" | |
# Define extensions for markdown conversion | |
extensions = [ | |
'markdown.extensions.extra', | |
'markdown.extensions.smarty', | |
TableExtension(), | |
FencedCodeExtension(), | |
TocExtension(toc_depth=3) if self.include_toc else None | |
] | |
# Remove None values | |
extensions = [ext for ext in extensions if ext is not None] | |
# Convert markdown to HTML | |
html_content = markdown.markdown(md_content, extensions=extensions) | |
return html_content | |
def _html_to_elements(self, html_content: str) -> None: | |
""" | |
Convert HTML content to ReportLab elements. | |
Args: | |
html_content: HTML content | |
""" | |
soup = BeautifulSoup(html_content, 'html.parser') | |
# Process elements | |
for element in soup.children: | |
if element.name: | |
self._process_element(element) | |
def _process_element(self, element: BeautifulSoup) -> None: | |
""" | |
Process an HTML element and convert it to ReportLab elements. | |
Args: | |
element: BeautifulSoup element | |
""" | |
if element.name in ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']: | |
level = int(element.name[1]) | |
text = element.get_text() | |
# Add to TOC | |
if self.include_toc: | |
self.toc_entries.append((level, text)) | |
# Create heading paragraph | |
self.elements.append( | |
Paragraph(text, self.styles[f'Heading{level}']) | |
) | |
elif element.name == 'p': | |
text = self._process_inline_elements(element) | |
self.elements.append( | |
Paragraph(text, self.styles['Normal']) | |
) | |
elif element.name == 'pre': | |
code = element.get_text() | |
self.elements.append( | |
Preformatted(code, self.styles['CodeBlock']) | |
) | |
elif element.name == 'img': | |
src = element.get('src', '') | |
alt = element.get('alt', 'Image') | |
# Handle different image sources | |
if src.startswith('http'): | |
# Remote image | |
try: | |
response = requests.get(src) | |
img_data = response.content | |
img_stream = io.BytesIO(img_data) | |
image = Image(img_stream, width=4*inch, height=3*inch) | |
# Try to get actual dimensions | |
try: | |
pil_img = PilImage.open(img_stream) | |
width, height = pil_img.size | |
aspect = width / height | |
max_width = 6 * inch | |
if width > max_width: | |
new_width = max_width | |
new_height = new_width / aspect | |
image = Image(img_stream, width=new_width, height=new_height) | |
except: | |
pass # Use default size if image can't be processed | |
self.elements.append(image) | |
except: | |
# If image can't be retrieved, add a placeholder | |
self.elements.append( | |
Paragraph(f"[Image: {alt}]", self.styles['Normal']) | |
) | |
elif src.startswith('data:image'): | |
# Base64 encoded image | |
try: | |
# Extract base64 data | |
b64_data = src.split(',')[1] | |
img_data = base64.b64decode(b64_data) | |
img_stream = io.BytesIO(img_data) | |
image = Image(img_stream, width=4*inch, height=3*inch) | |
self.elements.append(image) | |
except: | |
# If image can't be processed, add a placeholder | |
self.elements.append( | |
Paragraph(f"[Image: {alt}]", self.styles['Normal']) | |
) | |
else: | |
# Local image | |
if os.path.exists(src): | |
image = Image(src, width=4*inch, height=3*inch) | |
self.elements.append(image) | |
else: | |
# If image can't be found, add a placeholder | |
self.elements.append( | |
Paragraph(f"[Image: {alt}]", self.styles['Normal']) | |
) | |
elif element.name == 'ul' or element.name == 'ol': | |
list_items = [] | |
bullet_type = 'bullet' if element.name == 'ul' else 'numbered' | |
for item in element.find_all('li', recursive=False): | |
text = self._process_inline_elements(item) | |
list_items.append( | |
ListItem( | |
Paragraph(text, self.styles['ListItem']), | |
leftIndent=20 | |
) | |
) | |
self.elements.append( | |
ListFlowable( | |
list_items, | |
bulletType=bullet_type, | |
start=1 if bullet_type == 'numbered' else None, | |
bulletFormat='%s.' if bullet_type == 'numbered' else '%s' | |
) | |
) | |
elif element.name == 'table': | |
self._process_table(element) | |
elif element.name == 'div' and 'page-break' in element.get('class', []): | |
self.elements.append(PageBreak()) | |
elif element.name == 'hr': | |
self.elements.append(Spacer(1, 0.25*inch)) | |
# Process children for complex elements | |
elif element.name in ['div', 'blockquote', 'section', 'article']: | |
for child in element.children: | |
if hasattr(child, 'name') and child.name: | |
self._process_element(child) | |
def _process_inline_elements(self, element: BeautifulSoup) -> str: | |
""" | |
Process inline HTML elements like bold, italic, etc. | |
Args: | |
element: BeautifulSoup element | |
Returns: | |
Formatted text with ReportLab markup | |
""" | |
html_str = str(element) | |
# Convert common HTML tags to ReportLab paragraph markup | |
replacements = [ | |
(r'<strong>(.*?)</strong>', r'<b>\1</b>'), | |
(r'<b>(.*?)</b>', r'<b>\1</b>'), | |
(r'<em>(.*?)</em>', r'<i>\1</i>'), | |
(r'<i>(.*?)</i>', r'<i>\1</i>'), | |
(r'<code>(.*?)</code>', r'<font name="Courier">\1</font>'), | |
(r'<a href="(.*?)">(.*?)</a>', r'<link href="\1">\2</link>'), | |
(r'<u>(.*?)</u>', r'<u>\1</u>'), | |
(r'<strike>(.*?)</strike>', r'<strike>\1</strike>'), | |
(r'<del>(.*?)</del>', r'<strike>\1</strike>'), | |
] | |
for pattern, replacement in replacements: | |
html_str = re.sub(pattern, replacement, html_str, flags=re.DOTALL) | |
# Extract text with our ReportLab markup from the modified HTML | |
soup = BeautifulSoup(html_str, 'html.parser') | |
return soup.get_text() | |
def _process_table(self, table_element: BeautifulSoup) -> None: | |
""" | |
Process an HTML table into a ReportLab Table. | |
Args: | |
table_element: BeautifulSoup table element | |
""" | |
rows = [] | |
# Extract header row | |
thead = table_element.find('thead') | |
if thead: | |
header_cells = [] | |
for th in thead.find_all(['th']): | |
text = self._process_inline_elements(th) | |
# Create a paragraph with bold text for headers | |
header_cells.append(Paragraph(f"<b>{text}</b>", self.styles['Normal'])) | |
rows.append(header_cells) | |
# Extract body rows | |
tbody = table_element.find('tbody') or table_element | |
for tr in tbody.find_all('tr'): | |
if tr.parent.name == 'thead': | |
continue # Skip header rows already processed | |
row_cells = [] | |
for cell in tr.find_all(['td', 'th']): | |
text = self._process_inline_elements(cell) | |
if cell.name == 'th': | |
# Headers are bold | |
row_cells.append(Paragraph(f"<b>{text}</b>", self.styles['Normal'])) | |
else: | |
row_cells.append(Paragraph(text, self.styles['Normal'])) | |
if row_cells: # Only add non-empty rows | |
rows.append(row_cells) | |
if rows: | |
# Create table and style | |
col_widths = [None] * len(rows[0]) # Auto width for columns | |
table = Table(rows, colWidths=col_widths) | |
# Add basic grid and header styling | |
style = TableStyle([ | |
('GRID', (0, 0), (-1, -1), 0.5, colors.Color(0.7, 0.7, 0.7)), | |
('BACKGROUND', (0, 0), (-1, 0), colors.Color(0.8, 0.8, 0.8)), | |
('TEXTCOLOR', (0, 0), (-1, 0), colors.black), | |
('ALIGN', (0, 0), (-1, 0), 'CENTER'), | |
('FONTNAME', (0, 0), (-1, 0), f'{self.font_name}-Bold'), | |
('BOTTOMPADDING', (0, 0), (-1, 0), 8), | |
('TOPPADDING', (0, 0), (-1, 0), 8), | |
('BOTTOMPADDING', (0, 1), (-1, -1), 6), | |
('TOPPADDING', (0, 1), (-1, -1), 6), | |
]) | |
table.setStyle(style) | |
self.elements.append(table) | |
# Add some space after the table | |
self.elements.append(Spacer(1, 0.1*inch)) | |
def _generate_toc(self) -> None: | |
"""Generate a table of contents.""" | |
if not self.toc_entries: | |
return | |
self.elements.append(Paragraph("Table of Contents", self.styles['TOCHeading'])) | |
self.elements.append(Spacer(1, 0.2*inch)) | |
for level, text in self.toc_entries: | |
if level <= 3: # Only include headings up to level 3 | |
self.elements.append( | |
Paragraph(text, self.styles[f'TOC{level}']) | |
) | |
self.elements.append(PageBreak()) | |
def _generate_pdf(self) -> None: | |
"""Generate the PDF document.""" | |
# Create the document | |
doc = SimpleDocTemplate( | |
self.output_path, | |
pagesize=self.page_size, | |
leftMargin=self.margins[0]*inch, | |
rightMargin=self.margins[1]*inch, | |
topMargin=self.margins[2]*inch, | |
bottomMargin=self.margins[3]*inch | |
) | |
# Add TOC if requested | |
if self.include_toc and self.toc_entries: | |
self._generate_toc() | |
# Build the PDF | |
doc.build(self.elements) | |
class MarkdownToPDFAgent: | |
""" | |
AI Agent to convert Markdown files to PDF with enhanced formatting. | |
""" | |
def __init__(self, llm=None): | |
""" | |
Initialize the agent with optional LLM for content enhancement. | |
Args: | |
llm: Optional language model for content enhancement | |
""" | |
self.llm = llm | |
self.converter = MarkdownToPDFConverter() | |
def setup_from_openai(self, api_key=None): | |
""" | |
Setup agent with OpenAI LLM. | |
Args: | |
api_key: OpenAI API key (will use env var if not provided) | |
""" | |
try: | |
from langchain_openai import ChatOpenAI | |
api_key = api_key or os.getenv("OPENAI_API_KEY") | |
if not api_key: | |
logger.warning("No OpenAI API key provided. Agent will run without LLM enhancement.") | |
return False | |
self.llm = ChatOpenAI( | |
model="gpt-4", | |
temperature=0.1, | |
api_key=api_key | |
) | |
return True | |
except ImportError: | |
logger.warning("LangChain OpenAI package not found. Install with 'pip install langchain-openai'") | |
return False | |
def setup_from_gemini(self, api_key=None): | |
""" | |
Setup agent with Google Gemini LLM. | |
Args: | |
api_key: Google Gemini API key (will use env var if not provided) | |
""" | |
try: | |
from langchain_google_genai import ChatGoogleGenerativeAI | |
api_key = api_key or os.getenv("GOOGLE_API_KEY") | |
if not api_key: | |
logger.warning("No Google API key provided. Agent will run without LLM enhancement.") | |
return False | |
try: | |
# Use the latest Gemini model version | |
self.llm = ChatGoogleGenerativeAI( | |
model="gemini-1.5-flash", | |
temperature=0.1, | |
google_api_key=api_key, | |
convert_system_message_to_human=True | |
) | |
logger.info("Successfully set up Google Gemini LLM") | |
return True | |
except Exception as e: | |
logger.error(f"Error setting up Google Gemini LLM: {str(e)}") | |
return False | |
except ImportError: | |
logger.warning("LangChain Google Generative AI package not found. Install with 'pip install langchain-google-genai'") | |
return False | |
def enhance_markdown(self, content: str, instructions: str = None) -> str: | |
""" | |
Enhance markdown content using LLM if available. | |
Args: | |
content: Original markdown content | |
instructions: Specific enhancement instructions | |
Returns: | |
Enhanced markdown content | |
""" | |
if not self.llm: | |
logger.warning("No LLM available for enhancement. Returning original content.") | |
return content | |
default_instructions = """ | |
Enhance this markdown content while preserving its structure and meaning. | |
Make the following improvements: | |
1. Fix any grammar or spelling issues | |
2. Improve formatting for better readability | |
3. Ensure proper markdown syntax is used | |
4. Add appropriate section headings if missing | |
5. Keep the content factually identical to the original | |
""" | |
instructions = instructions or default_instructions | |
try: | |
# Create a prompt for the LLM | |
prompt = f"{instructions}\n\nOriginal content:\n\n{content}\n\nPlease provide the enhanced markdown content:" | |
# Use the LLM directly with proper error handling | |
try: | |
from langchain.schema import HumanMessage | |
logger.info(f"Using LLM type: {type(self.llm).__name__}") | |
messages = [HumanMessage(content=prompt)] | |
result = self.llm.invoke(messages).content | |
logger.info("Successfully received response from LLM") | |
except Exception as e: | |
logger.error(f"Error invoking LLM: {str(e)}") | |
return content | |
# Clean up the result (extract just the markdown part) | |
result = self._clean_agent_output(result) | |
return result | |
except Exception as e: | |
logger.error(f"Error enhancing markdown: {str(e)}") | |
return content # Return original content if enhancement fails | |
def _clean_agent_output(self, output: str) -> str: | |
""" | |
Clean up agent output to extract just the markdown content. | |
Args: | |
output: Raw agent output | |
Returns: | |
Cleaned markdown content | |
""" | |
# Check if the output is wrapped in markdown code blocks | |
md_pattern = r"```(?:markdown|md)?\s*([\s\S]*?)```" | |
match = re.search(md_pattern, output) | |
if match: | |
return match.group(1).strip() | |
# If no markdown blocks found, remove any agent commentary | |
lines = output.split('\n') | |
result_lines = [] | |
capture = False | |
for line in lines: | |
if capture or not (line.startswith("I") or line.startswith("Here") or line.startswith("The")): | |
capture = True | |
result_lines.append(line) | |
return '\n'.join(result_lines) | |
def process_file(self, input_path: str, output_path: str = None, enhance: bool = False, | |
enhancement_instructions: str = None, page_size: str = "A4") -> str: | |
""" | |
Process a single markdown file and convert it to PDF. | |
Args: | |
input_path: Path to input markdown file | |
output_path: Path for output PDF (defaults to input path with .pdf extension) | |
enhance: Whether to enhance the content with LLM | |
enhancement_instructions: Specific instructions for enhancement | |
page_size: Page size for the PDF ("A4" or "letter") | |
Returns: | |
Path to the generated PDF | |
""" | |
# Validate input file | |
if not os.path.exists(input_path): | |
logger.error(f"Input file not found: {input_path}") | |
return None | |
# Set default output path if not provided | |
if not output_path: | |
output_path = os.path.splitext(input_path)[0] + ".pdf" | |
# Read markdown content | |
with open(input_path, 'r', encoding='utf-8') as f: | |
content = f.read() | |
# Enhance content if requested | |
if enhance and self.llm: | |
logger.info(f"Enhancing content for {input_path}") | |
content = self.enhance_markdown(content, enhancement_instructions) | |
# Configure converter | |
self.converter = MarkdownToPDFConverter( | |
output_path=output_path, | |
page_size=page_size | |
) | |
# Convert to PDF | |
logger.info(f"Converting {input_path} to PDF") | |
self.converter.convert_content(content) | |
return output_path | |
def process_directory(self, input_dir: str, output_dir: str = None, pattern: str = "*.md", | |
enhance: bool = False, merge: bool = False, | |
output_filename: str = "merged_document.pdf", | |
page_size: str = "A4") -> List[str]: | |
""" | |
Process all markdown files in a directory. | |
Args: | |
input_dir: Path to input directory | |
output_dir: Path to output directory (defaults to input directory) | |
pattern: Glob pattern for markdown files | |
enhance: Whether to enhance content with LLM | |
merge: Whether to merge all files into a single PDF | |
output_filename: Filename for merged PDF | |
page_size: Page size for the PDF ("A4" or "letter") | |
Returns: | |
List of paths to generated PDFs | |
""" | |
# Validate input directory | |
if not os.path.isdir(input_dir): | |
logger.error(f"Input directory not found: {input_dir}") | |
return [] | |
# Set default output directory if not provided | |
if not output_dir: | |
output_dir = input_dir | |
elif not os.path.exists(output_dir): | |
os.makedirs(output_dir) | |
# Get all markdown files | |
md_files = glob.glob(os.path.join(input_dir, pattern)) | |
if not md_files: | |
logger.warning(f"No markdown files found in {input_dir} with pattern {pattern}") | |
return [] | |
# Sort files to ensure consistent ordering | |
md_files.sort() | |
if merge: | |
logger.info(f"Merging {len(md_files)} markdown files into a single PDF") | |
# Process each file for enhancement if requested | |
if enhance and self.llm: | |
enhanced_contents = [] | |
for md_file in md_files: | |
logger.info(f"Enhancing content for {md_file}") | |
with open(md_file, 'r', encoding='utf-8') as f: | |
content = f.read() | |
# Add file name as heading | |
file_name = os.path.splitext(os.path.basename(md_file))[0] | |
content = f"# {file_name}\n\n{content}" | |
enhanced_content = self.enhance_markdown(content) | |
enhanced_contents.append(enhanced_content) | |
# Merge enhanced contents with page breaks | |
merged_content = "\n\n<div class='page-break'></div>\n\n".join(enhanced_contents) | |
# Convert merged content | |
output_path = os.path.join(output_dir, output_filename) | |
self.converter = MarkdownToPDFConverter( | |
output_path=output_path, | |
page_size=page_size | |
) | |
self.converter.convert_content(merged_content) | |
return [output_path] | |
else: | |
# Merge without enhancement | |
output_path = os.path.join(output_dir, output_filename) | |
self.converter = MarkdownToPDFConverter( | |
output_path=output_path, | |
page_size=page_size | |
) | |
self.converter.convert_multiple_files(md_files, merge=True) | |
return [output_path] | |
else: | |
# Process each file individually | |
output_files = [] | |
for md_file in md_files: | |
output_filename = os.path.splitext(os.path.basename(md_file))[0] + ".pdf" | |
output_path = os.path.join(output_dir, output_filename) | |
processed_file = self.process_file( | |
md_file, | |
output_path, | |
enhance=enhance, | |
page_size=page_size | |
) | |
if processed_file: | |
output_files.append(processed_file) | |
return output_files | |
# Helper functions for the Gradio interface | |
def load_sample(): | |
"""Load a sample markdown document.""" | |
return """# Sample Markdown Document | |
## Introduction | |
This is a sample markdown document to demonstrate the capabilities of **MarkdownMuse**. You can use this as a starting point for your own documents. | |
## Features | |
- Convert markdown to PDF | |
- Support for tables and code blocks | |
- AI enhancement options | |
### Code Example | |
```python | |
def hello_world(): | |
print("Hello from MarkdownMuse!") | |
return True | |
``` | |
## Table Example | |
| Feature | Description | Status | | |
|---------|-------------|---------| | |
| Markdown Conversion | Convert MD to PDF | β | | |
| AI Enhancement | Improve content with AI | β | | |
| Custom Styling | Apply custom styles | β | | |
> **Note:** This is just a sample document. Feel free to modify it or create your own! | |
""" | |
def process_markdown(markdown_text, page_size, font_size, font_name, | |
margin_size, include_toc, use_ai, enhancement_instructions): | |
""" | |
Process markdown text and generate a PDF. | |
Returns: | |
Path to generated PDF file | |
""" | |
# Create a temporary file for the output | |
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") | |
output_path = temp_file.name | |
temp_file.close() | |
# Initialize the agent and process the markdown | |
agent = MarkdownToPDFAgent() | |
# Configure converter | |
agent.converter = MarkdownToPDFConverter( | |
output_path=output_path, | |
page_size=page_size, | |
base_font_size=font_size, | |
font_name=font_name, | |
margins=(margin_size, margin_size, margin_size, margin_size), | |
include_toc=include_toc | |
) | |
# Get Gemini API key from environment | |
api_key = os.environ.get('GOOGLE_API_KEY') | |
# Setup AI enhancement if requested | |
enhance = False | |
if use_ai and api_key: | |
success = agent.setup_from_gemini(api_key) | |
enhance = success | |
try: | |
# Create a temporary file for the markdown content | |
with tempfile.NamedTemporaryFile(suffix='.md', delete=False) as temp_md_file: | |
temp_md_path = temp_md_file.name | |
temp_md_file.write(markdown_text.encode('utf-8')) | |
# Process the file | |
output_file = agent.process_file( | |
temp_md_path, | |
output_path, | |
enhance=enhance, | |
enhancement_instructions=enhancement_instructions if enhancement_instructions else None, | |
page_size=page_size.lower() | |
) | |
# Remove the temporary md file | |
os.unlink(temp_md_path) | |
if output_file: | |
return output_file, "β PDF generated successfully!" | |
else: | |
return None, "β Error generating PDF. Please check your markdown syntax." | |
except Exception as e: | |
logger.error(f"Error processing markdown: {e}") | |
return None, f"β Error: {str(e)}" | |
# Check if the API key is available in the environment | |
has_api_key = bool(os.environ.get('GOOGLE_API_KEY')) | |
# Custom CSS for styling | |
custom_css = """ | |
<style> | |
:root { | |
--primary-color: #6366F1; | |
--secondary-color: #8B5CF6; | |
--accent-color: #4f46e5; | |
--text-color: #1F2937; | |
--light-text: #F9FAFB; | |
--border-color: #E5E7EB; | |
--background-color: #F3F4F6; | |
--card-background: #FFFFFF; | |
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); | |
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); | |
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); | |
--rounded-sm: 0.375rem; | |
--rounded-md: 0.5rem; | |
--rounded-lg: 0.75rem; | |
} | |
.header { | |
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); | |
color: var(--light-text); | |
padding: 2rem; | |
border-radius: var(--rounded-lg); | |
margin-bottom: 1.5rem; | |
box-shadow: var(--shadow-lg); | |
text-align: center; | |
position: relative; | |
overflow: hidden; | |
} | |
.header::before { | |
content: ""; | |
position: absolute; | |
top: 0; | |
left: 0; | |
right: 0; | |
bottom: 0; | |
background: repeating-linear-gradient( | |
45deg, | |
rgba(255, 255, 255, 0.05), | |
rgba(255, 255, 255, 0.05) 10px, | |
rgba(255, 255, 255, 0) 10px, | |
rgba(255, 255, 255, 0) 20px | |
); | |
} | |
.header h1 { | |
font-size: 2.5rem; | |
margin-bottom: 0.5rem; | |
font-weight: 700; | |
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); | |
} | |
.header p { | |
font-size: 1.25rem; | |
opacity: 0.9; | |
max-width: 700px; | |
margin: 0 auto; | |
} | |
.container { | |
max-width: 1200px; | |
margin: 0 auto; | |
padding: 0 1rem; | |
} | |
.card { | |
background: var(--card-background); | |
border-radius: var(--rounded-lg); | |
padding: 1.5rem; | |
box-shadow: var(--shadow-md); | |
margin-bottom: 1.5rem; | |
border: 1px solid var(--border-color); | |
transition: transform 0.2s, box-shadow 0.2s; | |
} | |
.card:hover { | |
transform: translateY(-2px); | |
box-shadow: var(--shadow-lg); | |
} | |
.section-title { | |
color: var(--primary-color); | |
font-size: 1.5rem; | |
font-weight: 600; | |
margin-bottom: 1rem; | |
padding-bottom: 0.75rem; | |
border-bottom: 2px solid var(--border-color); | |
} | |
.footer { | |
text-align: center; | |
margin-top: 2rem; | |
padding: 1.5rem; | |
background: var(--background-color); | |
border-radius: var(--rounded-lg); | |
font-size: 0.9rem; | |
color: var(--text-color); | |
box-shadow: var(--shadow-sm); | |
} | |
.feature-icon { | |
display: inline-block; | |
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); | |
color: white; | |
width: 2.5rem; | |
height: 2.5rem; | |
line-height: 2.5rem; | |
text-align: center; | |
border-radius: 50%; | |
margin-right: 0.75rem; | |
font-size: 1.25rem; | |
box-shadow: var(--shadow-sm); | |
} | |
.button-row { | |
display: flex; | |
gap: 0.75rem; | |
margin: 1rem 0; | |
} | |
.primary-btn { | |
background: linear-gradient(to right, var(--primary-color), var(--secondary-color)) !important; | |
transition: all 0.3s ease !important; | |
transform: translateY(0); | |
box-shadow: var(--shadow-md) !important; | |
font-weight: 600 !important; | |
} | |
.primary-btn:hover { | |
transform: translateY(-2px); | |
box-shadow: var(--shadow-lg) !important; | |
} | |
.secondary-btn { | |
background: var(--background-color) !important; | |
color: var(--text-color) !important; | |
border: 1px solid var(--border-color) !important; | |
font-weight: 500 !important; | |
} | |
.tip-box { | |
background: rgba(99, 102, 241, 0.1); | |
border-left: 4px solid var(--primary-color); | |
padding: 1rem; | |
margin: 1rem 0; | |
border-radius: var(--rounded-sm); | |
} | |
.tip-title { | |
color: var(--primary-color); | |
font-weight: 600; | |
margin-bottom: 0.5rem; | |
} | |
/* Tab styling */ | |
.tab-active { | |
border-bottom: 3px solid var(--primary-color); | |
color: var(--primary-color); | |
font-weight: 600; | |
} | |
/* Customize Gradio components */ | |
.gr-box { | |
border-radius: var(--rounded-md) !important; | |
border: 1px solid var(--border-color) !important; | |
} | |
.gr-button { | |
border-radius: var(--rounded-md) !important; | |
} | |
.gr-form { | |
border-radius: var(--rounded-md) !important; | |
border: 1px solid var(--border-color) !important; | |
box-shadow: var(--shadow-sm) !important; | |
} | |
.gr-input { | |
border-radius: var(--rounded-md) !important; | |
} | |
.gr-checkbox { | |
border-radius: var(--rounded-sm) !important; | |
} | |
.gr-panel { | |
border-radius: var(--rounded-md) !important; | |
} | |
.gr-accordion { | |
border-radius: var(--rounded-md) !important; | |
} | |
</style> | |
""" | |
# Define the Gradio interface | |
with gr.Blocks(title="MarkdownMuse", theme=gr.themes.Soft()) as demo: | |
# Header with custom styling | |
gr.HTML(custom_css + """ | |
<div class="header"> | |
<h1>π MarkdownMuse</h1> | |
<p>Transform your Markdown files into beautifully formatted PDFs with a single click. | |
Professional-looking documents made simple.</p> | |
</div> | |
""") | |
with gr.Row(): | |
# Input Section | |
with gr.Column(scale=1): | |
gr.Markdown("## π Input", elem_id="section-title") | |
markdown_input = gr.TextArea( | |
placeholder="Enter your markdown content here...", | |
label="Markdown Content", | |
lines=15, | |
elem_id="markdown-input" | |
) | |
with gr.Row(elem_id="button-row"): | |
sample_btn = gr.Button("π Load Sample", size="sm", elem_classes="secondary-btn") | |
clear_btn = gr.Button("ποΈ Clear", size="sm", elem_classes="secondary-btn") | |
with gr.Tabs(): | |
with gr.TabItem("π PDF Settings", elem_classes="tab-item"): | |
with gr.Row(): | |
with gr.Column(scale=1): | |
page_size = gr.Radio( | |
["A4", "Letter"], | |
label="Page Size", | |
value="A4", | |
elem_id="page-size" | |
) | |
include_toc = gr.Checkbox( | |
value=True, | |
label="Include Table of Contents", | |
elem_id="include-toc" | |
) | |
with gr.Column(scale=1): | |
font_name = gr.Dropdown( | |
["Helvetica", "Times-Roman", "Courier"], | |
label="Font Family", | |
value="Helvetica", | |
elem_id="font-name" | |
) | |
font_size = gr.Slider( | |
minimum=8, | |
maximum=14, | |
value=10, | |
step=1, | |
label="Base Font Size (pt)", | |
elem_id="font-size" | |
) | |
margin_size = gr.Slider( | |
minimum=0.5, | |
maximum=2.0, | |
value=0.75, | |
step=0.25, | |
label="Margins (inches)", | |
elem_id="margin-size" | |
) | |
with gr.TabItem("π§ AI Enhancement", elem_classes="tab-item"): | |
use_ai = gr.Checkbox( | |
value=has_api_key, | |
label="Enable AI Enhancement", | |
elem_id="use-ai" | |
) | |
# Only show API key message if no key is in environment | |
if not has_api_key: | |
gr.Markdown(""" | |
> **Note:** To use AI enhancement, add your Google Gemini API key to the Hugging Face Space secrets as `GOOGLE_API_KEY`. | |
""", elem_id="api-key-note") | |
else: | |
gr.Markdown(""" | |
> **API Key Detected!** AI enhancement is available. | |
""", elem_id="api-key-success") | |
enhancement_instructions = gr.TextArea( | |
placeholder="Provide specific instructions for how the AI should enhance your markdown... (Optional)", | |
label="Enhancement Instructions", | |
lines=3, | |
elem_id="enhancement-instructions" | |
) | |
convert_btn = gr.Button("π Convert to PDF", variant="primary", elem_classes="primary-btn") | |
# Output Section | |
with gr.Column(scale=1): | |
gr.Markdown("## π Output", elem_id="section-title") | |
status = gr.Markdown("β¨ Ready to convert your markdown to PDF.", elem_id="status") | |
output_pdf = gr.File(label="Generated PDF", elem_id="output-pdf") | |
with gr.Accordion("π‘ Markdown Tips", open=False, elem_id="markdown-tips"): | |
gr.HTML(""" | |
<div class="tip-box"> | |
<div class="tip-title">β¨ Basic Syntax</div> | |
<ul> | |
<li><strong>Headings</strong>: Use <code>#</code> for h1, <code>##</code> for h2, etc.</li> | |
<li><strong>Bold</strong>: Surround text with <code>**double asterisks**</code></li> | |
<li><strong>Italic</strong>: Surround text with <code>*single asterisks*</code></li> | |
<li><strong>Lists</strong>: Start lines with <code>-</code> or <code>*</code> for bullets, <code>1.</code> for numbered</li> | |
<li><strong>Links</strong>: <code>[link text](http://example.com)</code></li> | |
<li><strong>Images</strong>: <code></code></li> | |
</ul> | |
</div> | |
<div class="tip-box"> | |
<div class="tip-title">π Advanced Features</div> | |
<ul> | |
<li><strong>Tables</strong>: Use <code>|</code> to separate columns and <code>-</code> for header rows</li> | |
<li><strong>Code Blocks</strong>: Wrap with triple backticks (<code>```</code> code <code>```</code>)</li> | |
<li><strong>Blockquotes</strong>: Start lines with <code>></code></li> | |
<li><strong>Horizontal Rule</strong>: Three dashes <code>---</code></li> | |
</ul> | |
</div> | |
<p><a href="https://www.markdownguide.org/basic-syntax/" target="_blank">Learn more about Markdown syntax β</a></p> | |
""") | |
with gr.Accordion("π Features", open=False, elem_id="features"): | |
gr.HTML(""" | |
<div style="display: flex; flex-wrap: wrap; gap: 1rem; margin-top: 0.5rem;"> | |
<div style="flex: 1; min-width: 200px;"> | |
<div style="display: flex; align-items: center; margin-bottom: 0.5rem;"> | |
<span class="feature-icon">π</span> | |
<strong>PDF Conversion</strong> | |
</div> | |
<p>Transform any markdown document into a professionally formatted PDF with proper styling.</p> | |
</div> | |
<div style="flex: 1; min-width: 200px;"> | |
<div style="display: flex; align-items: center; margin-bottom: 0.5rem;"> | |
<span class="feature-icon">π§ </span> | |
<strong>AI Enhancement</strong> | |
</div> | |
<p>Use AI to improve content formatting, fix grammar, and enhance readability.</p> | |
</div> | |
<div style="flex: 1; min-width: 200px;"> | |
<div style="display: flex; align-items: center; margin-bottom: 0.5rem;"> | |
<span class="feature-icon">π¨</span> | |
<strong>Custom Styling</strong> | |
</div> | |
<p>Control fonts, sizes, margins, and other style elements to match your needs.</p> | |
</div> | |
<div style="flex: 1; min-width: 200px;"> | |
<div style="display: flex; align-items: center; margin-bottom: 0.5rem;"> | |
<span class="feature-icon">π</span> | |
<strong>Table of Contents</strong> | |
</div> | |
<p>Automatically generate a structured table of contents from document headings.</p> | |
</div> | |
</div> | |
""") | |
# Footer | |
gr.HTML(""" | |
<div class="footer"> | |
<p><strong>MarkdownMuse</strong> | A powerful Markdown to PDF converter with AI enhancement capabilities</p> | |
<p>Made with β€οΈ using Python, ReportLab, and Gradio | <a href="https://github.com/your-username/markdownmuse" target="_blank">GitHub</a></p> | |
</div> | |
""") | |
# Set up event handlers | |
sample_btn.click(load_sample, outputs=markdown_input) | |
clear_btn.click(lambda: "", outputs=markdown_input) | |
# Process markdown and generate PDF | |
convert_btn.click( | |
process_markdown, | |
inputs=[ | |
markdown_input, | |
page_size, | |
font_size, | |
font_name, | |
margin_size, | |
include_toc, | |
use_ai, | |
enhancement_instructions | |
], | |
outputs=[ | |
output_pdf, | |
status | |
] | |
) | |
# Launch the app | |
if __name__ == "__main__": | |
try: | |
print("Starting MarkdownMuse application...") | |
demo.launch(server_name="0.0.0.0", server_port=7860) | |
print("MarkdownMuse application launched successfully!") | |
except Exception as e: | |
print(f"ERROR LAUNCHING APP: {str(e)}") | |
import traceback | |
traceback.print_exc() |