Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions modern_graphics/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)."""
Expand Down Expand Up @@ -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"""<!DOCTYPE html>
<html lang="en">
<head>
Expand All @@ -130,6 +137,7 @@ def _wrap_html(self, content: str, styles: str) -> str:
</head>
<body>
{content}
{pretext_scripts}
</body>
</html>"""

Expand Down
1 change: 1 addition & 0 deletions modern_graphics/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
2 changes: 2 additions & 0 deletions modern_graphics/cli_create.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion modern_graphics/color_scheme.py
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down
39 changes: 37 additions & 2 deletions modern_graphics/diagrams/modern_hero.py
Original file line number Diff line number Diff line change
Expand Up @@ -378,13 +378,48 @@ def generate_modern_hero(
stats_html = _render_stats(stats)
cta_html = f"<div class='cta'>{cta}</div>" 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'<div class="headline">{headline}</div>'
subhead_html = f"<div class='subhead'>{subheadline}</div>" if subheadline else ""

html = f"""
<div class="hero {hero_classes}">
<div class="halo"></div>
<div class="hero-header">
{f"<div class='eyebrow'>{eyebrow}</div>" if eyebrow else ''}
<div class="headline">{headline}</div>
{f"<div class='subhead'>{subheadline}</div>" if subheadline else ''}
{headline_html}
{subhead_html}
</div>
<div class="hero-body">
{freeform_html}
Expand Down
10 changes: 10 additions & 0 deletions modern_graphics/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 7 additions & 1 deletion modern_graphics/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
},
Expand Down Expand Up @@ -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}",
Expand All @@ -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:
Expand Down
113 changes: 113 additions & 0 deletions modern_graphics/pretext_renderer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
"""Python-side helpers for emitting Pretext-compatible markup.

Layout functions use these helpers to emit ``<div class="pretext-slot">``
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'<div class="{classes}"'
f' data-pt-text="{escape(text, quote=True)}"'
f' data-pt-font="{escape(font, quote=True)}"'
f' data-pt-max-width="{max_width}"'
f' data-pt-line-height="{line_height}"'
f' data-pt-fill="{escape(fill, quote=True)}"'
f' data-pt-text-anchor="{text_anchor}"'
f'>{escaped_text}</div>'
)


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'<tspan x="{x_pos}" y="{font_size}">{escaped}</tspan>'
)
else:
tspans.append(
f'<tspan x="{x_pos}" dy="{line_height}">{escaped}</tspan>'
)

tspan_block = "\n ".join(tspans)
return (
f'<svg width="{width}" height="{total_height}"'
f' viewBox="0 0 {width} {total_height}"'
f' style="display:block;overflow:visible">\n'
f' <text font-family="{escape(font_family, quote=True)}"'
f' font-size="{font_size}" fill="{fill}"'
f' text-anchor="{text_anchor}">\n'
f' {tspan_block}\n'
f' </text>\n'
f'</svg>'
)
113 changes: 113 additions & 0 deletions modern_graphics/pretext_utils.py
Original file line number Diff line number Diff line change
@@ -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 <text> 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 <script> tag loading Pretext from CDN."""
return f'<script type="module" src="{PRETEXT_CDN_URL}"></script>'


def generate_pretext_bootstrap_script() -> str:
"""Return <script> that measures .pretext-slot elements and injects SVG text.

Runs on DOMContentLoaded. For each element with class ``pretext-slot``,
reads data attributes for font, max-width, and line-height, then replaces
the element's innerHTML with an inline SVG containing positioned <text>
and <tspan> elements.

Sets ``window.__pretextReady = true`` when all slots are processed so the
Playwright export pipeline can wait before taking a screenshot.
"""
return """
<script type="module">
import { prepareWithSegments, layoutWithLines } from '""" + PRETEXT_CDN_URL + """';

function processSlots() {
const slots = document.querySelectorAll('.pretext-slot');
if (!slots.length) {
window.__pretextReady = true;
return;
}

for (const slot of slots) {
const text = slot.getAttribute('data-pt-text') || slot.textContent.trim();
const font = slot.getAttribute('data-pt-font') || '16px Inter';
const maxWidth = parseFloat(slot.getAttribute('data-pt-max-width') || slot.offsetWidth || 900);
const lineHeight = parseFloat(slot.getAttribute('data-pt-line-height') || '1.15');
const fill = slot.getAttribute('data-pt-fill') || 'currentColor';
const textAnchor = slot.getAttribute('data-pt-text-anchor') || 'start';

// Parse font size from the font string (e.g. "64px 'Press Start 2P'" -> 64)
const fontSizeMatch = font.match(/(\\d+(?:\\.\\d+)?)px/);
const fontSize = fontSizeMatch ? parseFloat(fontSizeMatch[1]) : 16;

// Compute absolute line height
const absLineHeight = lineHeight < 4 ? fontSize * lineHeight : lineHeight;

try {
const prepared = prepareWithSegments(text, font);
const result = layoutWithLines(prepared, maxWidth, absLineHeight);

// Build SVG
const svgNS = 'http://www.w3.org/2000/svg';
const totalHeight = result.height || (result.lineCount * absLineHeight);
const svg = document.createElementNS(svgNS, 'svg');
svg.setAttribute('width', String(maxWidth));
svg.setAttribute('height', String(totalHeight));
svg.setAttribute('viewBox', `0 0 ${maxWidth} ${totalHeight}`);
svg.style.display = 'block';
svg.style.overflow = 'visible';

const textEl = document.createElementNS(svgNS, 'text');
textEl.setAttribute('font-family', font.replace(/^[\\d.]+px\\s*/, ''));
textEl.setAttribute('font-size', String(fontSize));
textEl.setAttribute('fill', fill);
textEl.setAttribute('text-anchor', textAnchor);

const xPos = textAnchor === 'middle' ? maxWidth / 2
: textAnchor === 'end' ? maxWidth
: 0;

for (let i = 0; i < result.lines.length; i++) {
const line = result.lines[i];
const tspan = document.createElementNS(svgNS, 'tspan');
tspan.setAttribute('x', String(xPos));
// First line uses dominant-baseline offset, subsequent use dy
if (i === 0) {
tspan.setAttribute('y', String(fontSize));
} else {
tspan.setAttribute('dy', String(absLineHeight));
}
tspan.textContent = line.text;
textEl.appendChild(tspan);
}

svg.appendChild(textEl);

// Replace slot content but preserve the container element and its classes
slot.innerHTML = '';
slot.appendChild(svg);
slot.setAttribute('data-pt-rendered', 'true');
} catch (err) {
// Fallback: leave original text content in place
console.warn('Pretext rendering failed for slot:', err);
slot.setAttribute('data-pt-error', err.message);
}
}

window.__pretextReady = true;
}

if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', processSlots);
} else {
processSlots();
}
</script>
"""
Loading
Loading