Files

240 lines
9.5 KiB
Python

"""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"(<script\b[^>]*data-df-chart\s*=\s*[\"'][^\"']+[\"'][^>]*>)([\s\S]*?)(</script>)",
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"<html\b[^>]*\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"<head\b[^>]*>", 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\b[^>]*>", html, re.IGNORECASE)
if html_match:
insert_at = html_match.end()
return html[:insert_at] + f"<head>{tags}</head>" + html[insert_at:]
return f"<head>{tags}</head>{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"<style {FIX_MARKER}>"
"html,body{"
f"background-image:url('{_escape_css_url(background_url)}')!important;"
"background-size:cover!important;"
"background-position:center!important;"
"background-repeat:no-repeat!important;"
"background-color:transparent!important;"
"}"
f"{overlay_rule}"
"</style>"
)
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"<style {_DARK_CONTRAST_FIX_MARKER}>"
f"{scope}{{color-scheme:dark;--bs-body-color:{text};--bs-body-bg:var(--bg,#0a1628);"
f"--bs-emphasis-color:{text};--bs-secondary-color:{dim};--bs-border-color:var(--border,rgba(0,180,255,.3));}}"
f"{scope} .card,{scope} .modal-content,{scope} .modal-body,{scope} .data-table,"
f"{scope} .data-table td,{scope} .data-table th,{scope} .detail-item,{scope} .detail-item span"
f"{{color:{text}!important;}}"
f"{scope} .data-table th,{scope} .detail-item label,{scope} .detail-desc"
f"{{color:{dim}!important;}}"
f"{scope} .form-control,{scope} .form-select,{scope} textarea,{scope} input"
f"{{color:{text};background-color:{surface};border-color:var(--border,rgba(0,180,255,.3));}}"
"</style>"
)
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