diff --git a/.gitignore b/.gitignore index 4fa7d52..900f0e6 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,9 @@ env/ ENV/ .venv +# Local planning notes (not shipped with the repo) +docs/plans/ + # IDE / local config .vscode/ .idea/ diff --git a/Makefile b/Makefile index c1ed769..d1223db 100644 --- a/Makefile +++ b/Makefile @@ -33,7 +33,7 @@ shell: $(DOCKER_RUN) -it -v $(OUTPUT_DIR):/app/output -w /app --entrypoint /bin/bash $(IMAGE) test: - $(DOCKER_RUN) -v $(PWD):/app -w /app --entrypoint /bin/bash $(IMAGE) -c "pip install pytest -q && pytest -q tests/smoke/test_overhaul_phase1_smoke.py tests/smoke/test_layout_strategy_smoke.py tests/smoke/test_create_cli_phase3_smoke.py tests/smoke/test_create_story_regression_phase7_smoke.py tests/smoke/test_export_phase4_smoke.py tests/smoke/test_cli_migration_phase5_smoke.py tests/smoke/test_export_presets_phase6_smoke.py" + $(DOCKER_RUN) -v $(PWD):/app -w /app --entrypoint /bin/bash $(IMAGE) -c "pip install pytest -q && pytest -q tests/smoke/test_overhaul_phase1_smoke.py tests/smoke/test_layout_strategy_smoke.py tests/smoke/test_create_cli_phase3_smoke.py tests/smoke/test_create_story_regression_phase7_smoke.py tests/smoke/test_story_tile_pretext_smoke.py tests/smoke/test_export_phase4_smoke.py tests/smoke/test_cli_migration_phase5_smoke.py tests/smoke/test_export_presets_phase6_smoke.py" mcp: @mkdir -p $(OUTPUT_DIR) diff --git a/README.md b/README.md index 5fa6bed..a1dd5ee 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,10 @@ Full docs map: [`docs/README.md`](docs/README.md) - Contribution guide: [`docs/CONTRIBUTING.md`](docs/CONTRIBUTING.md) - Historical plans and notes: [`docs/archive/`](docs/archive/) +## Acknowledgments + +- [@chenglou/pretext](https://github.com/chenglou/pretext) — pixel-perfect text measurement and layout by [Cheng Lou](https://github.com/chenglou), used for optional SVG text rendering (`--text-render pretext`) + ## License MIT License. diff --git a/docs/SVG_COMPOSITION.md b/docs/SVG_COMPOSITION.md index 27debcf..b6ffc6a 100644 --- a/docs/SVG_COMPOSITION.md +++ b/docs/SVG_COMPOSITION.md @@ -121,6 +121,16 @@ generator = ModernGraphicsGenerator("My Diagram") html = generator.generate_diagram("my_diagram", title="Items", items=["A", "B", "C"]) ``` +## Pretext, wireframes, and SVG `foreignObject` + +When `ModernGraphicsGenerator(..., use_pretext=True)` or CLI `--text-render pretext`, the HTML page includes a bootstrap script that replaces `.pretext-slot` placeholders with measured SVG text (`modern_graphics/pretext_utils.py`, `modern_graphics/pretext_renderer.py`). + +- **Open hero + `freeform_canvas`:** Pretext runs only where `pretext_slot` is emitted (headline, subhead, insight callout, etc.). Wireframe or diagram SVG passed as `freeform_canvas` is separate markup; Pretext does not process it unless you add your own `.pretext-slot` nodes inside that snippet. Typography and diagram are siblings in the hero body, not nested SVG text. +- **Story slide mini-tile:** Headline and subline can use Pretext inside SVG ``. Those regions use fixed widths and reserved heights so multi-line SVG output is not clipped; if you change copy or font sizes drastically, re-check layout in the mini-tile generator (`modern_graphics/diagrams/story_slide.py`, `_build_static_mini_tile`). +- **Hero clipping:** Open hero CSS uses `overflow: hidden` on `.hero`. Unusually tall header stacks or canvases can clip; adjust padding or hero height if needed. + +Export pipelines wait on `window.__pretextReady` when any `.pretext-slot` is present (`modern_graphics/export.py`). + ## Deep Dive For wireframe scene schema details, see [Wireframe Scene Spec](WIREFRAME_SCENE_SPEC.md). diff --git a/examples/pretext_mini_tile_refinement_demo.py b/examples/pretext_mini_tile_refinement_demo.py new file mode 100644 index 0000000..62aa3d6 --- /dev/null +++ b/examples/pretext_mini_tile_refinement_demo.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +"""Generate side-by-side HTML: legacy vs refined story mini-tile Pretext foreignObject bands. + + Open the output file in a browser (network needed for Pretext CDN and fonts). + + Run from repo root: + + python3 examples/pretext_mini_tile_refinement_demo.py + + Or with PYTHONPATH if needed: + + PYTHONPATH=. python3 examples/pretext_mini_tile_refinement_demo.py +""" + +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from modern_graphics import ModernGraphicsGenerator, Attribution +from modern_graphics.diagrams.story_slide import _build_static_mini_tile + +OUT = REPO_ROOT / "output" / "pretext-mini-tile-refinement-demo.html" + +_PALETTE = { + "background": "#FFFFFF", + "border": "#E5E5E5", + "text_primary": "#111111", + "text_secondary": "#555555", + "metric_bg": "#F0F4F8", + "metric_text": "#111111", + "accent_primary": "#0071E3", +} + +_DATA = { + "tile_headline": "Revenue acceleration outpaced linear forecast", + "subline": "Pipeline quality improved as cycle times compressed across regions.", + "pill": "FY 2025", + "metric": "Decision quality drives outcomes", + "chart": [72, 98, 68, 105], +} + + +def main() -> None: + gen = ModernGraphicsGenerator( + "Pretext mini-tile refinement demo", + Attribution(copyright="© Demo", show=False), + use_pretext=True, + ) + left = _build_static_mini_tile( + 640, + 400, + _PALETTE, + _DATA, + use_pretext=True, + pretext_foreign_object_layout="legacy", + ) + right = _build_static_mini_tile( + 640, + 400, + _PALETTE, + _DATA, + use_pretext=True, + pretext_foreign_object_layout="refined", + ) + body = f""" +
+

Mini-tile Pretext: legacy vs refined foreignObject

+

Same headline and subline. Left: pre-refinement band heights (44px / 36px) with Pretext—multi-line SVG text is clipped. Right: reserved bands (88px / 52px) with the chart and pill shifted down.

+
+
+
Legacy bands (clips)
+
{left}
+
+
+
Refined (current default)
+
{right}
+
+
+
+ """ + css = """ + * { box-sizing: border-box; } + body { font-family: system-ui, sans-serif; margin: 0; padding: 32px 48px; + background: #f4f4f5; color: #18181b; } + .page { max-width: 1400px; margin: 0 auto; } + h1 { font-size: 1.35rem; font-weight: 650; margin: 0 0 12px; } + .lede { max-width: 72ch; line-height: 1.5; color: #52525b; margin: 0 0 28px; } + .row { display: flex; flex-wrap: wrap; gap: 32px; align-items: flex-start; } + figure { margin: 0; flex: 1 1 420px; background: #fff; padding: 20px; + border-radius: 16px; border: 1px solid #e4e4e7; } + figcaption { font-size: 0.8rem; font-weight: 600; text-transform: uppercase; + letter-spacing: 0.06em; color: #71717a; margin-bottom: 12px; } + .tile-wrap { max-width: 100%; } + .tile-wrap svg { width: 100%; height: auto; display: block; } + code { font-size: 0.9em; } + """ + html = gen._wrap_html(body, css) + OUT.parent.mkdir(parents=True, exist_ok=True) + OUT.write_text(html, encoding="utf-8") + print(f"Wrote {OUT}") + print("Open in a browser (network for Pretext CDN + Google Fonts).") + + +if __name__ == "__main__": + main() diff --git a/modern_graphics/diagrams/comparison.py b/modern_graphics/diagrams/comparison.py index 39b02b5..1a4a338 100644 --- a/modern_graphics/diagrams/comparison.py +++ b/modern_graphics/diagrams/comparison.py @@ -37,6 +37,15 @@ def generate_comparison_diagram( step_size = max(tokens.typography.caption, 13) outcome_size = max(tokens.typography.body, 17) vs_size = max(tokens.typography.caption, 13) + use_pretext = getattr(generator, "use_pretext", False) + display_font = "'SF Pro Display', -apple-system, BlinkMacSystemFont, sans-serif" + if color_scheme is not None: + display_font = ( + getattr(color_scheme, "font_family_display", None) + or getattr(color_scheme, "font_family", display_font) + ) + if use_pretext: + from ..pretext_renderer import pretext_slot section_gap = tokens.spacing.xl card_padding = tokens.spacing.lg + tokens.spacing.xs step_gap = tokens.spacing.sm @@ -47,11 +56,21 @@ def generate_column_html(column_data: Dict, column_class: str, is_positive: bool title = column_data['title'] steps = column_data['steps'] icon = "✕" if not is_positive else "✓" + if use_pretext: + title_html = pretext_slot( + text=title, + font=f"{column_title_size}px {display_font}", + max_width=350, + line_height=1.2, + css_class="column-title", + ) + else: + title_html = f'
{title}
' html = f'''
{icon}
-
{title}
+ {title_html}
''' @@ -265,7 +284,18 @@ def generate_column_html(column_data: Dict, column_class: str, is_positive: bool {generate_column_html(left_column, left_class, False)}
-
{vs_text}
+ {"".join([ + '
', + pretext_slot( + text=vs_text, + font=f"{vs_size}px {display_font}", + max_width=36, + line_height=1.0, + css_class="vs-label", + text_anchor="middle", + ) if use_pretext else vs_text, + "
", + ])}
{generate_column_html(right_column, right_class, True)} diff --git a/modern_graphics/diagrams/funnel.py b/modern_graphics/diagrams/funnel.py index b25cc65..52f4776 100644 --- a/modern_graphics/diagrams/funnel.py +++ b/modern_graphics/diagrams/funnel.py @@ -57,6 +57,15 @@ def generate_funnel_diagram( ] theme = extract_theme_colors(color_scheme) + use_pretext = getattr(generator, "use_pretext", False) + display_font = "'Inter', -apple-system, BlinkMacSystemFont, sans-serif" + if color_scheme is not None: + display_font = ( + getattr(color_scheme, "font_family_display", None) + or getattr(color_scheme, "font_family", display_font) + ) + if use_pretext: + from ..pretext_renderer import pretext_slot max_value = max(stage.get("value", 0) for stage in stages) or 1 min_width = 45 max_width = 92 @@ -81,7 +90,27 @@ def generate_funnel_diagram( """) conversion = (stages[-1].get("value", 0) / stages[0].get("value", 1)) * 100 + conversion_text = f"{conversion:.1f}%" template = _get_template(generator) + if use_pretext: + title_html = pretext_slot( + text=generator.title, + font=f"28px {display_font}", + max_width=520, + line_height=1.2, + css_class="funnel-title", + ) + metric_value_html = pretext_slot( + text=conversion_text, + font=f"26px {display_font}", + max_width=180, + line_height=1.2, + css_class="value metric-value", + text_anchor="end", + ) + else: + title_html = f'
{generator.title}
' + metric_value_html = f'
{conversion_text}
' css_content = f""" {generate_css_variables(theme)} @@ -182,10 +211,10 @@ def generate_funnel_diagram( html_content = f"""
-
{generator.title}
+ {title_html}
Overall Conversion
-
{conversion:.1f}%
+ {metric_value_html}
diff --git a/modern_graphics/diagrams/grid.py b/modern_graphics/diagrams/grid.py index 9ef55f9..16f7ca2 100644 --- a/modern_graphics/diagrams/grid.py +++ b/modern_graphics/diagrams/grid.py @@ -29,24 +29,49 @@ def generate_grid_diagram( HTML string """ theme = extract_theme_colors(color_scheme) + use_pretext = getattr(generator, "use_pretext", False) + display_font = "'Inter', -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) + ) + if use_pretext: + from ..pretext_renderer import pretext_slot items_html = [] for item in items: number = item.get("number") text = item["text"] + if use_pretext: + item_text_html = pretext_slot( + text=text, + font=f"16px {body_font}", + max_width=130, + line_height=1.5, + css_class="md-subtitle1", + text_anchor="middle", + ) + else: + item_text_html = f'
{text}
' if number: items_html.append(f"""
-
{text}
+ {item_text_html}
""") else: items_html.append(f"""
-
{text}
+ {item_text_html}
""") @@ -212,11 +237,22 @@ def generate_grid_diagram( {ATTRIBUTION_STYLES} """ + if use_pretext: + headline_html = pretext_slot( + text=generator.title, + font=f"24px {display_font}", + max_width=860, + line_height=1.2, + css_class="md-headline", + text_anchor="middle", + ) + else: + headline_html = f'
{generator.title}
' html_content = f"""
-
{generator.title}
+ {headline_html}
{''.join(items_html)}
{convergence_html} diff --git a/modern_graphics/diagrams/insight.py b/modern_graphics/diagrams/insight.py index 3b75923..2be63f6 100644 --- a/modern_graphics/diagrams/insight.py +++ b/modern_graphics/diagrams/insight.py @@ -76,6 +76,7 @@ def generate_insight_story( section_gap = tokens.spacing.xl + tokens.spacing.xs panel_radius = tokens.radius["lg"] panel_pad = tokens.spacing.lg + use_pretext = getattr(generator, "use_pretext", False) if color_scheme: accent_color = color_scheme.primary success_color = color_scheme.success or success_color @@ -87,6 +88,8 @@ def generate_insight_story( font_family = "'Inter', -apple-system, BlinkMacSystemFont, sans-serif" font_family_display = font_family font_family_body = font_family + if use_pretext: + from ..pretext_renderer import pretext_slot # Build shift indicator HTML shift_html = "" @@ -157,13 +160,23 @@ def generate_insight_story( # Build key insight insight_html = "" if insight_text: + if use_pretext: + insight_text_html = pretext_slot( + text=insight_text, + font=f"{max(tokens.typography.body_l + 2, 22)}px {font_family_body}", + max_width=980, + line_height=1.5, + css_class="insight-text", + ) + else: + insight_text_html = f'

{insight_text}

' insight_html = f"""
{escape(insight_label)}
-

{insight_text}

+ {insight_text_html}
""" @@ -185,14 +198,37 @@ def generate_insight_story(
""" + if use_pretext: + headline_html = pretext_slot( + text=headline, + font=f"{max(tokens.typography.h1, 42)}px {font_family_display}", + max_width=600, + line_height=1.1, + css_class="insight-headline", + ) + subtitle_html = ( + pretext_slot( + text=subtitle, + font=f"{max(tokens.typography.body_l, 20)}px {font_family_body}", + max_width=600, + line_height=1.5, + css_class="subtitle", + ) + if subtitle + else "" + ) + else: + headline_html = f"

{escape(headline)}

" + subtitle_html = f'

{escape(subtitle)}

' if subtitle else "" + html_content = f"""
{f'
{escape(eyebrow)}
' if eyebrow else ''} -

{escape(headline)}

- {f'

{escape(subtitle)}

' if subtitle else ''} + {headline_html} + {subtitle_html}
{shift_html}
@@ -286,7 +322,7 @@ def generate_insight_story( border-radius: 1px; }} - h1 {{ + h1, .insight-headline {{ font-family: var(--font-display); font-size: {max(tokens.typography.h1, 42)}px; font-weight: 700; diff --git a/modern_graphics/diagrams/modern_hero.py b/modern_graphics/diagrams/modern_hero.py index b9ea038..6d313e8 100644 --- a/modern_graphics/diagrams/modern_hero.py +++ b/modern_graphics/diagrams/modern_hero.py @@ -13,6 +13,37 @@ from ..color_scheme import ColorScheme +CANVAS_WIDTH = 1200 +CANVAS_HEIGHT = 360 + +# Open hero: Pretext measure widths stay aligned with CSS max-widths. +HERO_OPEN_HEADLINE_MAX_WIDTH_PX = 900 +HERO_OPEN_SUBHEAD_MAX_WIDTH_PX = 720 +HERO_OPEN_EYEBROW_MAX_WIDTH_PX = HERO_OPEN_HEADLINE_MAX_WIDTH_PX +HERO_OPEN_INSIGHT_CALLOUT_BOX_MAX_WIDTH_PX = HERO_OPEN_SUBHEAD_MAX_WIDTH_PX +HERO_OPEN_INSIGHT_CALLOUT_TEXT_MAX_WIDTH_PX = 608 + +HERO_TRIPTYCH_HEADLINE_MAX_WIDTH_PX = 1200 +HERO_TRIPTYCH_SUBHEAD_MAX_WIDTH_PX = 780 +HERO_TRIPTYCH_PANEL_TITLE_MAX_WIDTH_PX = 360 + + +def _hero_open_font_stacks(color_scheme: Optional["ColorScheme"]) -> tuple[str, str]: + """Display and body font stacks for open-hero Pretext/Css parity.""" + display = "'SF Pro Display', -apple-system, BlinkMacSystemFont, sans-serif" + body = display + if color_scheme is not None: + display = ( + getattr(color_scheme, "font_family_display", None) + or getattr(color_scheme, "font_family", display) + ) + body = ( + getattr(color_scheme, "font_family_body", None) + or getattr(color_scheme, "font_family", body) + ) + return display, body + + def _render_svg_icon(kind: Optional[str], size: int = 64) -> str: """Return a lightweight SVG icon for hero panels.""" if kind == "manual": @@ -38,19 +69,54 @@ def _render_list(items: Optional[List[str]]) -> str: _QUOTE_ICON_SVG = '' -def _render_insight_callout(callout: Optional[Dict[str, str]]) -> str: +def _render_insight_callout( + callout: Optional[Dict[str, str]], + *, + use_pretext: bool = False, + body_font: str, + label_font_px: int = 11, + quote_font_px: int = 22, + text_max_width: float = HERO_OPEN_INSIGHT_CALLOUT_TEXT_MAX_WIDTH_PX, +) -> str: """Render an insight-card-style quote callout (same visual language as key insight card).""" if not callout or not callout.get("text"): return "" text = callout.get("text", "") label = callout.get("label", "") - label_html = f'
{escape(label)}
' if label else "" + if use_pretext: + from ..pretext_renderer import pretext_slot + + label_html = ( + pretext_slot( + text=label, + font=f"{label_font_px}px {body_font}", + max_width=text_max_width, + line_height=1.2, + css_class="insight-callout-label", + text_anchor="start", + ) + if label + else "" + ) + text_html = pretext_slot( + text=text, + font=f"italic {quote_font_px}px {body_font}", + max_width=text_max_width, + line_height=1.45, + css_class="insight-callout-text", + text_anchor="start", + ) + else: + label_html = ( + f'
{escape(label)}
' if label else "" + ) + text_html = f'

{text}

' return f"""
{_QUOTE_ICON_SVG}
{label_html} -

{text}

+ {text_html}
""" @@ -81,10 +147,6 @@ def _render_tile_flow(tiles: Optional[List[Dict[str, str]]]) -> str: return f"
{''.join(cards)}
" -CANVAS_WIDTH = 1200 -CANVAS_HEIGHT = 360 - - def _extract_panel_data( highlights: Optional[List[str]], highlight_tiles: Optional[List[Dict[str, str]]], @@ -377,47 +439,67 @@ 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) + use_pretext = getattr(generator, "use_pretext", False) + _display_font, _body_font = _hero_open_font_stacks(color_scheme) 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") + insight_callout_html = ( + _render_insight_callout( + insight_callout, + use_pretext=use_pretext, + body_font=_body_font, + text_max_width=float(HERO_OPEN_INSIGHT_CALLOUT_TEXT_MAX_WIDTH_PX), + ) + if insight_callout and insight_callout.get("text") + else "" + ) + _text_anchor = {"left": "start", "center": "middle", "right": "end"}.get( + headline_align, "start" + ) + if use_pretext: headline_html = pretext_slot( text=headline, font=f"{headline_size}px {_display_font}", - max_width=900, + max_width=float(HERO_OPEN_HEADLINE_MAX_WIDTH_PX), 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") + _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, + max_width=float(HERO_OPEN_SUBHEAD_MAX_WIDTH_PX), line_height=1.4, css_class="subhead", text_anchor=_sub_anchor, ) else: subhead_html = "" + if eyebrow: + eyebrow_html = pretext_slot( + text=eyebrow, + font=f"{eyebrow_size}px {_body_font}", + max_width=float(HERO_OPEN_EYEBROW_MAX_WIDTH_PX), + line_height=1.2, + css_class="eyebrow", + text_anchor=_text_anchor, + ) + else: + eyebrow_html = "" else: headline_html = f'
{headline}
' subhead_html = f"
{subheadline}
" if subheadline else "" + eyebrow_html = f"
{eyebrow}
" if eyebrow else "" html = f"""
- {f"
{eyebrow}
" if eyebrow else ''} + {eyebrow_html} {headline_html} {subhead_html}
@@ -523,8 +605,10 @@ def generate_modern_hero( .insight-callout { position: relative; max-width: 720px; margin-top: 28px; padding: 28px 32px 28px 80px; background: rgba(255,255,255,0.95); border-radius: 20px; box-shadow: 0 4px 24px rgba(0,0,0,0.06), 0 0 0 1px rgba(0,0,0,0.04); } .hero-dark .insight-callout { background: rgba(255,255,255,0.08); box-shadow: 0 4px 24px rgba(0,0,0,0.2), 0 0 0 1px rgba(255,255,255,0.08); } .hero-warm .insight-callout { background: rgba(255,255,255,0.9); box-shadow: 0 4px 24px rgba(0,0,0,0.06); } - .insight-callout-icon { position: absolute; left: 24px; top: 50%; transform: translateY(-50%); width: 40px; height: 40px; color: #7C3AED; opacity: 0.35; } + .insight-callout-icon { position: absolute; left: 24px; top: 50%; transform: translateY(-50%); width: 40px; height: 40px; color: #7C3AED; opacity: 0.35; z-index: 1; pointer-events: none; } .insight-callout-icon svg { width: 100%; height: 100%; } + .insight-callout-content { position: relative; z-index: 0; min-width: 0; } + .insight-callout-content .pretext-slot { display: block; max-width: 100%; } .hero-dark .insight-callout-icon { color: #a78bfa; opacity: 0.5; } .hero-warm .insight-callout-icon { color: #ea580c; opacity: 0.4; } .insight-callout-label { font-size: 11px; font-weight: 600; letter-spacing: 0.12em; text-transform: uppercase; color: #6b7280; margin-bottom: 8px; } @@ -547,6 +631,20 @@ def generate_modern_hero( .stat strong { display: block; margin-top: 6px; font-size: 24px; font-weight: 600; letter-spacing: -0.015em; } .hero-warm .stat strong { color: #6a2a00; } """ + css = css.replace( + "letter-spacing: -0.025em; max-width: 900px;", + f"letter-spacing: -0.025em; max-width: {HERO_OPEN_HEADLINE_MAX_WIDTH_PX}px;", + 1, + ).replace( + "margin-top: 16px; max-width: 720px; font-size: 24px;", + f"margin-top: 16px; max-width: {HERO_OPEN_SUBHEAD_MAX_WIDTH_PX}px; font-size: 24px;", + 1, + ).replace( + ".insight-callout { position: relative; max-width: 720px;", + f".insight-callout " + "{ position: relative; max-width: " + f"{HERO_OPEN_INSIGHT_CALLOUT_BOX_MAX_WIDTH_PX}px;", + 1, + ) css = css.replace("padding: 80px;", f"padding: {body_pad}px;", 1) css = css.replace("border-radius: 48px;", f"border-radius: {hero_radius}px;", 1) css = css.replace("padding: 72px;", f"padding: {hero_pad}px;", 1) @@ -601,6 +699,10 @@ def generate_modern_hero_triptych( panel_gap = tokens.spacing.lg panel_radius = tokens.radius["xl"] + tokens.spacing.xs stats_gap = tokens.spacing.lg - tokens.spacing.xs + use_pretext = getattr(generator, "use_pretext", False) + display_font, body_font = _hero_open_font_stacks(color_scheme) + if use_pretext: + from ..pretext_renderer import pretext_slot # Normalize alignment values headline_align = headline_align.lower() if headline_align else "left" @@ -615,17 +717,54 @@ def generate_modern_hero_triptych( headline_align_class = f"headline-align-{headline_align}" subheadline_align_class = f"subheadline-align-{subheadline_align}" - column_html = "".join( - f""" + column_html = [] + for col in columns[:3]: + title_text = col.get("title", "") + if use_pretext: + panel_title_html = pretext_slot( + text=title_text, + font=f"20px {display_font}", + max_width=float(HERO_TRIPTYCH_PANEL_TITLE_MAX_WIDTH_PX), + line_height=1.2, + css_class="panel-title", + ) + else: + panel_title_html = f"
{title_text}
" + column_html.append( + f"""
{_render_svg_icon(col.get('icon'))} -
{col.get('title', '')}
+ {panel_title_html} {_render_list(col.get('items'))}
""" - for col in columns[:3] - ) + ) + column_html_markup = "".join(column_html) stats_html = _render_stats(stats) + if use_pretext: + headline_html = pretext_slot( + text=headline, + font=f"{headline_size}px {display_font}", + max_width=float(HERO_TRIPTYCH_HEADLINE_MAX_WIDTH_PX), + line_height=1.15, + css_class="headline", + text_anchor={"left": "start", "center": "middle", "right": "end"}.get(headline_align, "start"), + ) + subhead_html = ( + pretext_slot( + text=subheadline, + font=f"{subhead_size}px {body_font}", + max_width=float(HERO_TRIPTYCH_SUBHEAD_MAX_WIDTH_PX), + line_height=1.35, + css_class="subhead", + text_anchor={"left": "start", "center": "middle", "right": "end"}.get(subheadline_align, "start"), + ) + if subheadline + else "" + ) + 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}
- {column_html} + {column_html_markup}
{stats_html}
@@ -674,6 +813,11 @@ def generate_modern_hero_triptych( .stat span { font-size: 12px; text-transform: uppercase; letter-spacing: 0.2em; color: #9D9FB5; } .stat strong { display: block; margin-top: 6px; font-size: 22px; font-weight: 600; letter-spacing: -0.01em; color: #2C2F3C; } """ + css = css.replace( + "max-width: 780px; font-size: 22px; color: #4B4E5F;", + f"max-width: {HERO_TRIPTYCH_SUBHEAD_MAX_WIDTH_PX}px; font-size: 22px; color: #4B4E5F;", + 1, + ) css = css.replace("padding: 80px;", f"padding: {body_pad}px;", 1) css = css.replace("border-radius: 48px;", f"border-radius: {hero_radius}px;", 1) css = css.replace("padding: 72px;", f"padding: {hero_pad}px;", 1) diff --git a/modern_graphics/diagrams/slide_cards.py b/modern_graphics/diagrams/slide_cards.py index 63747c8..dde48d2 100644 --- a/modern_graphics/diagrams/slide_cards.py +++ b/modern_graphics/diagrams/slide_cards.py @@ -93,6 +93,20 @@ def generate_slide_card_diagram( to embed that wireframe (themed if color_scheme provided). """ theme = extract_theme_colors(color_scheme) + use_pretext = getattr(generator, "use_pretext", False) + display_font = "'Inter', -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) + ) + if use_pretext: + from ..pretext_renderer import pretext_slot cards_html = [] cards_css = [] @@ -145,6 +159,24 @@ def generate_slide_card_diagram( # Generate HTML for this card badge_html = f'
{badge}
' if badge else '' + if use_pretext: + title_html = pretext_slot( + text=title, + font=f"20px {display_font}", + max_width=260, + line_height=1.35, + css_class="card-title", + ) + tagline_html = pretext_slot( + text=tagline, + font=f"16px {body_font}", + max_width=260, + line_height=1.5, + css_class="card-tagline", + ) + else: + title_html = f'
{title}
' + tagline_html = f'
{tagline}
' features_html = '' if features: @@ -160,8 +192,8 @@ def generate_slide_card_diagram( {badge_html}
{svg_mockup}
-
{title}
-
{tagline}
+ {title_html} + {tagline_html}
{subtext}
{features_html}
@@ -171,8 +203,8 @@ def generate_slide_card_diagram( cards_html.append(f"""
{badge_html} -
{title}
-
{tagline}
+ {title_html} + {tagline_html}
{svg_mockup}
{subtext}
{features_html} @@ -419,11 +451,22 @@ def generate_slide_card_diagram( {ATTRIBUTION_STYLES} """ + if use_pretext: + diagram_title_html = pretext_slot( + text=generator.title, + font=f"24px {display_font}", + max_width=1200, + line_height=1.35, + css_class="title", + text_anchor="middle", + ) + else: + diagram_title_html = f'
{generator.title}
' html_content = f"""
-
{generator.title}
+ {diagram_title_html}
{''.join(cards_html)}
@@ -453,6 +496,20 @@ def generate_slide_card_comparison( color_scheme: Optional ColorScheme for theming """ theme = extract_theme_colors(color_scheme) + use_pretext = getattr(generator, "use_pretext", False) + display_font = "'Inter', -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) + ) + if use_pretext: + from ..pretext_renderer import pretext_slot def generate_card_html(card: Dict[str, any], card_class: str) -> str: title = card.get("title", "") @@ -487,11 +544,30 @@ def generate_card_html(card: Dict[str, any], card_class: str) -> str: features_html += f'
{feature}
' features_html += '
' + if use_pretext: + title_html = pretext_slot( + text=title, + font=f"20px {display_font}", + max_width=300, + line_height=1.35, + css_class="card-title", + ) + tagline_html = pretext_slot( + text=tagline, + font=f"16px {body_font}", + max_width=300, + line_height=1.5, + css_class="card-tagline", + ) + else: + title_html = f'
{title}
' + tagline_html = f'
{tagline}
' + return f"""
{badge_html} -
{title}
-
{tagline}
+ {title_html} + {tagline_html}
{svg_mockup}
{features_html}
""" @@ -643,11 +719,22 @@ def generate_card_html(card: Dict[str, any], card_class: str) -> str: {ATTRIBUTION_STYLES} """ + if use_pretext: + comparison_title_html = pretext_slot( + text=generator.title, + font=f"24px {display_font}", + max_width=1200, + line_height=1.35, + css_class="title", + text_anchor="middle", + ) + else: + comparison_title_html = f'
{generator.title}
' html_content = f"""
-
{generator.title}
+ {comparison_title_html}
{generate_card_html(left_card, 'left-card')}
{vs_text}
diff --git a/modern_graphics/diagrams/story_slide.py b/modern_graphics/diagrams/story_slide.py index 1265532..1f19f21 100644 --- a/modern_graphics/diagrams/story_slide.py +++ b/modern_graphics/diagrams/story_slide.py @@ -3,7 +3,7 @@ import html import json import textwrap -from typing import Optional, Dict, List, Tuple, Any +from typing import Optional, Dict, List, Tuple, Any, Literal from ..base import BaseGenerator from ..constants import ATTRIBUTION_STYLES @@ -37,9 +37,19 @@ def _shorten_text(text: Optional[str], width: int) -> str: return textwrap.shorten(text.strip(), width, placeholder="...") -def _build_static_mini_tile(width: int, height: int, palette: Dict[str, str], data: Dict[str, str]) -> str: - safe_headline = html.escape((data.get('tile_headline') or data.get('headline') or '')[:36]) - safe_subline = html.escape((data.get('subline') or '')[:48]) +def _build_static_mini_tile( + width: int, + height: int, + palette: Dict[str, str], + data: Dict[str, str], + use_pretext: bool = False, + *, + pretext_foreign_object_layout: Literal["refined", "legacy"] = "refined", +) -> str: + raw_headline = (data.get("tile_headline") or data.get("headline") or "")[:36] + raw_subline = (data.get("subline") or "")[:48] + safe_headline = html.escape(raw_headline) + safe_subline = html.escape(raw_subline) safe_pill = html.escape((data.get('pill') or '')[:24]) safe_metric = html.escape((data.get('metric') or '')[:48]) @@ -55,12 +65,41 @@ def _build_static_mini_tile(width: int, height: int, palette: Dict[str, str], da pill_rect_width = min(240, chart_width) pill_rect_x = padding_x pill_text_x = pill_rect_x + pill_rect_width / 2 - + + # Pretext uses taller foreignObject bands so multi-line SVG text is not clipped. + _PRETEXT_TITLE_FO_Y = 56 + _PRETEXT_TITLE_FO_H = 88 + _PRETEXT_GAP_TITLE_SUB = 8 + _PRETEXT_SUB_FO_H = 52 + _PRETEXT_GAP_SUB_PILL = 12 + _PRETEXT_GAP_PILL_CHART = 8 + if use_pretext: + if pretext_foreign_object_layout == "legacy": + title_fo_y = 64 + title_fo_h = 44 + sub_fo_y = 112 + sub_fo_h = 36 + pill_rect_y = 164 + chart_top = 220 + else: + title_fo_y = _PRETEXT_TITLE_FO_Y + title_fo_h = _PRETEXT_TITLE_FO_H + sub_fo_y = title_fo_y + title_fo_h + _PRETEXT_GAP_TITLE_SUB + sub_fo_h = _PRETEXT_SUB_FO_H + pill_rect_y = sub_fo_y + sub_fo_h + _PRETEXT_GAP_SUB_PILL + chart_top = pill_rect_y + 36 + _PRETEXT_GAP_PILL_CHART + else: + pill_rect_y = 164 + chart_top = 220 + values = data.get('chart') or [70, 95, 65, 110] - chart_top = 220 chart_bottom_margin = 80 chart_base_y = height - chart_bottom_margin - chart_height = max(120, chart_base_y - chart_top) + span = chart_base_y - chart_top + if use_pretext and pretext_foreign_object_layout == "refined": + chart_height = max(48, span) + else: + chart_height = max(120, span) step = chart_width / max(len(values) - 1, 1) points = [] @@ -118,14 +157,51 @@ def wrap_text(text: str, limit: int = 32) -> list: metric_rect_height = 120 metric_rect_y = max(chart_top, chart_top + (chart_height - metric_rect_height) / 2) metric_text_y = metric_rect_y + (metric_rect_height / 2) - 6 - + + text_area_w = width - (padding_x * 2) + pill_text_y = pill_rect_y + 23 + if use_pretext: + from ..pretext_renderer import pretext_slot + + title_row = f""" + +
+ {pretext_slot( + text=raw_headline, + font="24px Inter, -apple-system, sans-serif", + max_width=float(text_area_w), + line_height=1.2, + css_class="mini-tile-headline", + fill=palette["text_primary"], + )} +
+
""" + sub_row = f""" + +
+ {pretext_slot( + text=raw_subline, + font="14px Inter, -apple-system, sans-serif", + max_width=float(text_area_w), + line_height=1.35, + css_class="mini-tile-subline", + fill=palette["text_secondary"], + )} +
+
""" + else: + title_row = f""" + {safe_headline}""" + sub_row = f""" + {safe_subline}""" + return f""" - {safe_headline} - {safe_subline} - - {safe_pill} + {title_row} + {sub_row} + + {safe_pill} {f'' if area_path_str else ''} {f'' if line_path_str else ''} @@ -528,6 +604,9 @@ def generate_story_slide( "metric_bg": metric_bg, "metric_text": metric_text } + use_pretext = getattr(generator, "use_pretext", False) + if use_pretext: + from ..pretext_renderer import pretext_slot cards_data = story_cards if story_cards is not None else _default_story_cards( what_changed, time_period, @@ -545,7 +624,7 @@ def generate_story_slide( """ hero_mockup_inner = f"""
- {_build_static_mini_tile(640, 400, tile_palette, hero_svg_data)} + {_build_static_mini_tile(640, 400, tile_palette, hero_svg_data, use_pretext=use_pretext)} {canvas_cards_html}
""" @@ -869,11 +948,40 @@ def generate_story_slide( container_classes = "story-slide-container" if top_tile_only: container_classes += " top-tile-only" - + + if use_pretext: + hero_headline_block = pretext_slot( + text=final_headline, + font=( + "56px Inter, -apple-system, BlinkMacSystemFont, sans-serif" + ), + max_width=920, + line_height=1.1, + css_class="hero-headline", + text_anchor="middle", + fill=hero_text_primary, + ) + hero_subheadline_block = pretext_slot( + text=final_subheadline, + font=( + "24px Inter, -apple-system, BlinkMacSystemFont, sans-serif" + ), + max_width=920, + line_height=1.3, + css_class="hero-subheadline", + text_anchor="middle", + fill=hero_text_secondary, + ) + else: + hero_headline_block = f'
{final_headline}
' + hero_subheadline_block = ( + f'
{final_subheadline}
' + ) + hero_section_html = f"""
-
{final_headline}
-
{final_subheadline}
+ {hero_headline_block} + {hero_subheadline_block}
{hero_mockup_inner} @@ -897,10 +1005,24 @@ def generate_story_slide(
""" + if use_pretext: + insight_text_block = pretext_slot( + text=insight, + font=( + "42px Inter, -apple-system, BlinkMacSystemFont, sans-serif" + ), + max_width=1160, + line_height=1.4, + css_class="insight-text", + text_anchor="middle", + fill="#FFFFFF", + ) + else: + insight_text_block = f'
{insight}
' insight_section_html = f"""
The Core Insight
-
{insight}
+ {insight_text_block}
""" html_content = f"""
diff --git a/modern_graphics/diagrams/timeline.py b/modern_graphics/diagrams/timeline.py index ee03420..0818364 100644 --- a/modern_graphics/diagrams/timeline.py +++ b/modern_graphics/diagrams/timeline.py @@ -42,6 +42,15 @@ def generate_timeline_diagram( return _generate_vertical_timeline(generator, events, color_scheme) theme = extract_theme_colors(color_scheme) + use_pretext = getattr(generator, "use_pretext", False) + display_font = "'Inter', -apple-system, BlinkMacSystemFont, sans-serif" + if color_scheme is not None: + display_font = ( + getattr(color_scheme, "font_family_display", None) + or getattr(color_scheme, "font_family", display_font) + ) + if use_pretext: + from ..pretext_renderer import pretext_slot # Material elevation 2dp (card) shadow = "0 2px 6px rgba(0, 0, 0, 0.16), 0 1px 3px rgba(0, 0, 0, 0.12)" @@ -70,11 +79,22 @@ def generate_timeline_diagram( }}""") desc_html = f'
{description}
' if description else "" + if use_pretext: + event_text_html = pretext_slot( + text=text, + font=f"16px {display_font}", + max_width=200, + line_height=1.4, + css_class="md-subtitle1", + text_anchor="middle", + ) + else: + event_text_html = f'
{text}
' events_html.append(f"""
{date}
-
{text}
+ {event_text_html} {desc_html}
@@ -214,11 +234,22 @@ def generate_timeline_diagram( {ATTRIBUTION_STYLES} """ + if use_pretext: + headline_html = pretext_slot( + text=generator.title, + font=f"24px {display_font}", + max_width=1000, + line_height=1.2, + css_class="md-headline", + text_anchor="middle", + ) + else: + headline_html = f'
{generator.title}
' html_content = f"""
-
{generator.title}
+ {headline_html}
{''.join(events_html)}
@@ -238,6 +269,15 @@ def _generate_vertical_timeline( ) -> str: """Generate a vertical timeline (Material Design: central line, alternating content, chips).""" theme = extract_theme_colors(color_scheme) + use_pretext = getattr(generator, "use_pretext", False) + display_font = "'Inter', -apple-system, BlinkMacSystemFont, sans-serif" + if color_scheme is not None: + display_font = ( + getattr(color_scheme, "font_family_display", None) + or getattr(color_scheme, "font_family", display_font) + ) + if use_pretext: + from ..pretext_renderer import pretext_slot shadow = "0 2px 6px rgba(0, 0, 0, 0.16), 0 1px 3px rgba(0, 0, 0, 0.12)" if theme.is_dark: @@ -263,11 +303,21 @@ def _generate_vertical_timeline( }}""") desc_html = f'
{description}
' if description else "" + if use_pretext: + event_text_html = pretext_slot( + text=text, + font=f"16px {display_font}", + max_width=260, + line_height=1.4, + css_class="md-subtitle1", + ) + else: + event_text_html = f'
{text}
' events_html.append(f"""
{date}
-
{text}
+ {event_text_html} {desc_html}
@@ -399,11 +449,22 @@ def _generate_vertical_timeline( {ATTRIBUTION_STYLES} """ + if use_pretext: + headline_html = pretext_slot( + text=generator.title, + font=f"24px {display_font}", + max_width=640, + line_height=1.2, + css_class="md-headline", + text_anchor="middle", + ) + else: + headline_html = f'
{generator.title}
' html_content = f"""
-
{generator.title}
+ {headline_html}
{''.join(events_html)}
diff --git a/modern_graphics/pretext_utils.py b/modern_graphics/pretext_utils.py index e46b87b..5a6a699 100644 --- a/modern_graphics/pretext_utils.py +++ b/modern_graphics/pretext_utils.py @@ -28,6 +28,40 @@ def generate_pretext_bootstrap_script() -> str: