301 lines
9.9 KiB
Python
301 lines
9.9 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
H5 到小程序样式对比检查工具
|
||
|
||
用法:
|
||
python check_h5_to_mp.py --h5 h5.html --wxss mp.wxss --output report.md
|
||
"""
|
||
|
||
import re
|
||
import argparse
|
||
from pathlib import Path
|
||
from typing import Dict, List, Set
|
||
from dataclasses import dataclass
|
||
from enum import Enum
|
||
from datetime import datetime
|
||
|
||
|
||
class IssueLevel(Enum):
|
||
"""问题级别"""
|
||
CRITICAL = "❌ 严重" # 差异 > 20%
|
||
WARNING = "⚠️ 警告" # 差异 10-20%
|
||
INFO = "ℹ️ 提示" # 差异 < 10%
|
||
OK = "✅ 正确" # 差异 < 2%
|
||
|
||
|
||
@dataclass
|
||
class StyleIssue:
|
||
"""样式问题"""
|
||
level: IssueLevel
|
||
category: str
|
||
tailwind_class: str
|
||
property_name: str
|
||
h5_value: str
|
||
expected_rpx: str
|
||
actual_rpx: str
|
||
difference: float
|
||
location: str
|
||
|
||
|
||
# Tailwind CSS v3 配置
|
||
TAILWIND_CONFIG = {
|
||
'text': {
|
||
'text-xs': {'font-size': '12px', 'line-height': '16px'},
|
||
'text-sm': {'font-size': '14px', 'line-height': '20px'},
|
||
'text-base': {'font-size': '16px', 'line-height': '24px'},
|
||
'text-lg': {'font-size': '18px', 'line-height': '28px'},
|
||
'text-xl': {'font-size': '20px', 'line-height': '28px'},
|
||
'text-2xl': {'font-size': '24px', 'line-height': '32px'},
|
||
},
|
||
'spacing': {
|
||
'p-2': '8px', 'p-3': '12px', 'p-4': '16px',
|
||
'm-2': '8px', 'm-3': '12px', 'm-4': '16px',
|
||
'gap-2': '8px', 'gap-3': '12px',
|
||
'ml-11': '44px', 'mb-2': '8px', 'mb-3': '12px',
|
||
'pt-2': '8px', 'pt-3': '12px',
|
||
},
|
||
}
|
||
|
||
PX_TO_RPX = 1.8204 # 412px 设备
|
||
|
||
|
||
def px_to_rpx(px_value: str) -> float:
|
||
"""将 px 值转换为 rpx"""
|
||
if not px_value or px_value == '0':
|
||
return 0
|
||
match = re.search(r'([\d.]+)', px_value)
|
||
if match:
|
||
return round(float(match.group(1)) * PX_TO_RPX, 2)
|
||
return 0
|
||
|
||
|
||
def rpx_to_px(rpx_value: str) -> float:
|
||
"""将 rpx 值转换为 px"""
|
||
if not rpx_value or rpx_value == '0':
|
||
return 0
|
||
match = re.search(r'([\d.]+)', rpx_value)
|
||
if match:
|
||
return round(float(match.group(1)) / PX_TO_RPX, 2)
|
||
return 0
|
||
|
||
|
||
def extract_tailwind_classes(html_content: str) -> Dict[str, Set[str]]:
|
||
"""提取 HTML 中所有 Tailwind 类"""
|
||
classes = {'text': set(), 'spacing': set(), 'custom': set()}
|
||
|
||
class_attrs = re.findall(r'class="([^"]+)"', html_content)
|
||
for class_str in class_attrs:
|
||
for cls in class_str.split():
|
||
if cls.startswith('text-'):
|
||
if '[' in cls:
|
||
classes['custom'].add(cls)
|
||
else:
|
||
classes['text'].add(cls)
|
||
elif re.match(r'^[pm][tblrxy]?-', cls) or cls.startswith('gap-'):
|
||
classes['spacing'].add(cls)
|
||
|
||
return classes
|
||
|
||
|
||
def extract_wxss_styles(wxss_content: str) -> Dict[str, Dict[str, str]]:
|
||
"""提取 WXSS 中所有样式"""
|
||
styles = {}
|
||
pattern = r'\.([a-zA-Z0-9_-]+(?:--[a-zA-Z0-9_-]+)?)\s*\{([^}]+)\}'
|
||
matches = re.findall(pattern, wxss_content, re.MULTILINE)
|
||
|
||
for class_name, rules in matches:
|
||
styles[class_name] = {}
|
||
for rule in rules.split(';'):
|
||
rule = rule.strip()
|
||
if ':' in rule:
|
||
prop, value = rule.split(':', 1)
|
||
value = re.sub(r'/\*.*?\*/', '', value).strip()
|
||
styles[class_name][prop.strip()] = value
|
||
|
||
return styles
|
||
|
||
|
||
def check_global_styles(wxss_content: str) -> List[StyleIssue]:
|
||
"""检查全局样式"""
|
||
issues = []
|
||
|
||
page_match = re.search(r'page\s*\{([^}]+)\}', wxss_content)
|
||
if page_match and 'line-height:' in page_match.group(1):
|
||
lh_match = re.search(r'line-height:\s*([\d.]+)', page_match.group(1))
|
||
if lh_match:
|
||
issues.append(StyleIssue(
|
||
level=IssueLevel.CRITICAL,
|
||
category='global',
|
||
tailwind_class='page',
|
||
property_name='line-height',
|
||
h5_value='无全局设置',
|
||
expected_rpx='无',
|
||
actual_rpx=lh_match.group(1),
|
||
difference=100.0,
|
||
location='page 全局样式'
|
||
))
|
||
|
||
return issues
|
||
|
||
|
||
def check_text_classes(tailwind_classes: Set[str], wxss_styles: Dict) -> List[StyleIssue]:
|
||
"""检查文本类"""
|
||
issues = []
|
||
|
||
for tw_class in tailwind_classes:
|
||
if tw_class not in TAILWIND_CONFIG['text']:
|
||
continue
|
||
|
||
config = TAILWIND_CONFIG['text'][tw_class]
|
||
h5_font_size = config['font-size']
|
||
h5_line_height = config['line-height']
|
||
expected_fs_rpx = px_to_rpx(h5_font_size)
|
||
expected_lh_rpx = px_to_rpx(h5_line_height)
|
||
|
||
for class_name, props in wxss_styles.items():
|
||
if 'font-size' not in props:
|
||
continue
|
||
|
||
actual_fs = props['font-size']
|
||
actual_px = rpx_to_px(actual_fs)
|
||
expected_px = float(h5_font_size.replace('px', ''))
|
||
|
||
if abs(actual_px - expected_px) < 2:
|
||
# 检查 line-height
|
||
if 'line-height' not in props:
|
||
issues.append(StyleIssue(
|
||
level=IssueLevel.CRITICAL,
|
||
category='text',
|
||
tailwind_class=tw_class,
|
||
property_name='line-height',
|
||
h5_value=h5_line_height,
|
||
expected_rpx=f'{expected_lh_rpx}rpx',
|
||
actual_rpx='缺失',
|
||
difference=100.0,
|
||
location=f'.{class_name}'
|
||
))
|
||
else:
|
||
actual_lh = props['line-height']
|
||
actual_lh_px = rpx_to_px(actual_lh)
|
||
expected_lh_px = float(h5_line_height.replace('px', ''))
|
||
diff = abs(actual_lh_px - expected_lh_px) / expected_lh_px * 100
|
||
|
||
if diff > 2:
|
||
level = IssueLevel.CRITICAL if diff > 20 else (
|
||
IssueLevel.WARNING if diff > 10 else IssueLevel.INFO)
|
||
issues.append(StyleIssue(
|
||
level=level,
|
||
category='text',
|
||
tailwind_class=tw_class,
|
||
property_name='line-height',
|
||
h5_value=h5_line_height,
|
||
expected_rpx=f'{expected_lh_rpx}rpx',
|
||
actual_rpx=actual_lh,
|
||
difference=diff,
|
||
location=f'.{class_name}'
|
||
))
|
||
|
||
return issues
|
||
|
||
|
||
def generate_report(issues: List[StyleIssue], output_file: Path):
|
||
"""生成 Markdown 报告"""
|
||
critical = [i for i in issues if i.level == IssueLevel.CRITICAL]
|
||
warning = [i for i in issues if i.level == IssueLevel.WARNING]
|
||
info = [i for i in issues if i.level == IssueLevel.INFO]
|
||
|
||
report = f"""# H5 到小程序样式对比报告
|
||
|
||
> 生成时间:{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
|
||
|
||
## 📊 问题统计
|
||
|
||
- ❌ 严重问题:{len(critical)} 个
|
||
- ⚠️ 警告:{len(warning)} 个
|
||
- ℹ️ 提示:{len(info)} 个
|
||
- **总计**:{len(issues)} 个
|
||
|
||
---
|
||
|
||
## ❌ 严重问题({len(critical)} 个)
|
||
|
||
"""
|
||
|
||
for issue in critical:
|
||
report += f"""### {issue.level.value} {issue.tailwind_class} - {issue.property_name}
|
||
|
||
- **位置**:{issue.location}
|
||
- **H5 值**:{issue.h5_value}
|
||
- **应该是**:{issue.expected_rpx}
|
||
- **实际是**:{issue.actual_rpx}
|
||
- **差异**:{issue.difference:.1f}%
|
||
|
||
"""
|
||
|
||
if warning:
|
||
report += f"\n## ⚠️ 警告({len(warning)} 个)\n\n"
|
||
for issue in warning:
|
||
report += f"- {issue.tailwind_class} - {issue.property_name}: 差异 {issue.difference:.1f}% ({issue.location})\n"
|
||
|
||
if info:
|
||
report += f"\n## ℹ️ 提示({len(info)} 个)\n\n"
|
||
for issue in info:
|
||
report += f"- {issue.tailwind_class} - {issue.property_name}: 差异 {issue.difference:.1f}% ({issue.location})\n"
|
||
|
||
output_file.write_text(report, encoding='utf-8')
|
||
|
||
|
||
def main():
|
||
import sys
|
||
import io
|
||
|
||
# 修复 Windows 控制台编码问题
|
||
if sys.platform == 'win32':
|
||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
|
||
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')
|
||
|
||
parser = argparse.ArgumentParser(description='H5 到小程序样式对比检查工具')
|
||
parser.add_argument('--h5', required=True, help='H5 HTML 文件路径')
|
||
parser.add_argument('--wxss', required=True, help='小程序 WXSS 文件路径')
|
||
parser.add_argument('--output', default='report.md', help='输出报告路径')
|
||
args = parser.parse_args()
|
||
|
||
h5_file = Path(args.h5)
|
||
wxss_file = Path(args.wxss)
|
||
output_file = Path(args.output)
|
||
|
||
if not h5_file.exists():
|
||
print(f"[ERROR] H5 文件不存在:{h5_file}")
|
||
return
|
||
if not wxss_file.exists():
|
||
print(f"[ERROR] WXSS 文件不存在:{wxss_file}")
|
||
return
|
||
|
||
print("[1/6] 读取文件...")
|
||
h5_content = h5_file.read_text(encoding='utf-8')
|
||
wxss_content = wxss_file.read_text(encoding='utf-8')
|
||
|
||
print("[2/6] 提取 Tailwind 类...")
|
||
tailwind_classes = extract_tailwind_classes(h5_content)
|
||
print(f" - text 类:{len(tailwind_classes['text'])} 个")
|
||
|
||
print("[3/6] 提取 WXSS 样式...")
|
||
wxss_styles = extract_wxss_styles(wxss_content)
|
||
print(f" - 样式类:{len(wxss_styles)} 个")
|
||
|
||
print("[4/6] 检查全局样式...")
|
||
issues = check_global_styles(wxss_content)
|
||
|
||
print("[5/6] 检查文本类...")
|
||
issues.extend(check_text_classes(tailwind_classes['text'], wxss_styles))
|
||
|
||
print(f"\n[统计] 发现 {len(issues)} 个问题")
|
||
print("[6/6] 生成报告...")
|
||
generate_report(issues, output_file)
|
||
print(f"[完成] 报告已生成:{output_file}")
|
||
|
||
|
||
if __name__ == '__main__':
|
||
main()
|