pimco-charts/app/renderer/export.py
Vadym Samoilenko d8461076b8 fix: axis labels, bar charts, legend, PDF fonts per Nina's feedback
- layout: _PAD_BOTTOM 100→160, _PAD_LEFT 120→160 to prevent label clipping
- axes: fix _format_tick float-comparison bug (1.1→"1.1" not "1.10");
  add auto-rotation for categorical x-axis labels when they overflow slot width
- engine: tick guard (>20 ticks falls back to nice_ticks); stacked_bar falls
  back to bar instead of crashing; extend _find_date_column keywords
- series: bar width uses 25th-percentile gap instead of min to avoid invisible bars
- legend: line series use line swatch with dash pattern; proportional square size
- export: @font-face injected in HTML <head> for PDF to prevent T3Font in InDesign
- prompts: add categorical bar chart few-shot example; ban stacked_bar; improve
  tick_interval guidance; add bar chart refine examples

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 18:48:17 +01:00

115 lines
4.1 KiB
Python

"""Playwright-based SVG export to PNG and PDF.
Headless Chromium honours @font-face with base64 data URIs, so Roboto Condensed
renders correctly in all exports — no CairoFont-0-0 fallback, no outlined glyphs.
For PDF: @font-face is duplicated in the HTML <head> (not just SVG <defs>) so
Chromium's PDF engine embeds TrueType subsets (CIDFont) rather than Type 3 fonts,
which prevents InDesign from substituting T3Font.
"""
from __future__ import annotations
import base64
from pathlib import Path
_playwright = None
_browser = None
# Resolve fonts directory relative to this file: app/renderer/ → app/static/fonts/
_FONTS_DIR = Path(__file__).parent.parent / "static" / "fonts"
_FONT_ENTRIES = [
("Roboto Condensed", "RobotoCondensed-Light.ttf", "300"),
("Roboto Condensed", "RobotoCondensed-Regular.ttf", "normal"),
("Roboto Condensed", "RobotoCondensed-Bold.ttf", "bold"),
]
def _build_font_css() -> str:
"""Build @font-face CSS for Roboto Condensed from the bundled TTF files."""
css = ""
for family, filename, weight in _FONT_ENTRIES:
font_path = _FONTS_DIR / filename
if font_path.exists():
b64 = base64.b64encode(font_path.read_bytes()).decode("ascii")
css += (
f"@font-face{{"
f"font-family:'{family}';"
f"src:url('data:font/truetype;base64,{b64}') format('truetype');"
f"font-weight:{weight};font-style:normal;"
f"}}"
)
return css
async def init_browser() -> None:
"""Start persistent Playwright/Chromium instance. Call once at app startup."""
global _playwright, _browser
from playwright.async_api import async_playwright
_playwright = await async_playwright().start()
_browser = await _playwright.chromium.launch(headless=True)
async def close_browser() -> None:
"""Shut down browser gracefully. Call at app shutdown."""
global _playwright, _browser
if _browser:
await _browser.close()
_browser = None
if _playwright:
await _playwright.stop()
_playwright = None
def _html_wrapper(svg_str: str, width: int, height: int, font_css: str = "") -> str:
return (
f"<!DOCTYPE html><html><head>"
f"<style>*{{margin:0;padding:0;overflow:hidden}}"
f"@page{{size:{width}px {height}px;margin:0}}"
f"{font_css}"
f"</style></head>"
f"<body style='width:{width}px;height:{height}px'>"
f"{svg_str}"
f"</body></html>"
)
async def svg_to_png(svg_str: str, width: int, height: int) -> bytes:
"""Render SVG to PNG via headless Chromium. Returns raw PNG bytes."""
page = await _browser.new_page(viewport={"width": width, "height": height})
try:
await page.set_content(_html_wrapper(svg_str, width, height), wait_until="load")
# Brief wait for @font-face to finish loading
await page.wait_for_timeout(200)
png_bytes = await page.screenshot(
type="png",
clip={"x": 0, "y": 0, "width": width, "height": height},
)
finally:
await page.close()
return png_bytes
async def svg_to_pdf(svg_str: str, width: int, height: int) -> bytes:
"""Render SVG to PDF via headless Chromium.
The resulting PDF contains embedded TTF subsets and real text operators,
making text fully selectable and editable in InDesign and Illustrator.
Stroke weights match the SVG preview exactly (1px SVG = 0.75pt PDF).
Font CSS is injected into the HTML <head> (not just SVG defs) so Chromium
embeds TrueType subsets as CIDFont rather than Type 3, preventing InDesign
T3Font substitution.
"""
font_css = _build_font_css()
page = await _browser.new_page(viewport={"width": width, "height": height})
try:
await page.set_content(_html_wrapper(svg_str, width, height, font_css), wait_until="load")
await page.wait_for_timeout(200)
pdf_bytes = await page.pdf(
width=f"{width}px",
height=f"{height}px",
print_background=True,
)
finally:
await page.close()
return pdf_bytes