הראה קוד מקור ל services.image_generator

"""
שירות ליצירת תמונות קוד עם היילייטינג (Playwright → WeasyPrint → PIL).

Code Image Generator Service

מסלול מועדף:

- Playwright (אם מותקן) – רינדור HTML בדפדפן headless באיכות גבוהה.
  Playwright רץ ב-subprocess נפרד כדי להימנע מקונפליקט עם gevent monkey patching.
- WeasyPrint (אם מותקן) – רינדור HTML איכותי.
- PIL fallback – ציור ידני עם סקייל x3 לשיפור חדות.
"""

from __future__ import annotations

import base64
import io
import json
import logging
import re
import subprocess
import sys
from pathlib import Path
from typing import Optional, Tuple, List

logger = logging.getLogger(__name__)

try:  # Pillow (נדרש)
    from PIL import Image, ImageDraw, ImageFont, ImageFilter
    from PIL.ImageFont import FreeTypeFont  # type: ignore
except Exception:  # pragma: no cover
    Image = None  # type: ignore[assignment]
    ImageDraw = None  # type: ignore[assignment]
    ImageFont = None  # type: ignore[assignment]
    FreeTypeFont = None  # type: ignore[assignment]

try:  # Pygments (נדרש)
    from pygments import highlight
    from pygments.formatters import HtmlFormatter
    from pygments.lexers import (
        get_lexer_by_name,
        get_lexer_for_filename,
        guess_lexer,
    )
    from pygments.style import Style
    from pygments.styles import get_style_by_name
    from pygments.token import Comment, Error, Generic, Keyword, Name, Number, Operator, String, Text
    from pygments.util import ClassNotFound
except Exception:  # pragma: no cover
    highlight = None  # type: ignore[assignment]
    HtmlFormatter = None  # type: ignore[assignment]
    get_lexer_by_name = None  # type: ignore[assignment]
    get_lexer_for_filename = None  # type: ignore[assignment]
    guess_lexer = None  # type: ignore[assignment]
    Style = None  # type: ignore[assignment]
    get_style_by_name = None  # type: ignore[assignment]
    ClassNotFound = Exception  # type: ignore[assignment]


if Style is not None:
[תיעוד] class TechGuideStyle(Style): """ ערכת הצבעים של Tech Guide: רקע כהה מאוד, צבעים חזקים וברורים לקוד. """ default_style = "" styles = { Text: '#d4d4d4', # טקסט רגיל (אפור בהיר) Comment: '#6a9955 italic', # הערות (ירוק) Keyword: '#569cd6', # מילות מפתח (כחול - כמו ב-VS Code המקורי) Keyword.Type: '#4ec9b0', # טיפוסים (טורקיז) Name: '#dcdcaa', # שמות משתנים/כללי Name.Function: '#dcdcaa', # פונקציות (צהוב בהיר) Name.Class: '#4ec9b0 bold', # מחלקות String: '#ce9178', # מחרוזות (כתום - ה-Signature של Tech Guide) Number: '#b5cea8', # מספרים (ירוק בהיר) Operator: '#d4d4d4', # אופרטורים Generic.Heading: '#569cd6 bold', Generic.Subheading: '#569cd6 bold', Error: '#f44747', }
else: # pragma: no cover TechGuideStyle = None # type: ignore[assignment]
[תיעוד] class CodeImageGenerator: """מחולל תמונות לקוד עם הדגשת תחביר. מבוסס על PIL לציור טקסט ואזורי מספרי שורות, ועל Pygments ליצירת HTML מודגש שממנו מחולצים צבעים בסיסיים. """ DEFAULT_WIDTH = 1200 DEFAULT_PADDING = 40 LINE_HEIGHT = 24 FONT_SIZE = 14 LINE_NUMBER_WIDTH = 60 LOGO_SIZE = (80, 20) LOGO_PADDING = 10 # Layout constants (px at 1x DPR) CARD_MARGIN = 18 TITLE_BAR_HEIGHT = 28 CODE_GUTTER_SPACING = 20 THEMES = { 'dark': { 'background': '#1e1e1e', 'text': '#d4d4d4', 'line_number_bg': '#252526', 'line_number_text': '#858585', 'border': '#3e3e42', }, 'light': { 'background': '#ffffff', 'text': '#333333', 'line_number_bg': '#f5f5f5', 'line_number_text': '#999999', 'border': '#e0e0e0', }, 'github': { 'background': '#0d1117', 'text': '#c9d1d9', 'line_number_bg': '#161b22', 'line_number_text': '#7d8590', 'border': '#30363d', }, 'monokai': { 'background': '#272822', 'text': '#f8f8f2', 'line_number_bg': '#3e3d32', 'line_number_text': '#75715e', 'border': '#49483e', }, 'gruvbox': { 'background': '#282828', # Gruvbox Dark 'text': '#ebdbb2', 'line_number_bg': '#3c3836', 'line_number_text': '#bdae93', 'border': '#504945', }, 'one_dark': { 'background': '#282c34', 'text': '#abb2bf', 'line_number_bg': '#21252b', 'line_number_text': '#5c6370', 'border': '#3b4048', }, 'dracula': { 'background': '#282a36', 'text': '#f8f8f2', 'line_number_bg': '#1e1f29', 'line_number_text': '#6272a4', 'border': '#44475a', }, 'banner_tech': { # הרקע מסביב לכרטיס (ה-Wallpaper) - הסגול של הממשק 'background': '#1d1a26', # צבע הטקסט הראשי 'text': '#d4d4d4', # הרקע של "העורך" בתוך הכרטיס - כהה מאוד כדי שהקוד יבלוט 'line_number_bg': '#14121a', # צבע מספרי השורות 'line_number_text': '#6e6290', # צבע גבול סגול עדין 'border': '#4b4363', # צבע חתימה/Watermark 'watermark': '#7cd827', }, }
[תיעוד] def __init__(self, style: str = 'monokai', theme: str = 'dark', *, font_family: Optional[str] = None) -> None: if Image is None: raise ImportError("Pillow is required for image generation") if highlight is None: raise ImportError("Pygments is required for syntax highlighting") self.style = style self.theme = theme self.colors = self.THEMES.get(theme, self.THEMES['dark']) self.font_family = (font_family or '').strip().lower() or None self._font_cache: dict[str, FreeTypeFont] = {} self._note_font_cache: dict[str, FreeTypeFont] = {} self._logo_cache: Optional[Image.Image] = None # Playwright (מועדף) – רץ ב-subprocess נפרד כדי להימנע מקונפליקט עם gevent self._has_playwright = False try: # pragma: no cover - תלות אופציונלית import playwright # noqa: F401 # בדיקה שדפדפן Chromium באמת מותקן (לא רק החבילה) pw_browsers_path = Path(playwright.__file__).parent / 'driver' / 'package' / '.local-browsers' if not pw_browsers_path.is_dir(): # Fallback: בדיקה דרך env var או נתיב ברירת מחדל לפי מערכת הפעלה import os, platform env_path = os.environ.get('PLAYWRIGHT_BROWSERS_PATH', '') if env_path: check_path = Path(env_path) elif platform.system() == 'Darwin': check_path = Path.home() / 'Library' / 'Caches' / 'ms-playwright' elif platform.system() == 'Windows': check_path = Path(os.environ.get('USERPROFILE', '~')) / 'AppData' / 'Local' / 'ms-playwright' else: check_path = Path.home() / '.cache' / 'ms-playwright' has_browsers = check_path.is_dir() and any(check_path.iterdir()) else: has_browsers = any(pw_browsers_path.iterdir()) if has_browsers: self._has_playwright = True logger.info("Playwright available with browser binaries") else: logger.warning("Playwright package installed but no browser binaries found – run 'playwright install chromium'") except ImportError: logger.debug("Playwright not installed") except Exception as exc: logger.warning("Playwright check failed: %s", exc) # WeasyPrint (fallback) – אופציונלי try: # pragma: no cover - תלות אופציונלית import weasyprint # noqa: F401 self._has_weasyprint = True except Exception: self._has_weasyprint = False
# --- Fonts & Logo ----------------------------------------------------- def _get_font(self, size: int, bold: bool = False) -> FreeTypeFont: cache_key = f"{size}_{int(bold)}" if cache_key in self._font_cache: return self._font_cache[cache_key] # מסלולי פונט מונוספייס נפוצים + העדפה לפי בחירת המשתמש (אם צוינה) preferred_map = { 'dejavu': [ '/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf', ], 'jetbrains': [ '/usr/share/fonts/truetype/jetbrains-mono/JetBrainsMono-Regular.ttf', '/usr/share/fonts/truetype/jetbrainsmono/JetBrainsMono-Regular.ttf', '/usr/share/fonts/truetype/ttf-jetbrains-mono/JetBrainsMono-Regular.ttf', '/usr/share/fonts/opentype/jetbrains-mono/JetBrainsMono-Regular.otf', ], 'cascadia': [ '/usr/share/fonts/truetype/cascadia/CascadiaCode.ttf', '/usr/share/fonts/truetype/cascadia/CascadiaCode-Regular.ttf', '/usr/share/fonts/truetype/cascadiamono/CascadiaMono.ttf', ], } default_paths = [ '/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf', '/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf', 'C:/Windows/Fonts/consola.ttf', '/System/Library/Fonts/Menlo.ttc', ] font_paths = [] if self.font_family and self.font_family in preferred_map: font_paths.extend(preferred_map[self.font_family]) font_paths.extend(default_paths) font: Optional[FreeTypeFont] = None for p in font_paths: try: path = Path(p) if path.exists(): font = ImageFont.truetype(str(path), size) if bold: # נסה להסיק מסלול "Bold" קרוב bold_path = str(path).replace('Regular', 'Bold').replace('.ttf', '-Bold.ttf') if Path(bold_path).exists(): font = ImageFont.truetype(bold_path, size) break except Exception: continue if font is None: font = ImageFont.load_default() self._font_cache[cache_key] = font # type: ignore[assignment] return font # type: ignore[return-value] def _get_note_font(self, size: int, bold: bool = False) -> FreeTypeFont: """פונט קריא להערות (לא מונוספייס) עם תמיכה מלאה בעברית.""" cache_key = f"{size}_{int(bold)}" if cache_key in self._note_font_cache: return self._note_font_cache[cache_key] # DejaVuSans כולל כיסוי עברית; נוסיף fallback-ים פופולריים. primary = '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf' candidates = [ primary, '/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf', '/usr/share/fonts/truetype/freefont/FreeSans.ttf', '/System/Library/Fonts/SFNSDisplay.ttf', 'C:/Windows/Fonts/segoeui.ttf', ] if bold: bold_candidates = [ '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', '/usr/share/fonts/truetype/noto/NotoSans-Bold.ttf', '/usr/share/fonts/truetype/freefont/FreeSansBold.ttf', '/System/Library/Fonts/SFNSText.ttf', 'C:/Windows/Fonts/segoeuib.ttf', ] candidates = bold_candidates + candidates font: Optional[FreeTypeFont] = None for path_str in candidates: try: path = Path(path_str) if not path.exists(): continue font = ImageFont.truetype(str(path), size) break except Exception: continue if font is None: font = ImageFont.load_default() self._note_font_cache[cache_key] = font # type: ignore[assignment] return font # type: ignore[return-value] def _get_logo_image(self) -> Optional[Image.Image]: if self._logo_cache is not None: return self._logo_cache # נסה לטעון לוגו מקובץ try: candidates = [ Path(__file__).parent.parent / 'assets' / 'logo.png', Path(__file__).parent.parent / 'assets' / 'logo_small.png', ] for path in candidates: if path.exists(): logo = Image.open(str(path)) logo = logo.resize(self.LOGO_SIZE, Image.Resampling.LANCZOS) self._logo_cache = logo return logo except Exception: pass # Fallback: לוגו טקסטואלי קטן try: logo = Image.new('RGBA', self.LOGO_SIZE, (0, 0, 0, 0)) draw = ImageDraw.Draw(logo) font = self._get_font(10, bold=True) text = "@my_code_keeper_bot" bbox = draw.textbbox((0, 0), text, font=font) tw = max(0, bbox[2] - bbox[0]) th = max(0, bbox[3] - bbox[1]) x = max(0, (self.LOGO_SIZE[0] - tw) // 2) y = max(0, (self.LOGO_SIZE[1] - th) // 2) try: bg_rgb = self._parse_color(str(self.colors.get('line_number_bg') or '#1e1e1e')) except Exception: bg_rgb = (30, 30, 30) try: wm_rgb = self._parse_color(str(self.colors.get('watermark') or '#ffffff')) except Exception: wm_rgb = (255, 255, 255) draw.rectangle([(0, 0), self.LOGO_SIZE], fill=(bg_rgb[0], bg_rgb[1], bg_rgb[2], 200)) draw.text((x, y), text, fill=(wm_rgb[0], wm_rgb[1], wm_rgb[2], 255), font=font) self._logo_cache = logo return logo except Exception as e: # pragma: no cover logger.warning(f"Failed to create logo: {e}") return None # --- HTML colors extraction ------------------------------------------ def _html_to_text_colors(self, html_str: str) -> List[Tuple[str, str]]: """Extract (text,color) segments from a single highlighted HTML line, preserving whitespace. We intentionally do not strip() so that leading spaces/tabs remain intact. Note: We work directly on the raw HTML string instead of passing it through BeautifulSoup, because the html.parser collapses leading whitespace (e.g. 4 spaces become 1), which destroys code indentation in the PIL fallback. """ import html as _html_mod # הסרת תגיות style/script עם תוכנן (ללא BeautifulSoup כדי לשמור על רווחים) s = re.sub(r'<(style|script)\b[^>]*>.*?</\1>', '', html_str, flags=re.DOTALL | re.IGNORECASE) text_colors: List[Tuple[str, str]] = [] pattern = r'<span[^>]*style="[^"]*color:\s*([^;"\s]+)[^"]*"[^>]*>(.*?)</span>' last = 0 for m in re.finditer(pattern, s, flags=re.DOTALL): before = s[last:m.start()] if before: clean = re.sub(r'<[^>]+>', '', before) clean = _html_mod.unescape(clean) if clean != "": text_colors.append((clean, self.colors['text'])) color = m.group(1).strip() inner = re.sub(r'<[^>]+>', '', m.group(2)) inner = _html_mod.unescape(inner) if inner != "": text_colors.append((inner, color)) last = m.end() tail = s[last:] if tail: clean = re.sub(r'<[^>]+>', '', tail) clean = _html_mod.unescape(clean) if clean != "": text_colors.append((clean, self.colors['text'])) if not text_colors: clean_all = re.sub(r'<[^>]+>', '', s) clean_all = _html_mod.unescape(clean_all) if clean_all != "": text_colors.append((clean_all, self.colors['text'])) return text_colors @staticmethod def _parse_color(color_str: str) -> Tuple[int, int, int]: c = color_str.strip().lower() if c.startswith('#'): h = c[1:] if len(h) == 6: return tuple(int(h[i:i+2], 16) for i in (0, 2, 4)) # type: ignore[return-value] if len(h) == 3: return tuple(int(ch * 2, 16) for ch in h) # type: ignore[return-value] m = re.match(r'rgb\((\d+),\s*(\d+),\s*(\d+)\)', c) if m: return tuple(int(x) for x in m.groups()) # type: ignore[return-value] common = { 'white': (255, 255, 255), 'black': (0, 0, 0), 'red': (255, 0, 0), 'green': (0, 255, 0), 'blue': (0, 0, 255), 'yellow': (255, 255, 0), 'cyan': (0, 255, 255), 'magenta': (255, 0, 255), } return common.get(c, (212, 212, 212)) # --- HTML template for high-quality renderers ------------------------- def _create_professional_html(self, highlighted_html: str, lines: List[str], width: int, height: int) -> str: """ יוצר HTML עם עיצוב "Carbon-style" מקצועי. משתמש ב-inline styles של Pygments (noclasses=True), ולכן אין תלות בקלאסים חיצוניים של CSS להדגשת התחביר. """ # Build a font stack that respects the user's chosen monospace font # and falls back to fonts that include Hebrew glyphs (e.g., DejaVu). selected_css_font = None try: if self.font_family: key = str(self.font_family).strip().lower() name_map = { 'jetbrains': "'JetBrains Mono'", 'dejavu': "'DejaVu Sans Mono'", 'cascadia': "'Cascadia Code'", } selected_css_font = name_map.get(key) except Exception: selected_css_font = None # Prefer DejaVu early in the fallback chain for better RTL/Hebrew coverage # Keep as an explicit comma-separated CSS string (not a Python tuple) fallback_stack = "'DejaVu Sans Mono','Liberation Mono','Noto Sans Mono','SF Mono','Monaco','Inconsolata','Fira Code','Source Code Pro','Consolas','Courier New',monospace" font_stack = f"{selected_css_font}, {fallback_stack}" if selected_css_font else fallback_stack line_numbers_html = "\n".join(f'<span class="line-number">{i}</span>' for i in range(1, len(lines) + 1)) wm_color = self.colors.get('watermark') or 'rgba(255,255,255,0.6)' if isinstance(wm_color, tuple): wm_color = f"rgb({wm_color[0]},{wm_color[1]},{wm_color[2]})" # כיתוב watermark טקסטואלי – אם אחר כך נוסיף לוגו אמיתי ב-PIL, נמנע כפילות html_doc = f"""<!DOCTYPE html> <html lang="he" dir="ltr"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <style> * {{ box-sizing: border-box; }} html, body {{ margin:0; padding:0; }} body {{ font-family: {font_stack}; background-color: {self.colors['background']}; color: {self.colors['text']}; font-size: {self.FONT_SIZE}px; line-height: {self.LINE_HEIGHT}px; padding: {self.DEFAULT_PADDING}px; width: {width}px; min-height: {height}px; overflow: hidden; direction: ltr; text-align: left; }} .wrap {{ position: relative; width: 100%; background: {self.colors['line_number_bg']}; border-radius: 10px; box-shadow: 0 10px 30px rgba(0,0,0,0.25); overflow: hidden; }} .title {{ height: 28px; background: {self.colors['line_number_bg']}; border-bottom: 1px solid {self.colors['border']}; display: flex; align-items:center; gap:8px; padding-inline-start: 16px; }} .tl {{ width:12px; height:12px; border-radius:50%; display:inline-block; }} .tl.red {{ background:#ff5f56; }} .tl.yellow {{ background:#ffbd2e; }} .tl.green {{ background:#27c93f; }} .content {{ display:flex; direction:ltr; }} .nums {{ background: {self.colors['line_number_bg']}; color: {self.colors['line_number_text']}; padding: {self.DEFAULT_PADDING}px 10px {self.DEFAULT_PADDING}px 16px; min-width: {self.LINE_NUMBER_WIDTH}px; border-inline-end: 1px solid {self.colors['border']}; user-select: none; text-align: end; font-size: {self.FONT_SIZE - 1}px; }} .line-number {{ display:block; line-height: {self.LINE_HEIGHT}px; opacity:0.7; }} .code {{ flex:1; padding: {self.DEFAULT_PADDING}px 16px; overflow: hidden; direction: ltr; text-align: left; }} pre {{ margin:0; white-space: pre; font-family: inherit; direction: ltr; text-align: left; }} code {{ font-family: inherit; direction: ltr; text-align: left; }} .wm {{ position: absolute; bottom: {self.LOGO_PADDING}px; right: {self.LOGO_PADDING}px; font-size: 11px; color: {wm_color}; background: rgba(0,0,0,0.25); padding: 4px 8px; border-radius: 4px; font-weight: 700; }} </style> </head> <body> <div class="wrap"> <div class="title"><span class="tl red"></span><span class="tl yellow"></span><span class="tl green"></span></div> <div class="content"> <div class="nums">{line_numbers_html}</div> <div class="code"><pre><code>{highlighted_html}</code></pre></div> </div> <div class="wm">@my_code_keeper_bot</div> </div> </body> </html>""" return html_doc # --- Language detection & safety ------------------------------------- def _detect_language_from_content(self, code: str, filename: Optional[str] = None) -> str: if filename: lang_map = { '.py': 'python', '.js': 'javascript', '.ts': 'typescript', '.tsx': 'tsx', '.jsx': 'jsx', '.java': 'java', '.cpp': 'cpp', '.c': 'c', '.cs': 'csharp', '.php': 'php', '.rb': 'ruby', '.go': 'go', '.rs': 'rust', '.swift': 'swift', '.kt': 'kotlin', '.scala': 'scala', '.clj': 'clojure', '.hs': 'haskell', '.ml': 'ocaml', '.r': 'r', '.sql': 'sql', '.sh': 'bash', '.yaml': 'yaml', '.yml': 'yaml', '.json': 'json', '.xml': 'xml', '.html': 'html', '.css': 'css', '.scss': 'scss', '.md': 'markdown', '.tex': 'latex', '.vue': 'vue', } ext = Path(filename).suffix.lower() if ext in lang_map: return lang_map[ext] patterns = { 'python': [r'def\s+\w+\s*\(', r'import\s+\w+', r'from\s+\w+\s+import', r'class\s+\w+.*:', r'__main__'], 'javascript': [r'function\s+\w+\s*\(', r'const\s+\w+\s*=', r'=>\s*{', r'var\s+\w+\s*=', r'let\s+\w+\s*='], 'java': [r'public\s+class\s+\w+', r'public\s+static\s+void\s+main', r'@Override', r'package\s+\w+'], 'cpp': [r'#include\s*<', r'std::', r'int\s+main\s*\('], 'bash': [r'#!/bin/(ba)?sh', r'\$\{', r' if \['], 'sql': [r'SELECT\s+.*\s+FROM', r'INSERT\s+INTO', r'CREATE\s+TABLE'], } for lang, pats in patterns.items(): if any(re.search(p, code, flags=re.IGNORECASE | re.MULTILINE) for p in pats): return lang return 'text' def _check_code_safety(self, code: str) -> None: suspicious = [r'exec\s*\(', r'eval\s*\(', r'__import__\s*\(', r'os\.system\s*\(', r'subprocess\.', r'compile\s*\('] for p in suspicious: if re.search(p, code, flags=re.IGNORECASE): logger.warning("Suspicious code pattern detected: %s", p) # --- Playwright / WeasyPrint renderers --------------------------------
[תיעוד] def cleanup(self) -> None: """תאימות לאחור – אין צורך בפעולה לאחר מעבר ל-Async Playwright.""" return
[תיעוד] def configure( self, *, style: Optional[str] = None, theme: Optional[str] = None, font_family: Optional[str] = None, ) -> None: """עדכון דינמי של ההעדפות בלי יצירת מופע חדש.""" if style is not None and style != self.style: self.style = style if theme is not None and theme != self.theme: self.theme = theme self.colors = self.THEMES.get(theme, self.THEMES['dark']) # ה-watermark/הרקעים יכולים להשתנות לפי תמה – נבנה לוגו מחדש אם צריך self._logo_cache = None if font_family is not None: normalized = (font_family or '').strip().lower() or None if normalized != self.font_family: self.font_family = normalized self._font_cache.clear() self._logo_cache = None
def _render_html_with_playwright(self, html_content: str, width: int, height: int) -> Image.Image: if not self._has_playwright: raise RuntimeError("Playwright is not available") # מריץ את Playwright ב-subprocess נפרד כדי להימנע מקונפליקט עם gevent monkey patching. # התהליך הנפרד לא יורש את ה-monkey patching ולכן Playwright יעבוד נכון. logger.debug("Playwright: running in subprocess to avoid gevent conflicts...") # Script Python שרץ בתהליך נפרד ומבצע את הרנדור subprocess_script = ''' import sys import base64 import traceback from playwright.sync_api import sync_playwright def render(html_content, width, height): with sync_playwright() as playwright: browser = playwright.chromium.launch(headless=True) try: page = browser.new_page( viewport={'width': width, 'height': height}, device_scale_factor=2, ) page.set_content(html_content, wait_until='load') page.wait_for_timeout(300) png_bytes = page.screenshot(type='png', full_page=True) page.close() return png_bytes finally: browser.close() if __name__ == '__main__': import json try: data = json.loads(sys.stdin.read()) png_bytes = render(data['html'], data['width'], data['height']) sys.stdout.write(base64.b64encode(png_bytes).decode('ascii')) except Exception: traceback.print_exc() sys.exit(1) ''' # Prepare input data input_data = json.dumps({ 'html': html_content, 'width': width, 'height': height, }) logger.debug("Playwright subprocess: starting...") try: result = subprocess.run( [sys.executable, '-c', subprocess_script], input=input_data, capture_output=True, text=True, timeout=60, # 60 seconds timeout ) if result.returncode != 0: stderr = result.stderr or '' logger.warning("Playwright subprocess failed: %s", stderr[:500]) raise RuntimeError(f"Playwright subprocess failed: {stderr[:200]}") # Decode base64 output png_bytes = base64.b64decode(result.stdout) logger.debug("Playwright subprocess: got %d bytes of PNG data", len(png_bytes)) return Image.open(io.BytesIO(png_bytes)) except subprocess.TimeoutExpired: logger.warning("Playwright subprocess timed out after 60s") raise RuntimeError("Playwright subprocess timed out") def _render_html_with_weasyprint(self, html_content: str, width: int, height: int) -> Image.Image: from weasyprint import HTML, CSS # type: ignore css = CSS(string=f""" @page {{ size: {width}px {height}px; margin: 0; }} body {{ margin:0; padding:0; background: {self.colors['background']}; color: {self.colors['text']}; }} """) png_bytes = HTML(string=html_content).write_png(stylesheets=[css]) return Image.open(io.BytesIO(png_bytes)) # --- Render helpers ---------------------------------------------------
[תיעוד] def optimize_image_size(self, img: Image.Image) -> Image.Image: if img.mode != 'RGB': img = img.convert('RGB') max_size = (2000, 2000) if img.width > max_size[0] or img.height > max_size[1]: img.thumbnail(max_size, Image.Resampling.LANCZOS) return img
[תיעוד] def save_optimized_png(self, img: Image.Image) -> bytes: """Always return PNG for crisp code images; keep optimization, avoid JPEG.""" # Pillow 12+ עשוי לטעון פורמטים בצורה עצלה יותר. בחלק מהסביבות זה גורם ל- # KeyError: 'PNG' בזמן save אם פלאגין PNG עדיין לא נרשם. # נוודא אתחול רישום הפורמטים לפני השמירה. try: Image.init() except Exception: pass buf = io.BytesIO() img.save(buf, format='PNG', optimize=True, compress_level=9) return buf.getvalue()
# --- Public API -------------------------------------------------------
[תיעוד] def generate_image( self, code: str, language: str = 'text', filename: Optional[str] = None, max_width: int = DEFAULT_WIDTH, max_height: Optional[int] = None, note: Optional[str] = None, ) -> bytes: if not isinstance(code, str): raise TypeError("Code must be a string") if not code: raise ValueError("Code cannot be empty") if len(code) > 100_000: raise ValueError("Code too large (max 100KB)") if language and not isinstance(language, str): raise TypeError("Language must be a string") self._check_code_safety(code) # Detect language if needed if not language or language == 'text': language = self._detect_language_from_content(code, filename) # Prepare lexer/formatter try: if filename: try: lexer = get_lexer_for_filename(filename) # type: ignore[misc] except ClassNotFound: lexer = get_lexer_by_name(language, stripall=True) # type: ignore[misc] else: lexer = get_lexer_by_name(language, stripall=True) # type: ignore[misc] except Exception: lexer = get_lexer_by_name('text', stripall=True) # type: ignore[misc] try: if self.style == 'banner_tech' and TechGuideStyle is not None: style = TechGuideStyle else: style = get_style_by_name(self.style) # type: ignore[misc] except Exception: style = get_style_by_name('default') # type: ignore[misc] formatter = HtmlFormatter(style=style, noclasses=True, nowrap=True) # type: ignore[call-arg] highlighted_html = highlight(code, lexer, formatter) # type: ignore[misc] # Layout calculations lines = code.split('\n') num_lines = len(lines) font = self._get_font(self.FONT_SIZE) line_height = self.LINE_HEIGHT max_line_width = 0 for ln in lines: try: bbox = font.getbbox(ln) # type: ignore[attr-defined] w = max(0, bbox[2] - bbox[0]) except Exception: w = len(ln) * 8 max_line_width = max(max_line_width, w) # Total width must include: card margins (both sides), left padding, line numbers, gutter, code, right padding content_width = ( self.CARD_MARGIN * 2 + self.DEFAULT_PADDING + self.LINE_NUMBER_WIDTH + self.CODE_GUTTER_SPACING + max_line_width + self.DEFAULT_PADDING ) image_width = min(int(content_width), int(max_width or self.DEFAULT_WIDTH)) # Height includes card margins, title bar, top/bottom padding and lines base_overhead = self.CARD_MARGIN * 2 + self.TITLE_BAR_HEIGHT + self.DEFAULT_PADDING * 2 image_height = int(num_lines * line_height + base_overhead) truncated_by_height = False if max_height and image_height > max_height: avail = max(0, int(max_height) - base_overhead) max_lines = max(1, avail // line_height) if len(lines) > max_lines: lines = lines[:max_lines] num_lines = len(lines) image_height = int(num_lines * line_height + base_overhead) truncated_by_height = True # נסה תחילה HTML מקצועי עם Playwright/WeasyPrint, ואז fallback לציור ידני # אם קיצרנו לפי גובה, נבצע Highlight מחדש על הקוד המקוצר כדי למנוע חוסר תאום מול מספרי השורות if truncated_by_height: try: truncated_code = "\n".join(lines) highlighted_html = highlight(truncated_code, lexer, formatter) # type: ignore[misc] except Exception: # במקרה כשל, ננסה לפחות לחתוך לפי שורות HTML קיימות try: hh_lines = highlighted_html.split('\n') highlighted_html = "\n".join(hh_lines[:num_lines]) except Exception: pass full_html = self._create_professional_html(highlighted_html, lines, image_width, image_height) # 1) Playwright (מועדף) - רץ ב-subprocess נפרד if self._has_playwright: try: logger.info("Attempting Playwright render (subprocess)...") img = self._render_html_with_playwright(full_html, image_width, image_height) logger.info("Playwright render succeeded!") img = self._add_annotation_overlay(img, note) img = self.optimize_image_size(img) return self.save_optimized_png(img) except Exception as e: logger.warning("Playwright render failed, falling back to PIL. Error: %s (type: %s). " "If browsers are missing run: python -m playwright install chromium", e, type(e).__name__) # 2) WeasyPrint (fallback) if self._has_weasyprint: try: logger.info("Attempting WeasyPrint render...") img = self._render_html_with_weasyprint(full_html, image_width, image_height) logger.info("WeasyPrint render succeeded!") img = self._add_annotation_overlay(img, note) img = self.optimize_image_size(img) return self.save_optimized_png(img) except Exception as e: logger.warning("WeasyPrint render failed, falling back to PIL. Error: %s (type: %s)", e, type(e).__name__) # 3) Manual rendering via PIL (ברירת מחדל) עם DPR=3 לשיפור חדות logger.info("Using PIL fallback for rendering (Playwright=%s, WeasyPrint=%s)", self._has_playwright, self._has_weasyprint) scale = 3 s = scale # מידות בסקייל גבוה w2 = int(image_width * s) h2 = int(image_height * s) pad2 = int(self.DEFAULT_PADDING * s) lnw2 = int(self.LINE_NUMBER_WIDTH * s) line_h2 = int(line_height * s) # בסיס RGBA כדי לאפשר הצללה רכה – ודא שהרקע לפי התמה ולא שחור bg_val = self.colors.get('background') try: if isinstance(bg_val, tuple): bg_rgb = tuple(bg_val[:3]) else: bg_rgb = self._parse_color(str(bg_val)) except Exception: bg_rgb = (30, 30, 30) img2 = Image.new('RGBA', (w2, h2), (bg_rgb[0], bg_rgb[1], bg_rgb[2], 255)) draw = ImageDraw.Draw(img2) # פרמטרי כרטיס ושוליים radius = int(16 * s) card_margin = int(18 * s) card_x1, card_y1 = card_margin, card_margin card_x2, card_y2 = w2 - 1 - card_margin, h2 - 1 - card_margin card_rect = [(card_x1, card_y1), (card_x2, card_y2)] # Drop shadow: מסכה מטושטשת עם היסט קל sh_dx, sh_dy = int(6 * s), int(8 * s) sh_blur = max(2, int(16 * s)) mask = Image.new('L', (w2, h2), 0) mdraw = ImageDraw.Draw(mask) try: mdraw.rounded_rectangle( [(card_x1 + sh_dx, card_y1 + sh_dy), (card_x2 + sh_dx, card_y2 + sh_dy)], radius=radius, fill=180, ) except Exception: mdraw.rectangle([(card_x1 + sh_dx, card_y1 + sh_dy), (card_x2 + sh_dx, card_y2 + sh_dy)], fill=180) mask = mask.filter(ImageFilter.GaussianBlur(sh_blur)) shadow = Image.new('RGBA', (w2, h2), (0, 0, 0, 160)) img2.paste(shadow, (0, 0), mask) # כרטיס מעוגל (Rounded card) panel_fill = self.colors.get('line_number_bg', self.colors['background']) card_layer = Image.new('RGBA', (w2, h2), (0, 0, 0, 0)) cl_draw = ImageDraw.Draw(card_layer) try: cl_draw.rounded_rectangle(card_rect, radius=radius, fill=panel_fill) except Exception: cl_draw.rectangle(card_rect, fill=panel_fill) # Gradient עדין בתוך הכרטיס (מלמעלה לבהיר קצת) try: grad_h = max(1, card_y2 - card_y1 + 1) grad = Image.new('RGBA', (card_x2 - card_x1 + 1, grad_h), (0, 0, 0, 0)) # הכנה לצבעי גרדיאנט def _hex_to_rgb(h): h = h.lstrip('#') return tuple(int(h[i:i+2], 16) for i in (0, 2, 4)) def _to_rgb(c): return c if isinstance(c, tuple) else _hex_to_rgb(str(c)) base_rgb = _to_rgb(panel_fill) top_rgb = tuple(min(255, int(v * 1.06)) for v in base_rgb) bottom_rgb = base_rgb for y in range(grad_h): t = y / max(1, grad_h - 1) col = tuple(int(top_rgb[i] * (1 - t) + bottom_rgb[i] * t) for i in range(3)) + (255,) ImageDraw.Draw(grad).line([(0, y), (grad.width, y)], fill=col) # המסכה לעיגול פינות clip = Image.new('L', (w2, h2), 0) clip_draw = ImageDraw.Draw(clip) try: clip_draw.rounded_rectangle(card_rect, radius=radius, fill=255) except Exception: clip_draw.rectangle(card_rect, fill=255) img2.alpha_composite(card_layer) img2.paste(grad, (card_x1, card_y1), clip) except Exception: # אם יש כשל בגרדיאנט, לפחות החבר את שכבת הכרטיס img2.alpha_composite(card_layer) # Title bar title_h = int(28 * s) tb_rect = [(card_x1, card_y1), (card_x2, card_y1 + title_h)] tb_fill = self.colors.get('line_number_bg', self.colors['background']) draw.rectangle(tb_rect, fill=tb_fill) # Traffic lights cx = card_x1 + int(16 * s) cy = card_y1 + int(title_h / 2) r = int(5 * s) for idx, col in enumerate([(255, 95, 86), (255, 189, 46), (39, 201, 63)]): x = cx + idx * int(14 * s) draw.ellipse([(x - r, cy - r), (x + r, cy + r)], fill=col) # אזור מספרי שורות וקוד בתוך הכרטיס ln_bg_x2 = card_x1 + pad2 + lnw2 code_x = ln_bg_x2 + int(self.CODE_GUTTER_SPACING * s) code_y = card_y1 + title_h + pad2 # רקע מספרי שורות + קו מפריד draw.rectangle([(card_x1, card_y1 + title_h), (ln_bg_x2, card_y2)], fill=self.colors['line_number_bg']) draw.line([(ln_bg_x2, card_y1 + title_h), (ln_bg_x2, card_y2)], fill=self.colors['border'], width=max(1, s)) # פונטים בסקייל font = self._get_font(int(self.FONT_SIZE * s)) ln_font = self._get_font(max(1, int((self.FONT_SIZE - 1) * s))) html_lines = highlighted_html.split('\n') for i, (plain_line, html_line) in enumerate(zip(lines, html_lines[:len(lines)]), start=1): y = code_y + (i - 1) * line_h2 # line number num_str = str(i) try: bbox = ln_font.getbbox(num_str) # type: ignore[attr-defined] num_w = max(0, bbox[2] - bbox[0]) except Exception: num_w = len(num_str) * int(6 * s) num_x = ln_bg_x2 - num_w - int(10 * s) draw.text((num_x, y), num_str, fill=self.colors['line_number_text'], font=ln_font) # code segments according to spans – שמור טאבים ורווחים x = code_x segments = self._html_to_text_colors(html_line) if not segments: segments = [(plain_line, self.colors['text'])] for text, color_str in segments: if text is None: continue text = text.replace('\t', ' ') if text == "": continue color = self._parse_color(color_str) draw.text((x, y), text, fill=color, font=font) try: bbox = font.getbbox(text) # type: ignore[attr-defined] wseg = max(0, bbox[2] - bbox[0]) except Exception: wseg = len(text) * int(8 * s) x += wseg # לוגו (אופציונלי) – בתוך הכרטיס logo = self._get_logo_image() if logo is not None: try: l2 = logo.resize((int(self.LOGO_SIZE[0] * s), int(self.LOGO_SIZE[1] * s)), Image.Resampling.LANCZOS) except Exception: l2 = logo lx = max(card_x1 + pad2, card_x2 - l2.width - int(self.LOGO_PADDING * s)) ly = max(card_y1 + pad2, card_y2 - l2.height - int(self.LOGO_PADDING * s)) if l2.mode == 'RGBA': img2.paste(l2, (lx, ly), l2) else: img2.paste(l2, (lx, ly)) # Downscale בחיתוך איכותי לשמירה על חדות # המרה חזרה ל-RGB לפני downscale img_rgb = img2.convert('RGB') img = img_rgb.resize((int(image_width), int(image_height)), Image.Resampling.LANCZOS) img = self._add_annotation_overlay(img, note) img = self.optimize_image_size(img) return self.save_optimized_png(img)
# --- Notes overlay ------------------------------------------------------ def _add_annotation_overlay(self, img: Image.Image, note: Optional[str]) -> Image.Image: text = (note or "").strip() if not text: return img text = text[:220] try: base = img.convert('RGBA') except Exception: base = img inner_padding = 12 card_left_boundary = self.CARD_MARGIN + inner_padding card_right_boundary = base.width - self.CARD_MARGIN - inner_padding card_width = max(0, card_right_boundary - card_left_boundary) max_width = min(max(160, min(int(base.width * 0.35), 320)), card_width) if base.width - max_width < 60 or base.height < 120 or max_width < 80: return img content_top = self.CARD_MARGIN + self.TITLE_BAR_HEIGHT + inner_padding content_bottom = base.height - self.CARD_MARGIN - inner_padding available_height = max(0, content_bottom - content_top) if available_height <= 0: return img font_size = max(14, self.FONT_SIZE + 4) font = self._get_note_font(font_size, bold=True) padding = 18 inner_width = max_width - padding * 2 lines = self._wrap_note_text(text, font, inner_width) max_lines = 5 if len(lines) > max_lines: lines = lines[:max_lines] lines[-1] = lines[-1][: max(0, len(lines[-1]) - 1)] + "…" try: ascent, descent = font.getmetrics() measured_height = ascent + descent except Exception: measured_height = font_size line_height = max(24, int(measured_height * 1.2)) min_note_height = padding * 2 + line_height if available_height < min_note_height: return img space_for_lines = max(0, available_height - padding * 2) max_lines_fit = min(len(lines), max(1, space_for_lines // line_height)) if len(lines) > max_lines_fit: lines = lines[:max_lines_fit] lines[-1] = lines[-1][: max(0, len(lines[-1]) - 1)] + "…" height = padding * 2 + line_height * len(lines) overlay = Image.new('RGBA', (max_width, height), (255, 244, 148, 235)) draw = ImageDraw.Draw(overlay) try: draw.rounded_rectangle( [(0, 0), (max_width - 1, height - 1)], radius=12, fill=(255, 244, 148, 235), outline=(230, 207, 102, 255), width=2, ) except Exception: draw.rectangle( [(0, 0), (max_width - 1, height - 1)], fill=(255, 244, 148, 235), outline=(230, 207, 102, 255), width=2, ) text_color = (80, 64, 16, 255) for idx, line in enumerate(lines): y = padding + idx * line_height draw.text((padding, y), line, fill=text_color, font=font) # שמור את הפתקית בתוך מעטפת ה"shell" card_left = card_left_boundary card_right = card_right_boundary x = max(card_left, card_right - max_width) y = content_top if y + height > content_bottom: y = max(content_top, content_bottom - height) try: base.paste(overlay, (x, y), overlay) except Exception: overlay_rgb = overlay.convert('RGB') base.paste(overlay_rgb, (x, y)) return base.convert('RGB') def _wrap_note_text(self, text: str, font: FreeTypeFont, max_width: int) -> List[str]: lines: List[str] = [] for raw_line in text.splitlines(): line = raw_line.strip() if not line: if lines and lines[-1]: lines.append("") continue words = line.split() current = "" for word in words: candidate = f"{current} {word}".strip() if not candidate: continue width = self._measure_text_width(candidate, font) if width <= max_width: current = candidate else: if current: lines.append(current) current = word if current: lines.append(current) if not lines: return [text[:32]] return lines def _measure_text_width(self, text: str, font: FreeTypeFont) -> int: try: bbox = font.getbbox(text) # type: ignore[attr-defined] return max(0, bbox[2] - bbox[0]) except Exception: return len(text) * (font.size // 2 + 4)