- 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>
115 lines
4.1 KiB
Python
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
|