马一丁

Improved Support for Word Clouds

@@ -167,6 +167,11 @@ class ChartToSVGConverter: @@ -167,6 +167,11 @@ class ChartToSVGConverter:
167 return None 167 return None
168 168
169 # 根据图表类型调用相应的渲染方法 169 # 根据图表类型调用相应的渲染方法
  170 + if 'wordcloud' in str(chart_type).lower():
  171 + # 词云由专用渲染逻辑处理,这里跳过SVG转换以避免告警
  172 + logger.debug("检测到词云图表,跳过chart_to_svg转换")
  173 + return None
  174 +
170 render_method = getattr(self, f'_render_{chart_type}', None) 175 render_method = getattr(self, f'_render_{chart_type}', None)
171 if not render_method: 176 if not render_method:
172 logger.warning(f"不支持的图表类型: {chart_type}") 177 logger.warning(f"不支持的图表类型: {chart_type}")
@@ -1628,6 +1628,10 @@ class HTMLRenderer: @@ -1628,6 +1628,10 @@ class HTMLRenderer:
1628 if is_chart: 1628 if is_chart:
1629 self.chart_validation_stats['total'] += 1 1629 self.chart_validation_stats['total'] += 1
1630 1630
  1631 + # 词云使用专用渲染逻辑,不按Chart.js规则验证,直接跳过防止误判
  1632 + if is_wordcloud:
  1633 + self.chart_validation_stats['valid'] += 1
  1634 + else:
1631 # 如果此前已记录失败,直接使用占位提示,避免重复修复 1635 # 如果此前已记录失败,直接使用占位提示,避免重复修复
1632 has_failed, cached_reason = self._has_chart_failure(block) 1636 has_failed, cached_reason = self._has_chart_failure(block)
1633 if has_failed: 1637 if has_failed:
@@ -1700,7 +1704,7 @@ class HTMLRenderer: @@ -1700,7 +1704,7 @@ class HTMLRenderer:
1700 title = props.get("title") 1704 title = props.get("title")
1701 title_html = f'<div class="chart-title">{self._escape_html(title)}</div>' if title else "" 1705 title_html = f'<div class="chart-title">{self._escape_html(title)}</div>' if title else ""
1702 fallback_html = ( 1706 fallback_html = (
1703 - self._render_wordcloud_fallback(props, block.get("widgetId")) 1707 + self._render_wordcloud_fallback(props, block.get("widgetId"), block.get("data"))
1704 if is_wordcloud 1708 if is_wordcloud
1705 else self._render_widget_fallback(normalized_data, block.get("widgetId")) 1709 else self._render_widget_fallback(normalized_data, block.get("widgetId"))
1706 ) 1710 )
@@ -1750,20 +1754,57 @@ class HTMLRenderer: @@ -1750,20 +1754,57 @@ class HTMLRenderer:
1750 """ 1754 """
1751 return table_html 1755 return table_html
1752 1756
1753 - def _render_wordcloud_fallback(self, props: Dict[str, Any] | None, widget_id: str | None = None) -> str: 1757 + def _render_wordcloud_fallback(
  1758 + self,
  1759 + props: Dict[str, Any] | None,
  1760 + widget_id: str | None = None,
  1761 + block_data: Any | None = None,
  1762 + ) -> str:
1754 """为词云提供表格兜底,避免WordCloud渲染失败后页面空白""" 1763 """为词云提供表格兜底,避免WordCloud渲染失败后页面空白"""
1755 - words = []  
1756 - if isinstance(props, dict):  
1757 - raw = props.get("data") 1764 + def _collect_items(raw: Any) -> list[dict]:
  1765 + collected: list[dict] = []
1758 if isinstance(raw, list): 1766 if isinstance(raw, list):
1759 for item in raw: 1767 for item in raw:
1760 - if not isinstance(item, dict):  
1761 - continue 1768 + if isinstance(item, dict):
1762 text = item.get("word") or item.get("text") or item.get("label") 1769 text = item.get("word") or item.get("text") or item.get("label")
1763 weight = item.get("weight") 1770 weight = item.get("weight")
1764 category = item.get("category") or "" 1771 category = item.get("category") or ""
1765 if text: 1772 if text:
1766 - words.append({"word": str(text), "weight": weight, "category": str(category)}) 1773 + collected.append({"word": str(text), "weight": weight, "category": str(category)})
  1774 + elif isinstance(item, (list, tuple)) and item:
  1775 + text = item[0]
  1776 + weight = item[1] if len(item) > 1 else None
  1777 + category = item[2] if len(item) > 2 else ""
  1778 + if text:
  1779 + collected.append({"word": str(text), "weight": weight, "category": str(category)})
  1780 + elif isinstance(item, str):
  1781 + collected.append({"word": item, "weight": 1.0, "category": ""})
  1782 + elif isinstance(raw, dict):
  1783 + if not {"labels", "datasets"}.intersection(raw.keys()):
  1784 + for text, weight in raw.items():
  1785 + collected.append({"word": str(text), "weight": weight, "category": ""})
  1786 + return collected
  1787 +
  1788 + words: list[dict] = []
  1789 + seen: set[str] = set()
  1790 + candidates = []
  1791 + if isinstance(props, dict):
  1792 + for key in ("data", "items", "words"):
  1793 + if key in props:
  1794 + candidates.append(props[key])
  1795 + candidates.append((props or {}).get("sourceData"))
  1796 +
  1797 + # 允许使用block.data兜底,避免缺失props时出现空白
  1798 + if block_data is not None:
  1799 + candidates.append(block_data)
  1800 +
  1801 + for raw in candidates:
  1802 + for item in _collect_items(raw):
  1803 + key = f"{item['word']}::{item.get('category','')}"
  1804 + if key in seen:
  1805 + continue
  1806 + seen.add(key)
  1807 + words.append(item)
1767 1808
1768 if not words: 1809 if not words:
1769 return "" 1810 return ""
@@ -2712,11 +2753,11 @@ table th {{ @@ -2712,11 +2753,11 @@ table th {{
2712 line-height: 1.6; 2753 line-height: 1.6;
2713 }} 2754 }}
2714 .chart-card.wordcloud-card .chart-container {{ 2755 .chart-card.wordcloud-card .chart-container {{
2715 - min-height: 260px; 2756 + min-height: 180px;
2716 }} 2757 }}
2717 .chart-container {{ 2758 .chart-container {{
2718 position: relative; 2759 position: relative;
2719 - min-height: 320px; 2760 + min-height: 220px;
2720 }} 2761 }}
2721 .chart-fallback {{ 2762 .chart-fallback {{
2722 display: none; 2763 display: none;
@@ -2743,6 +2784,31 @@ table th {{ @@ -2743,6 +2784,31 @@ table th {{
2743 .chart-fallback th {{ 2784 .chart-fallback th {{
2744 background: rgba(0,0,0,0.04); 2785 background: rgba(0,0,0,0.04);
2745 }} 2786 }}
  2787 +.wordcloud-fallback .wordcloud-badges {{
  2788 + display: flex;
  2789 + flex-wrap: wrap;
  2790 + gap: 6px;
  2791 + margin-top: 6px;
  2792 +}}
  2793 +.wordcloud-badge {{
  2794 + display: inline-flex;
  2795 + align-items: center;
  2796 + gap: 4px;
  2797 + padding: 4px 8px;
  2798 + border-radius: 999px;
  2799 + border: 1px solid rgba(74, 144, 226, 0.35);
  2800 + color: var(--text-color);
  2801 + background: linear-gradient(135deg, rgba(74, 144, 226, 0.14) 0%, rgba(74, 144, 226, 0.24) 100%);
  2802 + box-shadow: 0 4px 10px rgba(15, 23, 42, 0.06);
  2803 +}}
  2804 +.dark-mode .wordcloud-badge {{
  2805 + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.35);
  2806 +}}
  2807 +.wordcloud-badge small {{
  2808 + color: var(--secondary-color);
  2809 + font-weight: 600;
  2810 + font-size: 0.75rem;
  2811 +}}
2746 .chart-note {{ 2812 .chart-note {{
2747 margin-top: 8px; 2813 margin-top: 8px;
2748 font-size: 0.85rem; 2814 font-size: 0.85rem;
@@ -3039,6 +3105,45 @@ function liftDarkColor(color) { @@ -3039,6 +3105,45 @@ function liftDarkColor(color) {
3039 return normalized; 3105 return normalized;
3040 } 3106 }
3041 3107
  3108 +function mixColors(colorA, colorB, amount) {
  3109 + const rgbA = rgbFromColor(colorA);
  3110 + const rgbB = rgbFromColor(colorB);
  3111 + if (!rgbA && !rgbB) return colorA || colorB;
  3112 + if (!rgbA) return colorB;
  3113 + if (!rgbB) return colorA;
  3114 + const t = Math.min(1, Math.max(0, amount || 0));
  3115 + const mixed = rgbA.map((v, idx) => Math.round(v * (1 - t) + rgbB[idx] * t));
  3116 + return `rgb(${mixed[0]}, ${mixed[1]}, ${mixed[2]})`;
  3117 +}
  3118 +
  3119 +function pickComputedColor(keys, fallback, styles) {
  3120 + const styleRef = styles || getComputedStyle(document.body);
  3121 + for (const key of keys) {
  3122 + const val = styleRef.getPropertyValue(key);
  3123 + if (val && val.trim()) {
  3124 + const normalized = normalizeColorToken(val.trim());
  3125 + if (normalized) return normalized;
  3126 + }
  3127 + }
  3128 + return fallback;
  3129 +}
  3130 +
  3131 +function resolveWordcloudTheme() {
  3132 + const styles = getComputedStyle(document.body);
  3133 + const isDark = document.body.classList.contains('dark-mode');
  3134 + const text = pickComputedColor(['--text-color'], isDark ? '#e5e7eb' : '#111827', styles);
  3135 + const secondary = pickComputedColor(['--secondary-color', '--color-text-secondary'], isDark ? '#cbd5e1' : '#475569', styles);
  3136 + const accent = liftDarkColor(
  3137 + pickComputedColor(['--primary-color', '--color-accent', '--re-accent-color'], '#4A90E2', styles)
  3138 + );
  3139 + const cardBg = pickComputedColor(
  3140 + ['--card-bg', '--paper-bg', '--bg', '--bg-color', '--background', '--page-bg'],
  3141 + isDark ? '#0f172a' : '#ffffff',
  3142 + styles
  3143 + );
  3144 + return { text, secondary, accent, cardBg, isDark };
  3145 +}
  3146 +
3042 function normalizeDatasetColors(payload, chartType) { 3147 function normalizeDatasetColors(payload, chartType) {
3043 const changes = []; 3148 const changes = [];
3044 const data = payload && payload.data; 3149 const data = payload && payload.data;
@@ -3246,29 +3351,79 @@ function isWordCloudWidget(payload) { @@ -3246,29 +3351,79 @@ function isWordCloudWidget(payload) {
3246 return typeof type === 'string' && type.toLowerCase().includes('wordcloud'); 3351 return typeof type === 'string' && type.toLowerCase().includes('wordcloud');
3247 } 3352 }
3248 3353
  3354 +function hashString(str) {
  3355 + let h = 0;
  3356 + if (!str) return h;
  3357 + for (let i = 0; i < str.length; i++) {
  3358 + h = (h << 5) - h + str.charCodeAt(i);
  3359 + h |= 0;
  3360 + }
  3361 + return h;
  3362 +}
  3363 +
3249 function normalizeWordcloudItems(payload) { 3364 function normalizeWordcloudItems(payload) {
3250 - const source = payload && payload.props && payload.props.data;  
3251 - if (!Array.isArray(source)) return [];  
3252 - return source.map(item => {  
3253 - if (!item || typeof item !== 'object') return null;  
3254 - const word = item.word || item.text || item.label;  
3255 - if (!word) return null;  
3256 - const rawWeight = item.weight;  
3257 - let weight = 0;  
3258 - if (typeof rawWeight === 'number' && !Number.isNaN(rawWeight)) {  
3259 - weight = rawWeight;  
3260 - } else if (typeof rawWeight === 'string') {  
3261 - const parsed = parseFloat(rawWeight);  
3262 - weight = Number.isNaN(parsed) ? 0 : parsed;  
3263 - }  
3264 - const category = (item.category || '').toString().toLowerCase();  
3265 - return { word: String(word), weight, category };  
3266 - }).filter(Boolean); 3365 + const sources = [];
  3366 + const props = payload && payload.props;
  3367 + const dataField = payload && payload.data;
  3368 + if (props) {
  3369 + ['data', 'items', 'words', 'sourceData'].forEach(key => {
  3370 + if (props[key]) sources.push(props[key]);
  3371 + });
  3372 + }
  3373 + if (dataField) {
  3374 + sources.push(dataField);
  3375 + }
  3376 +
  3377 + const seen = new Map();
  3378 + const pushItem = (word, weight, category) => {
  3379 + if (!word) return;
  3380 + let numeric = 1;
  3381 + if (typeof weight === 'number' && Number.isFinite(weight)) {
  3382 + numeric = weight;
  3383 + } else if (typeof weight === 'string') {
  3384 + const parsed = parseFloat(weight);
  3385 + numeric = Number.isFinite(parsed) ? parsed : 1;
  3386 + }
  3387 + if (!(numeric > 0)) numeric = 1;
  3388 + const cat = (category || '').toString().toLowerCase();
  3389 + const key = `${word}__${cat}`;
  3390 + const existing = seen.get(key);
  3391 + const payloadItem = { word: String(word), weight: numeric, category: cat };
  3392 + if (!existing || numeric > existing.weight) {
  3393 + seen.set(key, payloadItem);
  3394 + }
  3395 + };
  3396 +
  3397 + const consume = (raw) => {
  3398 + if (!raw) return;
  3399 + if (Array.isArray(raw)) {
  3400 + raw.forEach(item => {
  3401 + if (!item) return;
  3402 + if (Array.isArray(item)) {
  3403 + pushItem(item[0], item[1], item[2]);
  3404 + } else if (typeof item === 'object') {
  3405 + pushItem(item.word || item.text || item.label, item.weight, item.category);
  3406 + } else if (typeof item === 'string') {
  3407 + pushItem(item, 1, '');
  3408 + }
  3409 + });
  3410 + } else if (typeof raw === 'object') {
  3411 + Object.entries(raw).forEach(([word, weight]) => pushItem(word, weight, ''));
  3412 + }
  3413 + };
  3414 +
  3415 + sources.forEach(consume);
  3416 +
  3417 + const items = Array.from(seen.values());
  3418 + items.sort((a, b) => (b.weight || 0) - (a.weight || 0));
  3419 + return items.slice(0, 150);
3267 } 3420 }
3268 3421
3269 function wordcloudColor(category) { 3422 function wordcloudColor(category) {
3270 const key = typeof category === 'string' ? category.toLowerCase() : ''; 3423 const key = typeof category === 'string' ? category.toLowerCase() : '';
3271 - return WORDCLOUD_CATEGORY_COLORS[key] || '#334155'; 3424 + const palette = resolveWordcloudTheme();
  3425 + const base = WORDCLOUD_CATEGORY_COLORS[key] || palette.accent || palette.secondary || '#334155';
  3426 + return liftDarkColor(base);
3272 } 3427 }
3273 3428
3274 function renderWordCloudFallback(canvas, items, reason) { 3429 function renderWordCloudFallback(canvas, items, reason) {
@@ -3282,26 +3437,44 @@ function renderWordCloudFallback(canvas, items, reason) { @@ -3282,26 +3437,44 @@ function renderWordCloudFallback(canvas, items, reason) {
3282 } else { 3437 } else {
3283 canvas.style.display = 'none'; 3438 canvas.style.display = 'none';
3284 } 3439 }
3285 - let fallback = card.querySelector('.chart-fallback'); 3440 + let fallback = card.querySelector('.chart-fallback[data-dynamic="true"]');
  3441 + if (!fallback) {
  3442 + fallback = card.querySelector('.chart-fallback');
  3443 + }
3286 if (!fallback) { 3444 if (!fallback) {
3287 fallback = document.createElement('div'); 3445 fallback = document.createElement('div');
3288 - fallback.className = 'chart-fallback wordcloud-fallback';  
3289 - fallback.setAttribute('data-dynamic', 'true');  
3290 card.appendChild(fallback); 3446 card.appendChild(fallback);
3291 } 3447 }
  3448 + fallback.className = 'chart-fallback wordcloud-fallback';
  3449 + fallback.setAttribute('data-dynamic', 'true');
3292 fallback.style.display = 'block'; 3450 fallback.style.display = 'block';
  3451 + fallback.innerHTML = '';
3293 card.setAttribute('data-chart-state', 'fallback'); 3452 card.setAttribute('data-chart-state', 'fallback');
  3453 + const buildBadge = (item, maxWeight) => {
  3454 + const badge = document.createElement('span');
  3455 + badge.className = 'wordcloud-badge';
  3456 + const clampedWeight = Math.max(0.5, (item.weight || 1));
  3457 + const normalized = Math.min(1, clampedWeight / (maxWeight || 1));
  3458 + const fontSize = 0.85 + normalized * 0.9;
  3459 + badge.style.fontSize = `${fontSize}rem`;
  3460 + badge.style.background = `linear-gradient(135deg, ${lightenColor(wordcloudColor(item.category), 0.05)} 0%, ${lightenColor(wordcloudColor(item.category), 0.15)} 100%)`;
  3461 + badge.style.borderColor = lightenColor(wordcloudColor(item.category), 0.25);
  3462 + badge.textContent = item.word;
  3463 + if (item.weight !== undefined && item.weight !== null) {
  3464 + const meta = document.createElement('small');
  3465 + meta.textContent = item.weight >= 0 && item.weight <= 1.5
  3466 + ? `${(item.weight * 100).toFixed(0)}%`
  3467 + : item.weight.toFixed(1).replace(/\.0+$/, '').replace(/0+$/, '').replace(/\.$/, '');
  3468 + badge.appendChild(meta);
  3469 + }
  3470 + return badge;
  3471 + };
  3472 +
3294 if (reason) { 3473 if (reason) {
3295 - let notice = fallback.querySelector('.chart-fallback__notice');  
3296 - if (!notice) {  
3297 - notice = document.createElement('p'); 3474 + const notice = document.createElement('p');
3298 notice.className = 'chart-fallback__notice'; 3475 notice.className = 'chart-fallback__notice';
3299 - fallback.insertBefore(notice, fallback.firstChild || null);  
3300 - }  
3301 - notice.textContent = `词云未能渲染${reason ? `(${reason})` : ''},已展示数据表。`;  
3302 - }  
3303 - if (fallback.querySelector('table')) {  
3304 - return; 3476 + notice.textContent = `词云未能渲染${reason ? `(${reason})` : ''},已展示关键词列表。`;
  3477 + fallback.appendChild(notice);
3305 } 3478 }
3306 if (!items || !items.length) { 3479 if (!items || !items.length) {
3307 const empty = document.createElement('p'); 3480 const empty = document.createElement('p');
@@ -3309,39 +3482,13 @@ function renderWordCloudFallback(canvas, items, reason) { @@ -3309,39 +3482,13 @@ function renderWordCloudFallback(canvas, items, reason) {
3309 fallback.appendChild(empty); 3482 fallback.appendChild(empty);
3310 return; 3483 return;
3311 } 3484 }
3312 - const table = document.createElement('table');  
3313 - const thead = document.createElement('thead');  
3314 - const headRow = document.createElement('tr');  
3315 - ['关键词', '权重', '类别'].forEach(text => {  
3316 - const th = document.createElement('th');  
3317 - th.textContent = text;  
3318 - headRow.appendChild(th);  
3319 - });  
3320 - thead.appendChild(headRow);  
3321 - table.appendChild(thead);  
3322 - const tbody = document.createElement('tbody'); 3485 + const badges = document.createElement('div');
  3486 + badges.className = 'wordcloud-badges';
  3487 + const maxWeight = items.reduce((max, item) => Math.max(max, item.weight || 0), 1);
3323 items.forEach(item => { 3488 items.forEach(item => {
3324 - const row = document.createElement('tr');  
3325 - const wordCell = document.createElement('td');  
3326 - wordCell.textContent = item.word;  
3327 - const weightCell = document.createElement('td');  
3328 - if (typeof item.weight === 'number' && !Number.isNaN(item.weight)) {  
3329 - weightCell.textContent = item.weight >= 0 && item.weight <= 1.5  
3330 - ? `${(item.weight * 100).toFixed(1)}%`  
3331 - : item.weight.toFixed(2).replace(/\.0+$/, '').replace(/0+$/, '').replace(/\.$/, '');  
3332 - } else {  
3333 - weightCell.textContent = item.weight !== undefined && item.weight !== null ? String(item.weight) : '—';  
3334 - }  
3335 - const categoryCell = document.createElement('td');  
3336 - categoryCell.textContent = item.category || '—';  
3337 - categoryCell.style.color = wordcloudColor(item.category);  
3338 - row.appendChild(wordCell);  
3339 - row.appendChild(weightCell);  
3340 - row.appendChild(categoryCell);  
3341 - tbody.appendChild(row); 3489 + badges.appendChild(buildBadge(item, maxWeight));
3342 }); 3490 });
3343 - table.appendChild(tbody);  
3344 - fallback.appendChild(table); 3491 + fallback.appendChild(badges);
3345 } 3492 }
3346 3493
3347 function renderWordCloud(canvas, payload, skipRegistry) { 3494 function renderWordCloud(canvas, payload, skipRegistry) {
@@ -3358,38 +3505,82 @@ function renderWordCloud(canvas, payload, skipRegistry) { @@ -3358,38 +3505,82 @@ function renderWordCloud(canvas, payload, skipRegistry) {
3358 renderWordCloudFallback(canvas, items, '词云依赖未加载'); 3505 renderWordCloudFallback(canvas, items, '词云依赖未加载');
3359 return; 3506 return;
3360 } 3507 }
  3508 + const theme = resolveWordcloudTheme();
3361 const dpr = Math.max(1, window.devicePixelRatio || 1); 3509 const dpr = Math.max(1, window.devicePixelRatio || 1);
3362 - const width = Math.max(240, (container ? container.clientWidth : canvas.clientWidth || canvas.width || 320));  
3363 - const height = Math.max(180, Math.round(width * 0.62)); 3510 + const width = Math.max(260, (container ? container.clientWidth : canvas.clientWidth || canvas.width || 320));
  3511 + const height = Math.max(120, Math.round(width / 5)); // 5:1 宽高比
3364 canvas.width = Math.round(width * dpr); 3512 canvas.width = Math.round(width * dpr);
3365 canvas.height = Math.round(height * dpr); 3513 canvas.height = Math.round(height * dpr);
3366 canvas.style.width = `${width}px`; 3514 canvas.style.width = `${width}px`;
3367 canvas.style.height = `${height}px`; 3515 canvas.style.height = `${height}px`;
  3516 + canvas.style.backgroundColor = 'transparent';
  3517 +
  3518 + const resolveBgColor = () => {
  3519 + const cardEl = card || container || document.body;
  3520 + const style = getComputedStyle(cardEl);
  3521 + const tokens = ['--card-bg', '--panel-bg', '--paper-bg', '--bg', '--background', '--page-bg'];
  3522 + for (const key of tokens) {
  3523 + const val = style.getPropertyValue(key);
  3524 + if (val && val.trim() && val.trim() !== 'transparent') return val.trim();
  3525 + }
  3526 + if (style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)') return style.backgroundColor;
  3527 + const bodyStyle = getComputedStyle(document.body);
  3528 + for (const key of tokens) {
  3529 + const val = bodyStyle.getPropertyValue(key);
  3530 + if (val && val.trim() && val.trim() !== 'transparent') return val.trim();
  3531 + }
  3532 + if (bodyStyle.backgroundColor && bodyStyle.backgroundColor !== 'rgba(0, 0, 0, 0)') {
  3533 + return bodyStyle.backgroundColor;
  3534 + }
  3535 + return 'transparent';
  3536 + };
  3537 + const bgColor = resolveBgColor() || theme.cardBg || 'transparent';
3368 3538
3369 const maxWeight = items.reduce((max, item) => Math.max(max, item.weight || 0), 0) || 1; 3539 const maxWeight = items.reduce((max, item) => Math.max(max, item.weight || 0), 0) || 1;
  3540 + const weightLookup = new Map();
  3541 + const categoryLookup = new Map();
  3542 + items.forEach(it => {
  3543 + weightLookup.set(it.word, it.weight || 1);
  3544 + categoryLookup.set(it.word, it.category || '');
  3545 + });
3370 const list = items.map(item => [item.word, item.weight && item.weight > 0 ? item.weight : 1]); 3546 const list = items.map(item => [item.word, item.weight && item.weight > 0 ? item.weight : 1]);
3371 try { 3547 try {
3372 WordCloud(canvas, { 3548 WordCloud(canvas, {
3373 list, 3549 list,
3374 - gridSize: Math.max(8, Math.floor(Math.sqrt(canvas.width * canvas.height) / 80)), 3550 + gridSize: Math.max(3, Math.floor(Math.sqrt(canvas.width * canvas.height) / 170)),
3375 weightFactor: (val) => { 3551 weightFactor: (val) => {
3376 const normalized = Math.max(0, val) / maxWeight; 3552 const normalized = Math.max(0, val) / maxWeight;
3377 - const size = 16 + normalized * 32; 3553 + const cap = Math.min(width, height);
  3554 + const base = Math.max(9, cap / 5.5);
  3555 + const size = base * (0.8 + normalized * 1.3);
3378 return size * dpr; 3556 return size * dpr;
3379 }, 3557 },
3380 color: (word) => { 3558 color: (word) => {
3381 - const found = items.find(entry => entry.word === word);  
3382 - return lightenColor(wordcloudColor(found && found.category), 0.05); 3559 + const w = weightLookup.get(word) || 1;
  3560 + const ratio = Math.max(0, Math.min(1, w / (maxWeight || 1)));
  3561 + const category = categoryLookup.get(word) || '';
  3562 + const base = wordcloudColor(category);
  3563 + const target = theme.isDark ? '#ffffff' : (theme.text || '#111827');
  3564 + const mixAmount = theme.isDark
  3565 + ? 0.28 + (1 - ratio) * 0.22
  3566 + : 0.12 + (1 - ratio) * 0.35;
  3567 + const mixed = mixColors(base, target, mixAmount);
  3568 + return ensureAlpha(mixed || base, theme.isDark ? 0.95 : 1);
3383 }, 3569 },
3384 - rotateRatio: 0.15, 3570 + rotateRatio: 0,
  3571 + rotationSteps: 0,
3385 shuffle: false, 3572 shuffle: false,
3386 shrinkToFit: true, 3573 shrinkToFit: true,
3387 drawOutOfBound: false, 3574 drawOutOfBound: false,
3388 - backgroundColor: getComputedStyle(document.body).getPropertyValue('--card-bg').trim() || '#fff' 3575 + shape: 'square',
  3576 + ellipticity: 0.45,
  3577 + clearCanvas: true,
  3578 + backgroundColor: bgColor
3389 }); 3579 });
3390 if (container) { 3580 if (container) {
3391 container.style.display = ''; 3581 container.style.display = '';
3392 container.style.minHeight = `${height}px`; 3582 container.style.minHeight = `${height}px`;
  3583 + container.style.background = 'transparent';
3393 } 3584 }
3394 const fallback = card && card.querySelector('.chart-fallback'); 3585 const fallback = card && card.querySelector('.chart-fallback');
3395 if (fallback) { 3586 if (fallback) {
@@ -3870,11 +4061,19 @@ function exportPdf() { @@ -3870,11 +4061,19 @@ function exportPdf() {
3870 } 4061 }
3871 4062
3872 document.addEventListener('DOMContentLoaded', () => { 4063 document.addEventListener('DOMContentLoaded', () => {
  4064 + const rerenderWordclouds = debounce(() => {
  4065 + wordCloudRegistry.forEach(fn => {
  4066 + if (typeof fn === 'function') {
  4067 + fn();
  4068 + }
  4069 + });
  4070 + }, 260);
3873 const themeBtn = document.getElementById('theme-toggle'); 4071 const themeBtn = document.getElementById('theme-toggle');
3874 if (themeBtn) { 4072 if (themeBtn) {
3875 themeBtn.addEventListener('click', () => { 4073 themeBtn.addEventListener('click', () => {
3876 document.body.classList.toggle('dark-mode'); 4074 document.body.classList.toggle('dark-mode');
3877 chartRegistry.forEach(applyChartTheme); 4075 chartRegistry.forEach(applyChartTheme);
  4076 + rerenderWordclouds();
3878 }); 4077 });
3879 } 4078 }
3880 const printBtn = document.getElementById('print-btn'); 4079 const printBtn = document.getElementById('print-btn');
@@ -3885,13 +4084,6 @@ document.addEventListener('DOMContentLoaded', () => { @@ -3885,13 +4084,6 @@ document.addEventListener('DOMContentLoaded', () => {
3885 if (exportBtn) { 4084 if (exportBtn) {
3886 exportBtn.addEventListener('click', exportPdf); 4085 exportBtn.addEventListener('click', exportPdf);
3887 } 4086 }
3888 - const rerenderWordclouds = debounce(() => {  
3889 - wordCloudRegistry.forEach(fn => {  
3890 - if (typeof fn === 'function') {  
3891 - fn();  
3892 - }  
3893 - });  
3894 - }, 260);  
3895 window.addEventListener('resize', rerenderWordclouds); 4087 window.addEventListener('resize', rerenderWordclouds);
3896 hydrateCharts(); 4088 hydrateCharts();
3897 }); 4089 });
@@ -331,6 +331,13 @@ class PDFRenderer: @@ -331,6 +331,13 @@ class PDFRenderer:
331 331
332 # 只处理chart.js类型的widget 332 # 只处理chart.js类型的widget
333 if widget_id and widget_type.startswith('chart.js'): 333 if widget_id and widget_type.startswith('chart.js'):
  334 + widget_type_lower = widget_type.lower()
  335 + props = block.get('props')
  336 + props_type = str(props.get('type') or '').lower() if isinstance(props, dict) else ''
  337 + if 'wordcloud' in widget_type_lower or 'wordcloud' in props_type:
  338 + logger.debug(f"检测到词云 {widget_id},跳过SVG转换并使用图片注入流程")
  339 + continue
  340 +
334 failed, fail_reason = self.html_renderer._has_chart_failure(block) 341 failed, fail_reason = self.html_renderer._has_chart_failure(block)
335 if block.get("_chart_renderable") is False or failed: 342 if block.get("_chart_renderable") is False or failed:
336 logger.debug( 343 logger.debug(
@@ -392,7 +399,13 @@ class PDFRenderer: @@ -392,7 +399,13 @@ class PDFRenderer:
392 widget_id = block.get('widgetId') 399 widget_id = block.get('widgetId')
393 widget_type = block.get('widgetType', '') 400 widget_type = block.get('widgetType', '')
394 401
395 - if widget_id and isinstance(widget_type, str) and 'wordcloud' in widget_type.lower(): 402 + props = block.get('props')
  403 + props_type = str(props.get('type') or '') if isinstance(props, dict) else ''
  404 + is_wordcloud = (
  405 + isinstance(widget_type, str) and 'wordcloud' in widget_type.lower()
  406 + ) or ('wordcloud' in props_type.lower())
  407 +
  408 + if widget_id and is_wordcloud:
396 try: 409 try:
397 data_uri = self._generate_wordcloud_image(block) 410 data_uri = self._generate_wordcloud_image(block)
398 if data_uri: 411 if data_uri:
@@ -464,12 +477,14 @@ class PDFRenderer: @@ -464,12 +477,14 @@ class PDFRenderer:
464 477
465 font_path = str(self._get_font_path()) 478 font_path = str(self._get_font_path())
466 wc = WordCloud( 479 wc = WordCloud(
467 - width=900,  
468 - height=520, 480 + width=1000,
  481 + height=360,
469 background_color="white", 482 background_color="white",
470 font_path=font_path, 483 font_path=font_path,
471 - prefer_horizontal=0.9, 484 + prefer_horizontal=0.98,
472 random_state=42, 485 random_state=42,
  486 + max_words=180,
  487 + collocations=False,
473 ) 488 )
474 wc.generate_from_frequencies(frequencies) 489 wc.generate_from_frequencies(frequencies)
475 490