From 998b7e1ddb9024ee816bcb0bdde01ade4e6b55cb Mon Sep 17 00:00:00 2001 From: Greg Meyer Date: Mon, 30 Mar 2026 10:21:39 -0700 Subject: [PATCH] feat: add Pretext integration for pixel-perfect SVG text rendering Integrates @chenglou/pretext as an optional text rendering engine. When enabled, headline and subheadline text is measured with Pretext's layout calculations and rendered as SVG elements instead of CSS-styled HTML divs, giving pixel-perfect control over line breaking and text positioning. - New pretext_utils.py with CDN loader and bootstrap script - New pretext_renderer.py with slot markup and SVG text helpers - BaseGenerator gains use_pretext flag, injects bootstrap in _wrap_html() - Export pipeline waits for __pretextReady before screenshot - Hero layout pilot: headline + subheadline use pretext-slot when enabled - Theme integration via fill:currentColor inheriting from parent CSS - MCP tool gains text_render param ("css" | "pretext"), default "css" - CLI gains --text-render flag on create command Co-Authored-By: Claude Opus 4.6 (1M context) --- modern_graphics/base.py | 12 ++- modern_graphics/cli.py | 1 + modern_graphics/cli_create.py | 2 + modern_graphics/color_scheme.py | 5 +- modern_graphics/diagrams/modern_hero.py | 39 +++++++- modern_graphics/export.py | 10 +++ modern_graphics/mcp_server.py | 8 +- modern_graphics/pretext_renderer.py | 113 ++++++++++++++++++++++++ modern_graphics/pretext_utils.py | 113 ++++++++++++++++++++++++ modern_graphics/rendering.py | 4 +- 10 files changed, 300 insertions(+), 7 deletions(-) create mode 100644 modern_graphics/pretext_renderer.py create mode 100644 modern_graphics/pretext_utils.py diff --git a/modern_graphics/base.py b/modern_graphics/base.py index dc30f76..bba5dc7 100644 --- a/modern_graphics/base.py +++ b/modern_graphics/base.py @@ -13,11 +13,12 @@ class BaseGenerator: """Base class for Modern Graphics generators""" - def __init__(self, title: str, template: Optional[StyleTemplate] = None, attribution: Optional[Attribution] = None, use_svg_js: bool = False): + def __init__(self, title: str, template: Optional[StyleTemplate] = None, attribution: Optional[Attribution] = None, use_svg_js: bool = False, use_pretext: bool = False): self.title = title self.template = template or DEFAULT_TEMPLATE self.attribution = attribution or Attribution() self.use_svg_js = use_svg_js + self.use_pretext = use_pretext def _generate_attribution_html(self) -> str: """Generate attribution label HTML (pill style, optional context).""" @@ -114,7 +115,13 @@ def _wrap_html(self, content: str, styles: str) -> str: if self.use_svg_js: from .svg_utils import generate_svg_js_cdn_script svg_js_script = generate_svg_js_cdn_script() - + + # Include Pretext for SVG text rendering if enabled + pretext_scripts = "" + if self.use_pretext: + from .pretext_utils import generate_pretext_bootstrap_script + pretext_scripts = generate_pretext_bootstrap_script() + return f""" @@ -130,6 +137,7 @@ def _wrap_html(self, content: str, styles: str) -> str: {content} +{pretext_scripts} """ diff --git a/modern_graphics/cli.py b/modern_graphics/cli.py index 376ba16..cbdafb2 100644 --- a/modern_graphics/cli.py +++ b/modern_graphics/cli.py @@ -141,6 +141,7 @@ def main(): create_expert.add_argument('--export-preset', choices=list_export_presets(), help='Channel preset for PNG export (linkedin|x|substack-hero)') create_expert.add_argument('--crop-mode', choices=['none', 'safe', 'tight'], default=CREATE_DEFAULTS.crop_mode, help=f'PNG crop mode (default: {CREATE_DEFAULTS.crop_mode})') create_expert.add_argument('--padding-mode', choices=['none', 'minimal', 'comfortable'], default=CREATE_DEFAULTS.padding_mode, help=f'PNG padding mode (default: {CREATE_DEFAULTS.padding_mode})') + create_expert.add_argument('--text-render', choices=['css', 'pretext'], default='css', help='Text rendering mode: css (default) or pretext (SVG text via Pretext)') # Themes browser subparsers.add_parser('themes', help='List available color themes with descriptions') diff --git a/modern_graphics/cli_create.py b/modern_graphics/cli_create.py index 7b791f1..575bfa2 100644 --- a/modern_graphics/cli_create.py +++ b/modern_graphics/cli_create.py @@ -298,9 +298,11 @@ def handle_create(args) -> int: if getattr(args, 'png', False) and output_path.suffix != '.png': output_path = output_path.with_suffix('.png') + use_pretext = getattr(args, 'text_render', 'css') == 'pretext' generator = ModernGraphicsGenerator( getattr(args, 'title', 'Modern Graphic'), attribution=attribution, + use_pretext=use_pretext, ) density = normalize_density(getattr(args, "density", "clarity")) color_scheme = get_scheme(getattr(args, 'theme', None)) if getattr(args, 'theme', None) else None diff --git a/modern_graphics/color_scheme.py b/modern_graphics/color_scheme.py index 4008645..413ec88 100644 --- a/modern_graphics/color_scheme.py +++ b/modern_graphics/color_scheme.py @@ -185,7 +185,10 @@ def get_css_overrides(self) -> str: """Generate CSS overrides for all graphics types""" css = f""" /* Color Scheme: {self.name} */ - + + /* Pretext SVG text: inherit color from parent CSS */ + .pretext-slot text, .pretext-slot tspan {{ fill: currentColor; }} + /* Base styles */ body {{ font-family: {self.font_family}; diff --git a/modern_graphics/diagrams/modern_hero.py b/modern_graphics/diagrams/modern_hero.py index 85083ae..b9ea038 100644 --- a/modern_graphics/diagrams/modern_hero.py +++ b/modern_graphics/diagrams/modern_hero.py @@ -378,13 +378,48 @@ def generate_modern_hero( stats_html = _render_stats(stats) cta_html = f"
{cta}
" if cta else "" insight_callout_html = _render_insight_callout(insight_callout) if insight_callout and insight_callout.get("text") else "" + # Determine headline/subhead rendering: pretext-slot or plain div + use_pretext = getattr(generator, 'use_pretext', False) + if use_pretext: + from ..pretext_renderer import pretext_slot + # Resolve font from color_scheme or defaults + _display_font = "'SF Pro Display', -apple-system, BlinkMacSystemFont, sans-serif" + _body_font = _display_font + if color_scheme is not None: + _display_font = getattr(color_scheme, 'font_family_display', None) or getattr(color_scheme, 'font_family', _display_font) + _body_font = getattr(color_scheme, 'font_family_body', None) or getattr(color_scheme, 'font_family', _body_font) + _text_anchor = {"left": "start", "center": "middle", "right": "end"}.get(headline_align, "start") + headline_html = pretext_slot( + text=headline, + font=f"{headline_size}px {_display_font}", + max_width=900, + line_height=1.15, + css_class="headline", + text_anchor=_text_anchor, + ) + if subheadline: + _sub_anchor = {"left": "start", "center": "middle", "right": "end"}.get(subheadline_align or headline_align, "start") + subhead_html = pretext_slot( + text=subheadline, + font=f"{subhead_size}px {_body_font}", + max_width=720, + line_height=1.4, + css_class="subhead", + text_anchor=_sub_anchor, + ) + else: + subhead_html = "" + else: + headline_html = f'
{headline}
' + subhead_html = f"
{subheadline}
" if subheadline else "" + html = f"""
{f"
{eyebrow}
" if eyebrow else ''} -
{headline}
- {f"
{subheadline}
" if subheadline else ''} + {headline_html} + {subhead_html}
{freeform_html} diff --git a/modern_graphics/export.py b/modern_graphics/export.py index f48b546..5b39b83 100644 --- a/modern_graphics/export.py +++ b/modern_graphics/export.py @@ -238,6 +238,16 @@ def export_html_to_png( page.goto(f"file://{temp_html_path.resolve()}", wait_until="networkidle") page.wait_for_timeout(1000) + # Wait for Pretext SVG text rendering if slots are present + has_pretext = page.query_selector('.pretext-slot') is not None + if has_pretext: + try: + page.wait_for_function( + "window.__pretextReady === true", timeout=5000 + ) + except Exception: + print("Warning: Pretext rendering did not complete in time, using fallback text.") + page.screenshot( path=str(temp_png_path), full_page=True, diff --git a/modern_graphics/mcp_server.py b/modern_graphics/mcp_server.py index 4bfaef3..eb320e6 100644 --- a/modern_graphics/mcp_server.py +++ b/modern_graphics/mcp_server.py @@ -128,6 +128,11 @@ async def list_tools() -> list[Tool]: "type": "boolean", "description": "If true, PNG background is transparent instead of white. Default: false.", }, + "text_render": { + "type": "string", + "enum": ["css", "pretext"], + "description": "Text rendering mode. 'css' uses standard HTML/CSS (default). 'pretext' uses Pretext for pixel-perfect SVG text.", + }, }, "required": ["layout", "args"], }, @@ -410,6 +415,7 @@ async def call_tool(name: str, arguments: dict) -> list: fmt = arguments.get("format", "html") theme = arguments.get("theme") transparent = arguments.get("transparent", False) + text_render = arguments.get("text_render", "css") default_file_path = os.path.join( SESSION_OUTPUT_ROOT or OUTPUT_DIR, f"{layout}.{fmt}", @@ -425,7 +431,7 @@ async def call_tool(name: str, arguments: dict) -> list: return _error_response(f"Unknown layout '{layout}'. Available: {', '.join(available)}") result = await asyncio.to_thread( - _generate_sync, layout, args, output_path, fmt, theme, transparent + _generate_sync, layout, args, output_path, fmt, theme, transparent, text_render ) if "error" in result: diff --git a/modern_graphics/pretext_renderer.py b/modern_graphics/pretext_renderer.py new file mode 100644 index 0000000..25859ff --- /dev/null +++ b/modern_graphics/pretext_renderer.py @@ -0,0 +1,113 @@ +"""Python-side helpers for emitting Pretext-compatible markup. + +Layout functions use these helpers to emit ``
`` +placeholders. The Pretext bootstrap script (see pretext_utils.py) handles +measurement and SVG injection at render time. +""" + +from html import escape +from typing import Optional + + +def pretext_slot( + text: str, + font: str, + max_width: float, + line_height: float = 1.15, + css_class: str = "", + fill: str = "currentColor", + text_anchor: str = "start", +) -> str: + """Emit a pretext-slot div that the bootstrap script will measure and render. + + Args: + text: The text content to render. + font: CSS font string, e.g. ``"64px 'Press Start 2P'"``. + max_width: Maximum width in pixels for line breaking. + line_height: Line height — values < 4 are treated as a multiplier + of font size; values >= 4 are absolute pixels. + css_class: Additional CSS classes for the container div. + fill: SVG fill color. Defaults to ``currentColor`` to inherit + from the parent CSS ``color`` property. + text_anchor: SVG text-anchor (``start``, ``middle``, ``end``). + + Returns: + HTML string for a pretext-slot element. + """ + classes = f"pretext-slot {css_class}".strip() + escaped_text = escape(text) + return ( + f'
{escaped_text}
' + ) + + +def svg_text_block( + lines: list[str], + font_family: str, + font_size: float, + line_height: float, + fill: str = "currentColor", + text_anchor: str = "start", + max_width: Optional[float] = None, +) -> str: + """Generate a static SVG text block from pre-measured lines. + + Use this when text layout has already been computed (e.g. cached + measurements) and you want to emit SVG directly without the + client-side bootstrap. + + Args: + lines: List of text strings, one per line. + font_family: Font family name (without size). + font_size: Font size in pixels. + line_height: Absolute line height in pixels. + fill: SVG fill color. + text_anchor: SVG text-anchor value. + max_width: SVG viewBox width. Defaults to a generous estimate. + + Returns: + Inline SVG markup string. + """ + if not lines: + return "" + + width = max_width or (len(max(lines, key=len)) * font_size * 0.6) + total_height = len(lines) * line_height + + x_pos = ( + width / 2 if text_anchor == "middle" + else width if text_anchor == "end" + else 0 + ) + + tspans = [] + for i, line_text in enumerate(lines): + escaped = escape(line_text) + if i == 0: + tspans.append( + f'{escaped}' + ) + else: + tspans.append( + f'{escaped}' + ) + + tspan_block = "\n ".join(tspans) + return ( + f'\n' + f' \n' + f' {tspan_block}\n' + f' \n' + f'' + ) diff --git a/modern_graphics/pretext_utils.py b/modern_graphics/pretext_utils.py new file mode 100644 index 0000000..e46b87b --- /dev/null +++ b/modern_graphics/pretext_utils.py @@ -0,0 +1,113 @@ +"""Pretext integration utilities for pixel-perfect SVG text rendering. + +Uses @chenglou/pretext to measure text layout (line breaks, dimensions) +and renders as SVG elements with precise positioning. +""" + +PRETEXT_VERSION = "0.0.3" +PRETEXT_CDN_URL = f"https://cdn.jsdelivr.net/npm/@chenglou/pretext@{PRETEXT_VERSION}/dist/layout.js" + + +def generate_pretext_cdn_script() -> str: + """Return ' + + +def generate_pretext_bootstrap_script() -> str: + """Return + """ diff --git a/modern_graphics/rendering.py b/modern_graphics/rendering.py index 76ce1af..896854b 100644 --- a/modern_graphics/rendering.py +++ b/modern_graphics/rendering.py @@ -53,6 +53,7 @@ def generate_sync( fmt: str = "html", theme: str | None = None, transparent: bool = False, + text_render: str = "css", ) -> Dict[str, Any]: """Synchronous rendering — generates HTML or PNG.""" from .generator import ModernGraphicsGenerator @@ -63,9 +64,10 @@ def generate_sync( out = Path(output_path) out.parent.mkdir(parents=True, exist_ok=True) + use_pretext = text_render == "pretext" attribution = Attribution() title = args.pop("title", "") or "" - generator = ModernGraphicsGenerator(title, attribution=attribution) + generator = ModernGraphicsGenerator(title, attribution=attribution, use_pretext=use_pretext) color_scheme = get_scheme(theme) if theme else None render_args = dict(args)