"""Post-process generated HTML so uploaded images stay as the page background.""" from __future__ import annotations import json import re FIX_MARKER = "data-df-uploaded-bg-fix" _DARK_CONTRAST_FIX_MARKER = "data-df-dark-contrast-fix" _DARK_CHART_TEXT = "#e8f4ff" _DARK_CHART_DIM = "#b8c8d8" _CHART_SCRIPT_RE = re.compile( r"(]*data-df-chart\s*=\s*[\"'][^\"']+[\"'][^>]*>)([\s\S]*?)()", re.IGNORECASE, ) _DARK_PAGE_RE = re.compile( r"(?:\bdata(?:-df)?-theme\s*=\s*[\"'][^\"']*\bdark\b|" r"--(?:bg|background|surface)(?:-[\w-]+)?\s*:\s*#[0-4][0-9a-f]{5}\b)", re.IGNORECASE, ) HTML_IMAGES_URL_RE = re.compile( r"url\s*\(\s*['\"]?([^'\"\)\s]+/html-images/[^'\"\)\s]+)['\"]?\s*\)", re.IGNORECASE, ) BODY_RULE_RE = re.compile(r"(?:html\s*,\s*)?body\s*\{[^}]*\}", re.IGNORECASE | re.DOTALL) OVERLAY_RULE_RE = re.compile(r"\.overlay\s*\{[^}]*\}", re.IGNORECASE | re.DOTALL) COVERING_LAYER_RE = re.compile(r"linear-gradient\s*\(|\.overlay\b", re.IGNORECASE) _DARK_THEME_RE = re.compile(r"]*\bdata(?:-df)?-theme\s*=\s*['\"][^'\"]*\bdark\b", re.IGNORECASE) def _escape_css_url(value: str) -> str: return value.replace("\\", "\\\\").replace("'", "\\'") def _extract_html_images_url(rule: str) -> str | None: match = HTML_IMAGES_URL_RE.search(rule) return match.group(1) if match else None def _inject_after_head_open(html: str, tags: str) -> str: if not tags: return html head_match = re.search(r"]*>", html, re.IGNORECASE) if head_match: insert_at = head_match.end() return html[:insert_at] + tags + html[insert_at:] html_match = re.search(r"]*>", html, re.IGNORECASE) if html_match: insert_at = html_match.end() return html[:insert_at] + f"{tags}" + html[insert_at:] return f"{tags}{html}" def _direct_background_style(background_url: str, *, clear_overlay: bool) -> str: overlay_rule = ".overlay{background:none!important;background-image:none!important}" if clear_overlay else "" return ( f"" ) def fix_uploaded_background_overlay(html: str) -> str: """Keep a signed html-images URL as the only page background.""" if not html or FIX_MARKER in html: return html body_rule = BODY_RULE_RE.search(html) if not body_rule: return html background_url = _extract_html_images_url(body_rule.group(0)) if not background_url: return html if not COVERING_LAYER_RE.search(html): return html return _inject_after_head_open( html, _direct_background_style(background_url, clear_overlay=bool(OVERLAY_RULE_RE.search(html))), ) def _dark_component_contrast_style() -> str: """Bridge Bootstrap's light defaults to a generated dark HTML portal. Bootstrap's ``.card`` applies its own foreground token. A page-level ``body { color: ... }`` therefore does not reliably reach a card, table, modal, or form control. The model should still author a complete theme, but this small, idempotent bridge prevents an otherwise valid first render from showing black Bootstrap text while it is being previewed. """ scope = 'html:is([data-theme="dark"],[data-df-theme="dark"])' text = "var(--df-dark-text,var(--text,#e8f4ff))" dim = "var(--df-dark-text-dim,var(--text-dim,#b8c8d8))" surface = "var(--df-dark-surface,var(--panel,#10213d))" return ( f"" ) def fix_dark_component_contrast(html: str) -> str: """Inject an explicit dark foreground bridge only for marked dark pages.""" if not html or _DARK_CONTRAST_FIX_MARKER in html or not _DARK_THEME_RE.search(html): return html return _inject_after_head_open(html, _dark_component_contrast_style()) def _is_dark_page(html: str) -> bool: return bool(_DARK_PAGE_RE.search(re.sub(r"/\*[\s\S]*?\*/", "", html))) def _is_dark_text_color(value: object) -> bool: if not isinstance(value, str): return True match = re.fullmatch(r"#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})", value.strip()) if not match: return False hex_color = match.group(1) if len(hex_color) == 3: hex_color = "".join(character * 2 for character in hex_color) red, green, blue = (int(hex_color[index : index + 2], 16) for index in range(0, 6, 2)) return (0.2126 * red + 0.7152 * green + 0.0722 * blue) / 255 < 0.35 def _as_option_list(value: object) -> list[dict[str, object]]: if isinstance(value, dict): return [value] if isinstance(value, list): return [item for item in value if isinstance(item, dict)] return [] def _needs_readable_color(style: object) -> bool: return not (isinstance(style, dict) and "color" in style and not _is_dark_text_color(style["color"])) def _ensure_style_color(target: dict[str, object], key: str, color: str) -> bool: current = target.get(key) if not isinstance(current, dict): target[key] = {"color": color} return True if _needs_readable_color(current): current["color"] = color return True return False def _apply_readable_chart_colors(data: dict[str, object]) -> bool: changed = _ensure_style_color(data, "textStyle", _DARK_CHART_TEXT) title = data.get("title") if isinstance(title, dict) and title.get("text"): changed = _ensure_style_color(title, "textStyle", _DARK_CHART_TEXT) or changed legend = data.get("legend") if isinstance(legend, dict) and legend.get("show") is not False: changed = _ensure_style_color(legend, "textStyle", _DARK_CHART_DIM) or changed for axis_name in ("xAxis", "yAxis"): for axis in _as_option_list(data.get(axis_name)): axis_label = axis.get("axisLabel") if not (isinstance(axis_label, dict) and axis_label.get("show") is False): changed = _ensure_style_color(axis, "axisLabel", _DARK_CHART_DIM) or changed if axis.get("name"): changed = _ensure_style_color(axis, "nameTextStyle", _DARK_CHART_TEXT) or changed for series in _as_option_list(data.get("series")): series_type = str(series.get("type") or "").lower() label = series.get("label") labels_are_visible = series_type in {"pie", "funnel", "radar"} or not ( isinstance(label, dict) and label.get("show") is False ) if not labels_are_visible: continue if isinstance(label, dict): if _needs_readable_color(label): label["color"] = _DARK_CHART_TEXT changed = True elif series_type in {"pie", "funnel", "radar"}: series["label"] = {"color": _DARK_CHART_TEXT} changed = True return changed def fix_dark_chart_contrast(html: str) -> str: """Fill missing/unreadable dark-chart text colors so validate does not ping-pong.""" if not html or not _is_dark_page(html) or "data-df-chart" not in html.lower(): return html def replace(match: re.Match[str]) -> str: try: data = json.loads(match.group(2)) except json.JSONDecodeError: return match.group(0) if not isinstance(data, dict) or not _apply_readable_chart_colors(data): return match.group(0) return match.group(1) + json.dumps(data, ensure_ascii=False) + match.group(3) return _CHART_SCRIPT_RE.sub(replace, html) def apply_mechanical_html_fixes(path: str, html: str) -> tuple[str, list[str]]: """Apply deterministic HTML repairs and describe what changed.""" notes: list[str] = [] if not path.lower().endswith((".html", ".htm")): return html, notes fixed = fix_dark_component_contrast(html) if fixed != html: notes.append("已自动补齐深色页面卡片/表格/弹窗的浅色文字") if "/html-images/" in fixed: overlay_fixed = fix_uploaded_background_overlay(fixed) if overlay_fixed != fixed: notes.append("已自动修正上传背景图被遮罩盖住的问题") fixed = overlay_fixed chart_fixed = fix_dark_chart_contrast(fixed) if chart_fixed != fixed: notes.append("已自动补齐深色图表的浅色文字(textStyle/坐标轴/图例/系列标签)") fixed = chart_fixed return fixed, notes def maybe_fix_generated_html(path: str, content: str) -> str: """Apply safe generated-page fixes before the sandbox writes an HTML artifact.""" fixed, _notes = apply_mechanical_html_fixes(path, content) return fixed