240 lines
9.5 KiB
Python
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
|