"""
שירות ליצירת תמונות קוד עם היילייטינג (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 _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)