Source code for sabbath_school_reproducer.generator.html_generator

import markdown
import re
import os
import json
import requests
from sabbath_school_reproducer.generator.css_styles import CSS_TEMPLATE, CssUpdater
from sabbath_school_reproducer.generator.css_editor import  CSSEditor
from sabbath_school_reproducer.utils.language_utils import  LanguageConfig


[docs] class HtmlGenerator: """Generates HTML content for PDF generation with incremental approach."""
[docs] @staticmethod def get_quarter_display(quarter, language_code='en'): """ Get a formatted display for the quarter Args: quarter (str): Quarter code (e.g., q1, q2, q3, q4) language_code (str): Language code for translations Returns: str: Formatted quarter display """ return LanguageConfig.get_translation( language_code, f'quarter_names.{quarter.lower()}', "Quarter" )
[docs] @staticmethod def read_svg_file(filepath): """ Read SVG file content from the specified path Args: filepath (str): Path to SVG file Returns: str or None: SVG content or None if error """ try: with open(filepath, 'r', encoding='utf-8') as f: return f.read() except Exception as e: print(f"Warning: Could not read SVG from {filepath}: {e}") return None
[docs] @staticmethod def create_cover_page(front_cover_svg_path=None, config=None): """ Creates the cover page HTML using the SVG from file if provided Args: front_cover_svg_path (str, optional): Path to front cover SVG config (dict, optional): Configuration dictionary Returns: str: HTML for cover page """ svg_content = "" # Set default values year = 2025 quarter = "q1" lesson_title = "" # Get language code from config language_code = config.get('language', 'en') if config else 'en' # Use values from config if available if config: # Use the target year and quarter for display (not the source/reproduction year) year = config.get("year", 2025) quarter = config.get("quarter", "q1") reproduce = config.get("reproduce", {}) year_orig = reproduce.get("year", 2025) # Default to 2025 if 'year' is not found quarter_orig = reproduce.get("quarter", "q1") # Get title from config or generate a default lesson_title = config.get("lesson_title", CSSEditor.get_lesson_title(year_orig, quarter_orig)) # If we're in reproduction mode, add a note about the original source if config.get("reproduce", {}).get("year"): source_year = config["reproduce"]["year"] source_quarter = config["reproduce"]["quarter"] # Update title to indicate it's a reproduction if not config.get("title"): # Only modify if no custom title is set # Get translated word for "from" based on language from_text = LanguageConfig.get_translation(language_code, 'from_text', 'from', config) lesson_title = f"Sabbath School Lessons ({from_text} {source_year} {source_quarter.upper()})" # Format quarter display using translated name quarter_display = LanguageConfig.get_translation( language_code, f'quarter_names.{quarter.lower()}', HtmlGenerator.get_quarter_display(quarter, language_code), config ) # Get quarter months for the specific language quarter_months = LanguageConfig.get_translation( language_code, f'quarter_months.{quarter.lower()}', f"Quarter {quarter[1]}", config ) # If a path is provided, try to read the SVG from file if front_cover_svg_path: svg_content = HtmlGenerator.read_svg_file(front_cover_svg_path) if not svg_content: print(f"Warning: Could not read SVG from {front_cover_svg_path}") # We'll fall back to the default SVG if not svg_content: # Get translated text for the cover page sabbath_school_text = LanguageConfig.get_translation(language_code, 'sabbath_school', 'SABBATH SCHOOL', config) lessons_text = LanguageConfig.get_translation(language_code, 'lessons', 'LESSONS', config) # Use default fallback SVG with dynamic content svg_content = f""" <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 1000" width="800" height="1000"> <rect width="800" height="1000" fill="#ffffff"/> <rect x="30" y="30" width="740" height="940" stroke="#7d2b2b" stroke-width="3" fill="none"/> <text x="400" y="170" font-family="Georgia, serif" font-size="48" font-weight="bold" text-anchor="middle" fill="#7d2b2b">{sabbath_school_text}</text> <text x="400" y="230" font-family="Georgia, serif" font-size="48" font-weight="bold" text-anchor="middle" fill="#7d2b2b">{lessons_text}</text> <text x="400" y="730" font-family="Georgia, serif" font-size="36" font-weight="bold" text-anchor="middle" fill="#5a4130">{lesson_title}</text> <text x="400" y="790" font-family="Georgia, serif" font-size="24" text-anchor="middle" fill="#5a4130">{quarter_display}, {year}</text> <text x="400" y="830" font-family="Georgia, serif" font-size="18" text-anchor="middle" fill="#5a4130">{quarter_months} {year}</text> </svg> """ # Add source attribution if this is a reproduction if config and config.get("reproduce", {}).get("year"): source_year = config["reproduce"]["year"] source_quarter = config["reproduce"]["quarter"].lower() # Get translated quarter name and adapted from text source_quarter_name = LanguageConfig.get_translation( language_code, f'quarter_names.{source_quarter}', HtmlGenerator.get_quarter_display(source_quarter, language_code), config ) adapted_from = LanguageConfig.get_translation(language_code, 'adapted_from', 'Adapted from', config) # Add source attribution text to SVG svg_content = svg_content.replace('</svg>', f""" <text x="400" y="870" font-family="Georgia, serif" font-size="16" text-anchor="middle" font-style="italic" fill="#666666"> {adapted_from} {source_quarter_name}, {source_year} </text> </svg> """) return f""" <div class="cover-page"> {svg_content} </div> """
[docs] @staticmethod def create_back_cover(back_cover_svg_path=None): """ Creates the back cover page HTML using the SVG from file if provided Args: back_cover_svg_path (str, optional): Path to back cover SVG Returns: str: HTML for back cover """ if not back_cover_svg_path: return "" svg_content = HtmlGenerator.read_svg_file(back_cover_svg_path) if not svg_content: return "" return f""" <div class="back-cover-page"> {svg_content} </div> """
[docs] @staticmethod def convert_markdown_to_html(markdown_content): """ Convert markdown to HTML with table support Args: markdown_content (str): Markdown content Returns: str: HTML content """ return markdown.markdown( markdown_content, extensions=['tables', 'extra'] # Enable table and extra extensions for better markdown support )
[docs] @staticmethod def create_frontmatter_html(frontmatter_content): """ Creates HTML for the front matter section with table support Args: frontmatter_content (str): Front matter markdown content Returns: str: HTML for front matter """ if not frontmatter_content: return "" # Convert markdown to HTML with table support html_content = HtmlGenerator.convert_markdown_to_html(frontmatter_content) # Wrap in appropriate container return f""" <div class="front-matter"> {html_content} <div style="page-break-after: always;"></div> </div> """
[docs] @staticmethod def create_backmatter_html(backmatter_content): """ Creates HTML for the back matter section with table support Args: backmatter_content (str): Back matter markdown content Returns: str: HTML for back matter """ if not backmatter_content: return "" # Convert markdown to HTML with table support html_content = HtmlGenerator.convert_markdown_to_html(backmatter_content) # Wrap in appropriate container return f""" <div class="back-matter"> {html_content} </div> """
[docs] @staticmethod def create_lesson_html(lesson, language_code='en'): """ Creates HTML for a single lesson with improved formatting for title and date Args: lesson (dict): Lesson dictionary language_code (str): Language code for translations Returns: str: HTML for lesson """ # Determine title font size based on length title_font_size = "24px" # Default title_top = '40px' if lesson.get('title'): if len(lesson.get('title', '')) <= 31: title_top = '45px' if len(lesson.get('title', '')) > 57: title_font_size = "18px" # Process preliminary note if present preliminary_html = "" if lesson.get('preliminary_note'): # Convert markdown to HTML with proper formatting preliminary_content = HtmlGenerator.convert_markdown_to_html(lesson['preliminary_note']) # Remove any lines that match the lesson date if lesson.get('date'): # Create more flexible patterns to match the date in various formats date_only = lesson['date'].strip() date_patterns = [ re.escape(date_only), # Exact match re.escape(date_only) + r'\s*$', # Date at end of line r'^\s*' + re.escape(date_only), # Date at beginning of line r'^\s*' + re.escape(date_only) + r'\s*$' # Date alone on a line ] # Apply each pattern to remove the date for pattern in date_patterns: preliminary_content = re.sub(pattern, '', preliminary_content, flags=re.MULTILINE) # If there's still content after removing dates, add it if preliminary_content.strip(): preliminary_html = f""" <div class="preliminary-note"> {preliminary_content} </div> """ # Process question sections with headers question_sections_html = "" # Group questions by section question_sections = {} # Get translations default_questions_header = LanguageConfig.get_translation(language_code, 'questions', 'QUESTIONS') # Create a default questions section if no headers are present if not lesson.get('question_headers'): question_sections[default_questions_header] = [] # Group questions by their sections for question in lesson.get('questions', []): section = question.get('section', default_questions_header) if section not in question_sections: question_sections[section] = [] question_sections[section].append(question) # Sort sections to ensure they're in the correct order if numbers are in section names section_names = sorted(question_sections.keys()) # Get answer prefix translation answer_prefix = LanguageConfig.get_translation(language_code, 'answer_prefix', 'Ans.') # Process each section for section_name in section_names: section_questions = question_sections[section_name] section_questions_html = "" for i, question in enumerate(section_questions, 1): # Handle scripture reference with proper punctuation scripture_html = "" if question.get('scripture'): # Ensure the scripture reference ends with a period if it doesn't already scripture_with_period = question['scripture'] if not scripture_with_period.endswith('.'): scripture_with_period += '.' scripture_html = f'<span class="scripture-ref">{scripture_with_period}</span>' # Handle answer answer_html = "" if question.get('answer'): answer_html = f'<div class="answer"><em>{answer_prefix}{question["answer"]}</em></div>' # Add padding for two-digit numbers num_class = "two-digit" if i >= 10 else "one-digit" # Make sure question text ends with proper punctuation question_text = question.get('text', '') if question_text and not re.search(r'[.?!]$', question_text): question_text += '.' question_html = f""" <div class="question"> <span class="question-number {num_class}">{i}.</span> <div class="question-text"> {question_text} {scripture_html} {answer_html} </div> <div class="clearfix"></div> </div> """ section_questions_html += question_html # Create the section with the proper header question_sections_html += f""" <div class="questions-section"> <div class="questions-header">{section_name}</div> {section_questions_html} </div> """ # Process additional sections if present additional_sections_html = "" if lesson.get('additional_sections'): for section in lesson.get('additional_sections', []): section_title = section.get('title', 'ADDITIONAL') section_content = section.get('content', '') # Convert markdown to HTML with proper formatting section_content_html = HtmlGenerator.convert_markdown_to_html(section_content) additional_sections_html += f""" <div class="additional-section"> <div class="additional-header">{section_title}</div> <div class="additional-content"> {section_content_html} </div> </div> """ # Process notes if present notes_html = "" if lesson.get('notes'): # Get translations for 'NOTES' and 'NOTE' notes_header = LanguageConfig.get_translation(language_code, 'notes', 'NOTES') note_header = LanguageConfig.get_translation(language_code, 'note', 'NOTE') # Convert markdown to HTML with proper formatting notes_content = HtmlGenerator.convert_markdown_to_html(HtmlGenerator.fix_markdown_lists(lesson['notes'])) paragraphs = notes_content.split('</p>') non_empty_paragraphs = [p for p in paragraphs if p.strip()] # Use singular or plural form based on number of paragraphs header = note_header if len(non_empty_paragraphs) == 1 else notes_header notes_html = f""" <div class="notes-section"> <div class="notes-header">{header}</div> <div class="notes-content"> {notes_content} </div> </div> """ # Combine all sections with updated header structure return f""" <div class="lesson"> <div class="lesson-header"> <div class="corner top-left"></div> <div class="corner top-right"></div> <div class="corner bottom-left"></div> <div class="corner bottom-right"></div> <div class="lesson-circle">{lesson.get('number', '')}</div> <div class="lesson-title-container"> <div class="lesson-title" style="font-size: {title_font_size};top: {title_top}">{lesson.get('title', '')}</div> <div class="lesson-date">{lesson.get('date', '')}</div> </div> </div> {preliminary_html} {question_sections_html} {additional_sections_html} {notes_html} </div> """
[docs] @staticmethod def create_table_of_contents(lessons, language_code='en', config=None): """ Creates the table of contents HTML with links to lessons Args: lessons (list): List of lesson dictionaries language_code (str): Language code for translations config (dict, optional): Configuration dictionary containing language_config_path Returns: str: HTML for table of contents """ toc_rows = "" # Get translations table_title = LanguageConfig.get_translation(language_code, 'table_of_contents', 'TABLE OF CONTENTS', config) lesson_column = LanguageConfig.get_translation(language_code, 'lesson_column', 'Lesson', config) title_column = LanguageConfig.get_translation(language_code, 'title_column', 'Title', config) date_column = LanguageConfig.get_translation(language_code, 'date_column', 'Date', config) page_column = LanguageConfig.get_translation(language_code, 'page_column', 'Page', config) for lesson in lessons: # Only include items that have proper lesson structure if 'number' in lesson and 'title' in lesson and 'date' in lesson: toc_row = f""" <tr> <td style="width: 40px; padding: 5px;">{lesson['number']}</td> <td style=""><a href="#lesson-{lesson['number']}">{lesson['title']}</a></td> <td style="">{lesson['date']}</td> <td style="width: 40px; padding: 5px; text-align: right;">{lesson['number']}</td> </tr> """ toc_rows += toc_row return f""" <div class="toc-title">{table_title}</div> <table class="toc-table"> <tr class="header"> <td style="width: 40px; padding: 5px;">{lesson_column}</td> <td style="padding: 5px;">{title_column}</td> <td style="width: 100px; padding: 5px;">{date_column}</td> <td style="width: 40px; padding: 5px; text-align: right;">{page_column}</td> </tr> {toc_rows} </table> <div class="sectionbreaknone"></div> """
[docs] @staticmethod def fix_markdown_lists(markdown_content): """ Fix markdown list rendering by indenting all non-numbered list item lines, but only if at least one numbered list item exists in the content. Args: markdown_content (str): Markdown content with or without numbered lists Returns: str: Fixed markdown content with proper indentation for list continuity """ # Split the content into lines lines = markdown_content.split('\n') # If there's only one line, return the content as is if len(lines) == 1: return markdown_content # Check if there's at least one numbered list item has_numbered_list = any(re.match(r'^\s*(\d+)\.\s+', line) for line in lines) # Only indent non-numbered list lines if there's at least one numbered list item if has_numbered_list: lines = list(map(lambda line: "\t" + line if not re.match(r'^\s*(\d+)\.\s+', line) else line, lines)) # Join the lines back together and return the modified content return '\n'.join(lines)
[docs] @staticmethod def create_debug_html_with_css(content_parts, dynamic_css): """ Creates HTML with current content parts and CSS Args: content_parts (list): List of HTML content parts dynamic_css (str): CSS content with modifications Returns: str: Complete HTML string """ # HTML structure with placeholders html_structure = """<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>Sabbath School Lessons</title> <!-- CSS_PLACEHOLDER --> </head> <body> <!-- CONTENT_PLACEHOLDER --> </body> </html>""" # Join all content parts all_content = ''.join(content_parts) # Insert the content and CSS into the HTML structure complete_html = html_structure.replace("<!-- CSS_PLACEHOLDER -->", f"<style>{dynamic_css}</style>") complete_html = complete_html.replace("<!-- CONTENT_PLACEHOLDER -->", all_content) return complete_html
[docs] @staticmethod def add_section(content_parts, dynamic_css, state, section_name, section_html, start_on_odd=True, reset_counter=False): """ Adds a section to the document, ensuring it starts on the correct page Args: content_parts (list): List of HTML content parts dynamic_css (str): CSS content with modifications state (dict): Current state tracking page numbers, etc. section_name (str): Name of the section for comments section_html (str): HTML content for the section start_on_odd (bool): Whether section should start on odd-numbered page reset_counter (bool): Whether to reset page counter for this section Returns: tuple: (updated content_parts, updated dynamic_css, updated state) """ absolute_page_number = state.get('absolute_page_number', 1) # Check if we need to add a blank page to start on correct page type if start_on_odd and absolute_page_number % 2 == 0: # Need odd page but on even # Add a blank page content_parts.append('<div class="blank-page" style="page-break-after: always; height: 100vh;"></div>') # Add comment for debugging content_parts.append(f'<!-- Added blank page to ensure {section_name} starts on odd page {absolute_page_number + 1} -->') absolute_page_number += 1 state['absolute_page_number'] = absolute_page_number elif not start_on_odd and absolute_page_number % 2 == 1: # Need even page but on odd # Add a blank page content_parts.append('<div class="blank-page" style="page-break-after: always; height: 100vh;"></div>') # Add comment for debugging content_parts.append(f'<!-- Added blank page to ensure {section_name} starts on even page {absolute_page_number + 1} -->') absolute_page_number += 1 state['absolute_page_number'] = absolute_page_number # Add page-specific CSS rule if needed section_start_page = absolute_page_number if reset_counter: dynamic_css += f""" /* {section_name} starts on page {section_start_page} */ @page :nth({section_start_page}) {{ counter-reset: page 1; /* Reset page counter for {section_name} */ }} """ else: dynamic_css += f""" /* {section_name} starts on page {section_start_page} */ @page :nth({section_start_page}) {{ /* {section_name} specific styling can go here */ }} """ # Add the section content content_parts.append(section_html) # Add comment for debugging content_parts.append(f'<!-- {section_name}: starts at page {absolute_page_number} -->') # Estimate how many pages this section will add # This is a simplistic estimate - for accurate counts, we'd need to render the HTML estimated_pages = section_html.count('page-break-after: always') + 1 # Update the absolute page number absolute_page_number += estimated_pages state['absolute_page_number'] = absolute_page_number # Add comment about section page count content_parts.append(f'<!-- {section_name}: estimated {estimated_pages} pages -->') return content_parts, dynamic_css, state
[docs] @staticmethod def generate_html(content_data, front_cover_svg_path=None, back_cover_svg_path=None, config=None): """ Generate complete HTML document from content data with incremental approach Args: content_data (dict): Dictionary with content data front_cover_svg_path (str, optional): Path to front cover SVG back_cover_svg_path (str, optional): Path to back cover SVG config (dict, optional): Configuration dictionary Returns: str: Complete HTML document """ lessons = content_data['lessons'] frontmatter = content_data['frontmatter'] backmatter = content_data['backmatter'] # Get language code from config language_code = config.get('language', 'en') if config else 'en' # Initialize state tracking state = { 'absolute_page_number': 1 } # Initialize content parts and CSS content_parts = [] dynamic_css = CSS_TEMPLATE # Update CSS with configuration if available if config: dynamic_css = CssUpdater.update_css_template(dynamic_css, config, content_data) # Replace first page selector with nth-child selector for more control dynamic_css = dynamic_css.replace("@page :first {", "@page :nth(1) {") # 1. Add cover page - pass the config to the cover page creation cover_html = HtmlGenerator.create_cover_page(front_cover_svg_path, config) content_parts, dynamic_css, state = HtmlGenerator.add_section( content_parts, dynamic_css, state, "Cover page", cover_html, start_on_odd=True, reset_counter=False ) # 2. Add front matter if present if frontmatter: frontmatter_html = f'<div class="frontmatter-container">{HtmlGenerator.create_frontmatter_html(frontmatter)}</div>' content_parts, dynamic_css, state = HtmlGenerator.add_section( content_parts, dynamic_css, state, "Front matter", frontmatter_html, start_on_odd=True, reset_counter=True ) # 3. Add table of contents - pass language_code toc_html = f'<div class="frontmatter-container">{HtmlGenerator.create_table_of_contents(lessons, language_code)}</div>' content_parts, dynamic_css, state = HtmlGenerator.add_section( content_parts, dynamic_css, state, "Table of contents", toc_html, start_on_odd=True, reset_counter=False ) # 4. Add main content (lessons) main_content_html = '<div class="mainmatter-container">' # Add each lesson - pass language_code for lesson in lessons: main_content_html += f'<div id="lesson-{lesson["number"]}">{HtmlGenerator.create_lesson_html(lesson, language_code)}</div>' # Add back matter if present if backmatter: main_content_html += HtmlGenerator.create_backmatter_html(backmatter) main_content_html += '<div style="page-break-after: always;"></div>' # Close main content container main_content_html += '</div>' content_parts, dynamic_css, state = HtmlGenerator.add_section( content_parts, dynamic_css, state, "Main content", main_content_html, start_on_odd=True, reset_counter=True ) # 5. Add blank pages to ensure total is divisible by 4 total_pages = state['absolute_page_number'] - 1 remainder = total_pages % 4 if remainder != 0: blank_pages_needed = 4 - remainder blank_html = "" for i in range(blank_pages_needed): blank_html += '<div class="blank-page" style="page-break-after: always; height: 100vh;"></div>' content_parts, dynamic_css, state = HtmlGenerator.add_section( content_parts, dynamic_css, state, "Padding blank pages", blank_html, start_on_odd=False, reset_counter=False ) # 6. Add back cover if provided if back_cover_svg_path: back_cover_html = HtmlGenerator.create_back_cover(back_cover_svg_path) content_parts, dynamic_css, state = HtmlGenerator.add_section( content_parts, dynamic_css, state, "Back cover", back_cover_html, start_on_odd=False, reset_counter=False ) # Generate the complete HTML document return HtmlGenerator.create_debug_html_with_css(content_parts, dynamic_css)