from __future__ import annotations import logging import re from bs4 import BeautifulSoup, Tag from header_styles import ( SHARED_HEADER_CSS, inject_header_css, inject_header_layout, ) logger = logging.getLogger(__name__) # Single source of truth: (field_name, css_var, default_value). # To add a new theme property, add one entry here — nothing else needs updating. _THEME_PROPS: list[tuple[str, str, str]] = [ ("primary", "--theme-primary", "#1e3a5f"), ("accent", "--theme-accent", "#2563eb"), ("secondary", "--theme-secondary", "#475569"), ("bg", "--theme-bg", "#ffffff"), ("surface", "--theme-surface", "#f8fafc"), ("border", "--theme-border", "#e2e8f0"), ("text", "--theme-text", "#1a1a1a"), ("text_muted", "--theme-text-muted", "#6b7280"), ("heading", "--theme-heading", "#111827"), ("font", "--theme-font", "'Helvetica Neue', Arial, sans-serif"), ("font_size_base", "--theme-font-size-base", "10pt"), ("font_size_heading", "--theme-font-size-heading", "14pt"), ("font_weight_body", "--theme-font-weight-body", "400"), ("font_weight_heading", "--theme-font-weight-heading", "700"), ("font_weight_bold", "--theme-font-weight-bold", "600"), ("line_height", "--theme-line-height", "1.4"), ("page_margin", "--theme-page-margin", "20mm"), ] _FIELD_TO_VAR: dict[str, str] = {field: var for field, var, _ in _THEME_PROPS} _DEFAULTS: dict[str, str] = {var: default for _, var, default in _THEME_PROPS} DEFAULT_THEME_CSS: str = ":root {\n" + "\n".join(f" {var}: {val};" for var, val in _DEFAULTS.items()) + "\n}" def build_theme_css(overrides: dict[str, str] | None = None) -> str: """Build a :root { } block of CSS custom properties with optional overrides.""" if not overrides: return DEFAULT_THEME_CSS props = {**_DEFAULTS, **{_FIELD_TO_VAR.get(k, k): v for k, v in overrides.items()}} return ":root {\n" + "\n".join(f" {var}: {val};" for var, val in props.items()) + "\n}" def inject_theme(html_content: str, theme_overrides: dict[str, str] | None = None) -> str: """ Inject theme CSS variables into an HTML document. If the template contains {{THEME_CSS}}, it is replaced with the theme block. Otherwise, the style block is injected before . A body-level cascade is included so that typography CSS variables take effect on existing templates without requiring per-template changes. """ theme_css = build_theme_css(theme_overrides) body_cascade = ( # html background covers edge-to-edge (including @page margin area) because # Chrome applies the html element background to the entire page in print. # Do NOT zero out @page margins here — body padding only applies to the # first/last page, so using it as a substitute for @page margins breaks # the top margin on all pages after a page break. "html { " "background: var(--theme-bg) !important; " "-webkit-print-color-adjust: exact !important; " "print-color-adjust: exact !important; " "} " "body { " "background: var(--theme-bg) !important; " "-webkit-print-color-adjust: exact !important; " "print-color-adjust: exact !important; " "font-size: var(--theme-font-size-base) !important; " "line-height: var(--theme-line-height) !important; " "} " "h1, .doc-title, .name { color: var(--theme-primary) !important; } " "h2, h3, h4, h5, h6, .section-heading { color: var(--theme-primary) !important; } " ".party-card-header, .entry-org, .job-title, .sender-company { color: var(--theme-accent) !important; }" ) full_css = f"{theme_css}\n{body_cascade}" logger.info("[inject_theme] overrides=%s", theme_overrides) logger.info("[inject_theme] full CSS block being injected:\n%s", full_css) if "{{THEME_CSS}}" in html_content: logger.info("[inject_theme] replacing {{THEME_CSS}} placeholder") return html_content.replace("{{THEME_CSS}}", full_css) style_tag = f"" if "" in html_content: logger.info("[inject_theme] injecting