4 Commits

Author SHA1 Message Date
991a89147e 🎨 Optimize code 2025-11-17 23:14:58 +08:00
a08c0d8448 🎨 Modify code block logic 2025-11-17 22:11:16 +08:00
59db8dd177 Added Monocraft font 2025-11-16 22:04:02 +08:00
29693f1baf 💄 Modify some styles 2025-11-16 21:23:59 +08:00
71 changed files with 1168 additions and 540 deletions

View File

@@ -1,254 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
鸿蒙字体压缩工具
使用 fonttools 库压缩 TTF 字体文件,减小文件大小
"""
import os
import sys
import subprocess
import shutil
from pathlib import Path
from typing import List, Tuple
def check_dependencies():
"""检查必要的依赖是否已安装"""
missing_packages = []
# 检查 fonttools
try:
import fontTools
except ImportError:
missing_packages.append('fonttools')
# 检查 brotli
try:
import brotli
except ImportError:
missing_packages.append('brotli')
# 检查 pyftsubset 命令是否可用
try:
result = subprocess.run(['pyftsubset', '--help'], capture_output=True, text=True)
if result.returncode != 0:
missing_packages.append('fonttools[subset]')
except FileNotFoundError:
if 'fonttools' not in missing_packages:
missing_packages.append('fonttools[subset]')
if missing_packages:
print(f"缺少必要的依赖包: {', '.join(missing_packages)}")
print("请运行以下命令安装:")
print(f"pip install {' '.join(missing_packages)}")
return False
return True
def get_file_size(file_path: str) -> int:
"""获取文件大小(字节)"""
return os.path.getsize(file_path)
def format_file_size(size_bytes: int) -> str:
"""格式化文件大小显示"""
if size_bytes < 1024:
return f"{size_bytes} B"
elif size_bytes < 1024 * 1024:
return f"{size_bytes / 1024:.2f} KB"
else:
return f"{size_bytes / (1024 * 1024):.2f} MB"
def compress_font(input_path: str, output_path: str, compression_level: str = "basic") -> bool:
"""
压缩单个字体文件
Args:
input_path: 输入字体文件路径
output_path: 输出字体文件路径
compression_level: 压缩级别 ("basic", "medium", "aggressive")
Returns:
bool: 压缩是否成功
"""
try:
# 基础压缩参数
base_args = [
"pyftsubset", input_path,
"--output-file=" + output_path,
"--flavor=woff2", # 输出为 WOFF2 格式,压缩率更高
"--with-zopfli", # 使用 Zopfli 算法进一步压缩
]
# 根据压缩级别设置不同的参数
if compression_level == "basic":
# 基础压缩:保留常用字符和功能
args = base_args + [
"--unicodes=U+0020-007F,U+00A0-00FF,U+2000-206F,U+2070-209F,U+20A0-20CF", # 基本拉丁字符、标点符号等
"--layout-features=*", # 保留所有布局特性
"--glyph-names", # 保留字形名称
"--symbol-cmap", # 保留符号映射
"--legacy-cmap", # 保留传统字符映射
"--notdef-glyph", # 保留 .notdef 字形
"--recommended-glyphs", # 保留推荐字形
"--name-IDs=*", # 保留所有名称ID
"--name-legacy", # 保留传统名称
]
elif compression_level == "medium":
# 中等压缩:移除一些不常用的功能
args = base_args + [
"--unicodes=U+0020-007F,U+00A0-00FF,U+2000-206F", # 减少字符范围
"--layout-features=kern,liga,clig", # 只保留关键布局特性
"--no-glyph-names", # 移除字形名称
"--notdef-glyph",
"--name-IDs=1,2,3,4,5,6", # 只保留基本名称ID
]
else: # aggressive
# 激进压缩:最大程度减小文件大小
args = base_args + [
"--unicodes=U+0020-007F", # 只保留基本ASCII字符
"--no-layout-features", # 移除所有布局特性
"--no-glyph-names", # 移除字形名称
"--no-symbol-cmap", # 移除符号映射
"--no-legacy-cmap", # 移除传统映射
"--notdef-glyph",
"--name-IDs=1,2", # 只保留最基本的名称
"--desubroutinize", # 去子程序化可能减小CFF字体大小
]
# 执行压缩命令
result = subprocess.run(args, capture_output=True, text=True)
if result.returncode == 0:
return True
else:
print(f"压缩失败: {result.stderr}")
return False
except Exception as e:
print(f"压缩过程中出现错误: {str(e)}")
return False
def find_font_files(directory: str) -> List[str]:
"""查找目录中的所有字体文件"""
font_extensions = ['.ttf', '.otf', '.woff', '.woff2']
font_files = []
for root, dirs, files in os.walk(directory):
for file in files:
if any(file.lower().endswith(ext) for ext in font_extensions):
font_files.append(os.path.join(root, file))
return font_files
def compress_fonts_batch(font_directory: str, compression_level: str = "basic"):
"""
批量压缩字体文件
Args:
font_directory: 字体文件目录
compression_level: 压缩级别
"""
if not os.path.exists(font_directory):
print(f"错误: 目录 {font_directory} 不存在")
return
# 查找所有字体文件
font_files = find_font_files(font_directory)
if not font_files:
print("未找到字体文件")
return
print(f"找到 {len(font_files)} 个字体文件")
print(f"压缩级别: {compression_level}")
print(f"压缩后的文件将与源文件放在同一目录,扩展名为 .woff2")
print("-" * 60)
total_original_size = 0
total_compressed_size = 0
successful_compressions = 0
for i, font_file in enumerate(font_files, 1):
print(f"[{i}/{len(font_files)}] 处理: {os.path.basename(font_file)}")
# 获取原始文件大小
original_size = get_file_size(font_file)
total_original_size += original_size
# 生成输出文件名(保持原文件名,只改变扩展名)
file_dir = os.path.dirname(font_file)
base_name = os.path.splitext(os.path.basename(font_file))[0]
output_file = os.path.join(file_dir, f"{base_name}.woff2")
# 压缩字体
if compress_font(font_file, output_file, compression_level):
if os.path.exists(output_file):
compressed_size = get_file_size(output_file)
total_compressed_size += compressed_size
successful_compressions += 1
# 计算压缩率
compression_ratio = (1 - compressed_size / original_size) * 100
print(f" ✓ 成功: {format_file_size(original_size)}{format_file_size(compressed_size)} "
f"(压缩 {compression_ratio:.1f}%)")
else:
print(f" ✗ 失败: 输出文件未生成")
else:
print(f" ✗ 失败: 压缩过程出错")
print()
# 显示总结
print("=" * 60)
print("压缩完成!")
print(f"成功压缩: {successful_compressions}/{len(font_files)} 个文件")
if successful_compressions > 0:
total_compression_ratio = (1 - total_compressed_size / total_original_size) * 100
print(f"总大小: {format_file_size(total_original_size)}{format_file_size(total_compressed_size)}")
print(f"总压缩率: {total_compression_ratio:.1f}%")
print(f"节省空间: {format_file_size(total_original_size - total_compressed_size)}")
def main():
"""主函数"""
print("鸿蒙字体压缩工具")
print("=" * 60)
# 检查依赖
if not check_dependencies():
return
# 获取当前脚本所在目录
current_dir = os.path.dirname(os.path.abspath(__file__))
# 设置默认字体目录
font_directory = current_dir
print(f"字体目录: {font_directory}")
# 让用户选择压缩级别
print("\n请选择压缩级别:")
print("1. 基础压缩 (保留大部分功能,适合网页使用)")
print("2. 中等压缩 (平衡文件大小和功能)")
print("3. 激进压缩 (最小文件大小,可能影响显示效果)")
while True:
choice = input("\n请输入选择 (1-3): ").strip()
if choice == "1":
compression_level = "basic"
break
elif choice == "2":
compression_level = "medium"
break
elif choice == "3":
compression_level = "aggressive"
break
else:
print("无效选择,请输入 1、2 或 3")
# 开始批量压缩
compress_fonts_batch(font_directory, compression_level=compression_level)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,179 @@
# 字体压缩工具使用指南
## 📖 简介
`font_compressor.py` 是一个通用的字体压缩工具,可以:
- ✅ 将 TTF、OTF、WOFF 字体文件转换为 WOFF2 格式
- ✅ 支持相对路径和绝对路径
- ✅ 自动生成 CSS 字体定义文件
- ✅ 智能识别字体字重和样式
- ✅ 批量处理整个目录(包括子目录)
## 🚀 前置要求
安装 Python 依赖包:
```bash
pip install fonttools brotli
```
## 📝 使用方法
### 基础用法
```bash
# 进入 fonts 目录
cd frontend/src/assets/fonts
# 交互式模式处理当前目录
python font_compressor.py
# 处理相对路径的 Monocraft 目录
python font_compressor.py Monocraft
# 处理相对路径并指定压缩级别
python font_compressor.py Monocraft -l basic
```
### 生成 CSS 文件
```bash
# 压缩 Monocraft 字体并生成 CSS 文件
python font_compressor.py Monocraft -l basic -c ../styles/monocraft_fonts.css
# 压缩 Hack 字体并生成 CSS
python font_compressor.py Hack -l basic -c ../styles/hack_fonts_new.css
# 压缩 OpenSans 字体并生成 CSS
python font_compressor.py OpenSans -l medium -c ../styles/opensans_fonts.css
```
### 高级用法
```bash
# 使用绝对路径
python font_compressor.py E:\Go_WorkSpace\voidraft\frontend\src\assets\fonts\Monocraft -l basic -c monocraft.css
# 不同压缩级别
python font_compressor.py Monocraft -l basic # 基础压缩,保留所有功能
python font_compressor.py Monocraft -l medium # 中等压缩,平衡大小和功能
python font_compressor.py Monocraft -l aggressive # 激进压缩,最小文件
```
## ⚙️ 命令行参数
| 参数 | 说明 | 示例 |
|------|------|------|
| `directory` | 字体目录(相对/绝对路径) | `Monocraft``/path/to/fonts` |
| `-l, --level` | 压缩级别 (basic/medium/aggressive) | `-l basic` |
| `-c, --css` | CSS 输出文件路径 | `-c monocraft.css` |
| `--version` | 显示版本信息 | `--version` |
| `-h, --help` | 显示帮助信息 | `-h` |
## 📊 压缩级别说明
### basic基础 - 推荐
- 保留大部分字体功能
- 适合网页使用
- 压缩率约 30-40%
### medium中等
- 移除一些不常用的功能
- 平衡文件大小和功能
- 压缩率约 40-50%
### aggressive激进
- 最大程度减小文件大小
- 可能影响高级排版功能
- 压缩率约 50-60%
## 📁 输出结果
### 字体文件
压缩后的 `.woff2` 文件会保存在原文件相同的目录下,例如:
- `Monocraft/ttf/Monocraft-Bold.ttf``Monocraft/ttf/Monocraft-Bold.woff2`
- `Hack/hack-regular.ttf``Hack/hack-regular.woff2`
### CSS 文件
生成的 CSS 文件会包含:
- 自动识别的字体家族名称
- 正确的字重和样式设置
- 使用相对路径的字体引用
- 按字重排序的 `@font-face` 定义
生成的 CSS 示例:
```css
/* 自动生成的字体文件 */
/* 由 font_compressor.py 生成 */
/* Monocraft 字体家族 */
/* Monocraft Light */
@font-face {
font-family: 'Monocraft';
src: url('../fonts/Monocraft/ttf/Monocraft-Light.woff2') format('woff2');
font-weight: 300;
font-style: normal;
font-display: swap;
}
/* Monocraft Bold */
@font-face {
font-family: 'Monocraft';
src: url('../fonts/Monocraft/ttf/Monocraft-Bold.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: swap;
}
```
## 🎯 实际使用示例
### 示例 1: 压缩 Monocraft 字体
```bash
cd frontend/src/assets/fonts
python font_compressor.py Monocraft -l basic -c ../styles/monocraft_fonts.css
```
这将:
1. 扫描 `Monocraft/ttf``Monocraft/otf` 目录
2. 将所有字体文件转换为 WOFF2
3.`frontend/src/assets/styles/monocraft_fonts.css` 生成 CSS 文件
### 示例 2: 批量处理多个字体目录
```bash
cd frontend/src/assets/fonts
# 压缩 Monocraft
python font_compressor.py Monocraft -l basic -c ../styles/monocraft_fonts.css
# 压缩 OpenSans
python font_compressor.py OpenSans -l basic -c ../styles/opensans_fonts.css
# 压缩 Hack已有 CSS只需生成新版本对比
python font_compressor.py Hack -l basic -c ../styles/hack_fonts_new.css
```
## 🔍 字体信息自动识别
工具会自动从文件名识别:
- **字重**Thin(100), Light(300), Regular(400), Medium(500), SemiBold(600), Bold(700), Black(900)
- **样式**normal, italic
- **字体家族**:自动去除字重和样式后缀
支持的命名格式:
- `FontName-Bold.ttf`
- `FontName_Bold.otf`
- `FontName-BoldItalic.ttf`
- `FontName_SemiBold_Italic.woff`
## 📞 获取帮助
```bash
python font_compressor.py --help
```

View File

@@ -0,0 +1,494 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
通用字体压缩工具
使用 fonttools 库将字体文件转换为 WOFF2 格式,减小文件大小
支持 TTF、OTF、WOFF 等格式的字体文件
"""
import os
import sys
import subprocess
import shutil
import argparse
import re
from pathlib import Path
from typing import List, Tuple, Dict, Optional
def check_dependencies():
"""检查必要的依赖是否已安装"""
missing_packages = []
# 检查 fonttools
try:
import fontTools
except ImportError:
missing_packages.append('fonttools')
# 检查 brotli
try:
import brotli
except ImportError:
missing_packages.append('brotli')
# 检查 pyftsubset 命令是否可用
try:
result = subprocess.run(['pyftsubset', '--help'], capture_output=True, text=True)
if result.returncode != 0:
missing_packages.append('fonttools[subset]')
except FileNotFoundError:
if 'fonttools' not in missing_packages:
missing_packages.append('fonttools[subset]')
if missing_packages:
print(f"缺少必要的依赖包: {', '.join(missing_packages)}")
print("请运行以下命令安装:")
print(f"pip install {' '.join(missing_packages)}")
return False
return True
def get_file_size(file_path: str) -> int:
"""获取文件大小(字节)"""
return os.path.getsize(file_path)
def format_file_size(size_bytes: int) -> str:
"""格式化文件大小显示"""
if size_bytes < 1024:
return f"{size_bytes} B"
elif size_bytes < 1024 * 1024:
return f"{size_bytes / 1024:.2f} KB"
else:
return f"{size_bytes / (1024 * 1024):.2f} MB"
def compress_font(input_path: str, output_path: str, compression_level: str = "basic") -> bool:
"""
压缩单个字体文件
Args:
input_path: 输入字体文件路径
output_path: 输出字体文件路径
compression_level: 压缩级别 ("basic", "medium", "aggressive")
Returns:
bool: 压缩是否成功
"""
try:
# 基础压缩参数
base_args = [
"pyftsubset", input_path,
"--output-file=" + output_path,
"--flavor=woff2", # 输出为 WOFF2 格式,压缩率更高
"--with-zopfli", # 使用 Zopfli 算法进一步压缩
]
# 根据压缩级别设置不同的参数
if compression_level == "basic":
# 基础压缩:保留常用字符和功能
args = base_args + [
"--unicodes=U+0020-007F,U+00A0-00FF,U+2000-206F,U+2070-209F,U+20A0-20CF", # 基本拉丁字符、标点符号等
"--layout-features=*", # 保留所有布局特性
"--glyph-names", # 保留字形名称
"--symbol-cmap", # 保留符号映射
"--legacy-cmap", # 保留传统字符映射
"--notdef-glyph", # 保留 .notdef 字形
"--recommended-glyphs", # 保留推荐字形
"--name-IDs=*", # 保留所有名称ID
"--name-legacy", # 保留传统名称
]
elif compression_level == "medium":
# 中等压缩:移除一些不常用的功能
args = base_args + [
"--unicodes=U+0020-007F,U+00A0-00FF,U+2000-206F", # 减少字符范围
"--layout-features=kern,liga,clig", # 只保留关键布局特性
"--no-glyph-names", # 移除字形名称
"--notdef-glyph",
"--name-IDs=1,2,3,4,5,6", # 只保留基本名称ID
]
else: # aggressive
# 激进压缩:最大程度减小文件大小
args = base_args + [
"--unicodes=U+0020-007F", # 只保留基本ASCII字符
"--no-layout-features", # 移除所有布局特性
"--no-glyph-names", # 移除字形名称
"--no-symbol-cmap", # 移除符号映射
"--no-legacy-cmap", # 移除传统映射
"--notdef-glyph",
"--name-IDs=1,2", # 只保留最基本的名称
"--desubroutinize", # 去子程序化可能减小CFF字体大小
]
# 执行压缩命令
result = subprocess.run(args, capture_output=True, text=True)
if result.returncode == 0:
return True
else:
print(f"压缩失败: {result.stderr}")
return False
except Exception as e:
print(f"压缩过程中出现错误: {str(e)}")
return False
def find_font_files(directory: str, exclude_woff2: bool = False) -> List[str]:
"""查找目录中的所有字体文件"""
if exclude_woff2:
font_extensions = ['.ttf', '.otf', '.woff']
else:
font_extensions = ['.ttf', '.otf', '.woff', '.woff2']
font_files = []
for root, dirs, files in os.walk(directory):
for file in files:
if any(file.lower().endswith(ext) for ext in font_extensions):
font_files.append(os.path.join(root, file))
return font_files
def parse_font_info(filename: str) -> Dict[str, any]:
"""
从字体文件名解析字体信息(字重、样式等)
Args:
filename: 字体文件名(不含路径)
Returns:
包含字体信息的字典
"""
# 移除扩展名
name_without_ext = os.path.splitext(filename)[0]
# 字重映射
weight_mapping = {
'thin': (100, 'Thin'),
'extralight': (200, 'ExtraLight'),
'light': (300, 'Light'),
'regular': (400, 'Regular'),
'normal': (400, 'Regular'),
'medium': (500, 'Medium'),
'semibold': (600, 'SemiBold'),
'bold': (700, 'Bold'),
'extrabold': (800, 'ExtraBold'),
'black': (900, 'Black'),
'heavy': (900, 'Heavy'),
}
# 默认值
font_weight = 400
font_style = 'normal'
weight_name = 'Regular'
# 检查是否为斜体
if re.search(r'italic', name_without_ext, re.IGNORECASE):
font_style = 'italic'
# 检查字重
name_lower = name_without_ext.lower()
for weight_key, (weight_value, weight_label) in weight_mapping.items():
if weight_key in name_lower:
font_weight = weight_value
weight_name = weight_label
break
# 提取字体家族名称(移除字重和样式后缀)
family_name = name_without_ext
for weight_key, (_, weight_label) in weight_mapping.items():
family_name = re.sub(r'[-_]?' + weight_label, '', family_name, flags=re.IGNORECASE)
family_name = re.sub(r'[-_]?italic', '', family_name, flags=re.IGNORECASE)
family_name = family_name.strip('-_')
return {
'family': family_name,
'weight': font_weight,
'style': font_style,
'weight_name': weight_name,
'full_name': name_without_ext
}
def generate_css(font_files: List[str], output_css_path: str, css_base_path: str):
"""
生成CSS字体文件
Args:
font_files: 字体文件路径列表woff2文件
output_css_path: 输出CSS文件路径
css_base_path: CSS文件相对于字体文件的基础路径
"""
# 按字体家族分组
font_groups: Dict[str, List[Dict]] = {}
for font_file in font_files:
if not font_file.endswith('.woff2'):
continue
filename = os.path.basename(font_file)
font_info = parse_font_info(filename)
# 计算相对路径
font_dir = os.path.dirname(font_file)
css_dir = os.path.dirname(output_css_path)
try:
# 计算从CSS文件到字体文件的相对路径
rel_path = os.path.relpath(font_file, css_dir)
# 统一使用正斜杠适用于Web
rel_path = rel_path.replace('\\', '/')
except ValueError:
# 如果在不同驱动器上,使用绝对路径
rel_path = font_file.replace('\\', '/')
font_info['path'] = rel_path
family = font_info['family']
if family not in font_groups:
font_groups[family] = []
font_groups[family].append(font_info)
# 生成CSS内容
css_lines = ['/* 自动生成的字体文件 */', '/* 由 font_compressor.py 生成 */', '']
for family, fonts in sorted(font_groups.items()):
css_lines.append(f'/* {family} 字体家族 */')
css_lines.append('')
# 按字重排序
fonts.sort(key=lambda x: (x['weight'], x['style']))
for font in fonts:
css_lines.append(f"/* {family} {font['weight_name']}{' Italic' if font['style'] == 'italic' else ''} */")
css_lines.append('@font-face {')
css_lines.append(f" font-family: '{family}';")
css_lines.append(f" src: url('{font['path']}') format('woff2');")
css_lines.append(f" font-weight: {font['weight']};")
css_lines.append(f" font-style: {font['style']};")
css_lines.append(' font-display: swap;')
css_lines.append('}')
css_lines.append('')
# 写入CSS文件
with open(output_css_path, 'w', encoding='utf-8') as f:
f.write('\n'.join(css_lines))
print(f"[OK] CSS文件已生成: {output_css_path}")
print(f" 包含 {sum(len(fonts) for fonts in font_groups.values())} 个字体定义")
print(f" 字体家族: {', '.join(sorted(font_groups.keys()))}")
def compress_fonts_batch(font_directory: str, compression_level: str = "basic") -> List[str]:
"""
批量压缩字体文件
Args:
font_directory: 字体文件目录
compression_level: 压缩级别
Returns:
生成的woff2文件路径列表
"""
if not os.path.exists(font_directory):
print(f"错误: 目录 {font_directory} 不存在")
return []
# 查找所有字体文件排除已经是woff2的
font_files = find_font_files(font_directory, exclude_woff2=True)
if not font_files:
print("未找到字体文件")
return []
print(f"找到 {len(font_files)} 个字体文件")
print(f"压缩级别: {compression_level}")
print(f"压缩后的文件将与源文件放在同一目录,扩展名为 .woff2")
print("-" * 60)
total_original_size = 0
total_compressed_size = 0
successful_compressions = 0
generated_woff2_files = []
for i, font_file in enumerate(font_files, 1):
print(f"[{i}/{len(font_files)}] 处理: {os.path.basename(font_file)}")
# 获取原始文件大小
original_size = get_file_size(font_file)
total_original_size += original_size
# 生成输出文件名(保持原文件名,只改变扩展名)
file_dir = os.path.dirname(font_file)
base_name = os.path.splitext(os.path.basename(font_file))[0]
output_file = os.path.join(file_dir, f"{base_name}.woff2")
# 压缩字体
if compress_font(font_file, output_file, compression_level):
if os.path.exists(output_file):
compressed_size = get_file_size(output_file)
total_compressed_size += compressed_size
successful_compressions += 1
generated_woff2_files.append(output_file)
# 计算压缩率
compression_ratio = (1 - compressed_size / original_size) * 100
print(f" [OK] 成功: {format_file_size(original_size)} -> {format_file_size(compressed_size)} "
f"(压缩 {compression_ratio:.1f}%)")
else:
print(f" [失败] 输出文件未生成")
else:
print(f" [失败] 压缩过程出错")
print()
# 显示总结
print("=" * 60)
print("压缩完成!")
print(f"成功压缩: {successful_compressions}/{len(font_files)} 个文件")
if successful_compressions > 0:
total_compression_ratio = (1 - total_compressed_size / total_original_size) * 100
print(f"总大小: {format_file_size(total_original_size)}{format_file_size(total_compressed_size)}")
print(f"总压缩率: {total_compression_ratio:.1f}%")
print(f"节省空间: {format_file_size(total_original_size - total_compressed_size)}")
return generated_woff2_files
def main():
"""主函数"""
# 解析命令行参数
parser = argparse.ArgumentParser(
description='通用字体压缩工具 - 将字体文件转换为 WOFF2 格式并生成CSS',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog='''
使用示例:
%(prog)s # 交互式模式,处理当前目录
%(prog)s Monocraft # 处理相对路径目录
%(prog)s Monocraft -l basic # 使用基础压缩级别
%(prog)s Monocraft -l basic -c monocraft.css # 压缩并生成CSS文件
%(prog)s /path/to/fonts -l medium -c fonts.css # 使用绝对路径
压缩级别说明:
basic - 基础压缩:保留大部分功能,适合网页使用
medium - 中等压缩:平衡文件大小和功能
aggressive - 激进压缩:最小文件大小,可能影响显示效果
CSS生成说明:
使用 -c/--css 选项生成CSS文件自动使用相对路径引用字体文件
'''
)
parser.add_argument(
'directory',
nargs='?',
default=None,
help='字体文件目录路径(支持相对/绝对路径,默认为当前脚本所在目录)'
)
parser.add_argument(
'-l', '--level',
choices=['basic', 'medium', 'aggressive'],
default=None,
help='压缩级别basic基础、medium中等、aggressive激进'
)
parser.add_argument(
'-c', '--css',
default=None,
help='生成CSS文件路径相对于脚本位置或绝对路径'
)
parser.add_argument(
'--version',
action='version',
version='%(prog)s 2.0'
)
args = parser.parse_args()
print("=" * 60)
print("通用字体压缩工具 v2.0")
print("=" * 60)
# 检查依赖
if not check_dependencies():
return
# 获取脚本所在目录
script_dir = os.path.dirname(os.path.abspath(__file__))
# 确定字体目录
if args.directory:
# 支持相对路径和绝对路径
if os.path.isabs(args.directory):
font_directory = args.directory
else:
font_directory = os.path.join(script_dir, args.directory)
font_directory = os.path.abspath(font_directory)
else:
# 默认使用当前脚本所在目录
font_directory = script_dir
# 检查目录是否存在
if not os.path.exists(font_directory):
print(f"\n错误: 目录不存在: {font_directory}")
sys.exit(1)
print(f"\n字体目录: {font_directory}")
# 确定压缩级别
compression_level = args.level
if compression_level is None:
# 交互式选择压缩级别
print("\n请选择压缩级别:")
print("1. 基础压缩 (保留大部分功能,适合网页使用)")
print("2. 中等压缩 (平衡文件大小和功能)")
print("3. 激进压缩 (最小文件大小,可能影响显示效果)")
while True:
choice = input("\n请输入选择 (1-3): ").strip()
if choice == "1":
compression_level = "basic"
break
elif choice == "2":
compression_level = "medium"
break
elif choice == "3":
compression_level = "aggressive"
break
else:
print("无效选择,请输入 1、2 或 3")
# 开始批量压缩
print()
generated_files = compress_fonts_batch(font_directory, compression_level=compression_level)
# 生成CSS文件
if args.css and generated_files:
print()
print("=" * 60)
print("生成CSS文件...")
print("=" * 60)
# 确定CSS输出路径
if os.path.isabs(args.css):
css_path = args.css
else:
css_path = os.path.join(script_dir, args.css)
css_path = os.path.abspath(css_path)
# 确保输出目录存在
css_dir = os.path.dirname(css_path)
if css_dir and not os.path.exists(css_dir):
os.makedirs(css_dir)
# 生成CSS
generate_css(generated_files, css_path, script_dir)
elif args.css and not generated_files:
print("\n警告: 没有成功生成WOFF2文件跳过CSS生成")
print()
print("=" * 60)
print("全部完成!")
print("=" * 60)
if __name__ == "__main__":
main()

View File

@@ -1,7 +1,8 @@
/* 导入所有CSS文件 */
@import 'normalize.css';
@import 'variables.css';
@import "harmony_fonts.css";
@import 'scrollbar.css';
@import "harmony_fonts.css";
@import 'hack_fonts.css';
@import 'opensans_fonts.css';
@import 'opensans_fonts.css';
@import "monocraft_fonts.css";

View File

@@ -0,0 +1,202 @@
/* 自动生成的字体文件 */
/* 由 font_compressor.py 生成 */
/* Monocraft 字体家族 */
/* Monocraft ExtraLight Italic */
@font-face {
font-family: 'Monocraft';
src: url('../fonts/Monocraft/otf/Monocraft-ExtraLight-Italic.woff2') format('woff2');
font-weight: 200;
font-style: italic;
font-display: swap;
}
/* Monocraft ExtraLight Italic */
@font-face {
font-family: 'Monocraft';
src: url('../fonts/Monocraft/ttf/Monocraft-ExtraLight-Italic.woff2') format('woff2');
font-weight: 200;
font-style: italic;
font-display: swap;
}
/* Monocraft ExtraLight */
@font-face {
font-family: 'Monocraft';
src: url('../fonts/Monocraft/otf/Monocraft-ExtraLight.woff2') format('woff2');
font-weight: 200;
font-style: normal;
font-display: swap;
}
/* Monocraft ExtraLight */
@font-face {
font-family: 'Monocraft';
src: url('../fonts/Monocraft/ttf/Monocraft-ExtraLight.woff2') format('woff2');
font-weight: 200;
font-style: normal;
font-display: swap;
}
/* Monocraft Light Italic */
@font-face {
font-family: 'Monocraft';
src: url('../fonts/Monocraft/otf/Monocraft-Light-Italic.woff2') format('woff2');
font-weight: 300;
font-style: italic;
font-display: swap;
}
/* Monocraft Light Italic */
@font-face {
font-family: 'Monocraft';
src: url('../fonts/Monocraft/ttf/Monocraft-Light-Italic.woff2') format('woff2');
font-weight: 300;
font-style: italic;
font-display: swap;
}
/* Monocraft Light */
@font-face {
font-family: 'Monocraft';
src: url('../fonts/Monocraft/otf/Monocraft-Light.woff2') format('woff2');
font-weight: 300;
font-style: normal;
font-display: swap;
}
/* Monocraft Light */
@font-face {
font-family: 'Monocraft';
src: url('../fonts/Monocraft/ttf/Monocraft-Light.woff2') format('woff2');
font-weight: 300;
font-style: normal;
font-display: swap;
}
/* Monocraft Regular Italic */
@font-face {
font-family: 'Monocraft';
src: url('../fonts/Monocraft/otf/Monocraft-Italic.woff2') format('woff2');
font-weight: 400;
font-style: italic;
font-display: swap;
}
/* Monocraft Regular Italic */
@font-face {
font-family: 'Monocraft';
src: url('../fonts/Monocraft/ttf/Monocraft-Italic.woff2') format('woff2');
font-weight: 400;
font-style: italic;
font-display: swap;
}
/* Monocraft SemiBold Italic */
@font-face {
font-family: 'Monocraft';
src: url('../fonts/Monocraft/otf/Monocraft-SemiBold-Italic.woff2') format('woff2');
font-weight: 600;
font-style: italic;
font-display: swap;
}
/* Monocraft SemiBold Italic */
@font-face {
font-family: 'Monocraft';
src: url('../fonts/Monocraft/ttf/Monocraft-SemiBold-Italic.woff2') format('woff2');
font-weight: 600;
font-style: italic;
font-display: swap;
}
/* Monocraft SemiBold */
@font-face {
font-family: 'Monocraft';
src: url('../fonts/Monocraft/otf/Monocraft-SemiBold.woff2') format('woff2');
font-weight: 600;
font-style: normal;
font-display: swap;
}
/* Monocraft SemiBold */
@font-face {
font-family: 'Monocraft';
src: url('../fonts/Monocraft/ttf/Monocraft-SemiBold.woff2') format('woff2');
font-weight: 600;
font-style: normal;
font-display: swap;
}
/* Monocraft Bold Italic */
@font-face {
font-family: 'Monocraft';
src: url('../fonts/Monocraft/otf/Monocraft-Bold-Italic.woff2') format('woff2');
font-weight: 700;
font-style: italic;
font-display: swap;
}
/* Monocraft Bold Italic */
@font-face {
font-family: 'Monocraft';
src: url('../fonts/Monocraft/ttf/Monocraft-Bold-Italic.woff2') format('woff2');
font-weight: 700;
font-style: italic;
font-display: swap;
}
/* Monocraft Bold */
@font-face {
font-family: 'Monocraft';
src: url('../fonts/Monocraft/otf/Monocraft-Bold.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: swap;
}
/* Monocraft Bold */
@font-face {
font-family: 'Monocraft';
src: url('../fonts/Monocraft/ttf/Monocraft-Bold.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: swap;
}
/* Monocraft Black Italic */
@font-face {
font-family: 'Monocraft';
src: url('../fonts/Monocraft/otf/Monocraft-Black-Italic.woff2') format('woff2');
font-weight: 900;
font-style: italic;
font-display: swap;
}
/* Monocraft Black Italic */
@font-face {
font-family: 'Monocraft';
src: url('../fonts/Monocraft/ttf/Monocraft-Black-Italic.woff2') format('woff2');
font-weight: 900;
font-style: italic;
font-display: swap;
}
/* Monocraft Black */
@font-face {
font-family: 'Monocraft';
src: url('../fonts/Monocraft/otf/Monocraft-Black.woff2') format('woff2');
font-weight: 900;
font-style: normal;
font-display: swap;
}
/* Monocraft Black */
@font-face {
font-family: 'Monocraft';
src: url('../fonts/Monocraft/ttf/Monocraft-Black.woff2') format('woff2');
font-weight: 900;
font-style: normal;
font-display: swap;
}

View File

@@ -13,6 +13,10 @@ export const FONT_OPTIONS = [
label: 'Open Sans',
value: '"Open Sans"'
},
{
label: 'Monocraft',
value: 'Monocraft'
},
// Common system fonts
{
label: 'Arial',
@@ -46,7 +50,7 @@ export const FONT_OPTIONS = [
label: 'System UI',
value: 'system-ui'
},
// Chinese fonts
{
label: 'Microsoft YaHei',
@@ -56,7 +60,7 @@ export const FONT_OPTIONS = [
label: 'PingFang SC',
value: '"PingFang SC"'
},
// Popular programming fonts
{
label: 'JetBrains Mono',

View File

@@ -0,0 +1,56 @@
import { Annotation, Transaction } from "@codemirror/state";
/**
* 统一的 CodeBlock 注解,用于标记内部触发的事务。
*/
export const codeBlockEvent = Annotation.define<string>();
export const LANGUAGE_CHANGE = "codeblock-language-change";
export const ADD_NEW_BLOCK = "codeblock-add-new-block";
export const MOVE_BLOCK = "codeblock-move-block";
export const DELETE_BLOCK = "codeblock-delete-block";
export const CURRENCIES_LOADED = "codeblock-currencies-loaded";
export const CONTENT_EDIT = "codeblock-content-edit";
/**
* 统一管理的 userEvent 常量。
*/
export const USER_EVENTS = {
INPUT: "input",
DELETE: "delete",
MOVE: "move",
SELECT: "select",
DELETE_LINE: "delete.line",
DELETE_CUT: "delete.cut",
INPUT_PASTE: "input.paste",
MOVE_LINE: "move.line",
MOVE_CHARACTER: "move.character",
SELECT_BLOCK_BOUNDARY: "select.block-boundary",
INPUT_REPLACE: "input.replace",
INPUT_REPLACE_ALL: "input.replace.all",
} as const;
/**
* 判断事务列表中是否包含指定注解。
*/
export function transactionsHasAnnotation(
transactions: readonly Transaction[],
annotation: string
) {
return transactions.some(
tr => tr.annotation(codeBlockEvent) === annotation
);
}
/**
* 判断事务列表中是否包含任一注解。
*/
export function transactionsHasAnnotationsAny(
transactions: readonly Transaction[],
annotations: readonly string[]
) {
return transactions.some(tr => {
const value = tr.annotation(codeBlockEvent);
return value ? annotations.includes(value) : false;
});
}

View File

@@ -2,11 +2,12 @@
* Block 命令
*/
import { EditorSelection } from "@codemirror/state";
import { EditorSelection, Transaction } from "@codemirror/state";
import { Command } from "@codemirror/view";
import { blockState, getActiveNoteBlock, getFirstNoteBlock, getLastNoteBlock, getNoteBlockFromPos } from "./state";
import { Block, EditorOptions, DELIMITER_REGEX } from "./types";
import { formatBlockContent } from "./formatCode";
import { codeBlockEvent, LANGUAGE_CHANGE, ADD_NEW_BLOCK, MOVE_BLOCK, DELETE_BLOCK, CURRENCIES_LOADED, USER_EVENTS } from "./annotation";
/**
* 获取块分隔符
@@ -32,7 +33,7 @@ export const insertNewBlockAtCursor = (options: EditorOptions): Command => ({ st
dispatch(state.replaceSelection(delimText), {
scrollIntoView: true,
userEvent: "input",
userEvent: USER_EVENTS.INPUT,
});
return true;
@@ -49,15 +50,16 @@ export const addNewBlockBeforeCurrent = (options: EditorOptions): Command => ({
const delimText = getBlockDelimiter(options.defaultBlockToken, options.defaultBlockAutoDetect);
dispatch(state.update({
dispatch(state.update({
changes: {
from: block.delimiter.from,
insert: delimText,
},
selection: EditorSelection.cursor(block.delimiter.from + delimText.length),
annotations: [codeBlockEvent.of(ADD_NEW_BLOCK)],
}, {
scrollIntoView: true,
userEvent: "input",
userEvent: USER_EVENTS.INPUT,
}));
return true;
@@ -74,15 +76,16 @@ export const addNewBlockAfterCurrent = (options: EditorOptions): Command => ({ s
const delimText = getBlockDelimiter(options.defaultBlockToken, options.defaultBlockAutoDetect);
dispatch(state.update({
dispatch(state.update({
changes: {
from: block.content.to,
insert: delimText,
},
selection: EditorSelection.cursor(block.content.to + delimText.length)
selection: EditorSelection.cursor(block.content.to + delimText.length),
annotations: [codeBlockEvent.of(ADD_NEW_BLOCK)],
}, {
scrollIntoView: true,
userEvent: "input",
userEvent: USER_EVENTS.INPUT,
}));
return true;
@@ -99,15 +102,16 @@ export const addNewBlockBeforeFirst = (options: EditorOptions): Command => ({ st
const delimText = getBlockDelimiter(options.defaultBlockToken, options.defaultBlockAutoDetect);
dispatch(state.update({
dispatch(state.update({
changes: {
from: block.delimiter.from,
insert: delimText,
},
selection: EditorSelection.cursor(delimText.length),
annotations: [codeBlockEvent.of(ADD_NEW_BLOCK)],
}, {
scrollIntoView: true,
userEvent: "input",
userEvent: USER_EVENTS.INPUT,
}));
return true;
@@ -124,15 +128,16 @@ export const addNewBlockAfterLast = (options: EditorOptions): Command => ({ stat
const delimText = getBlockDelimiter(options.defaultBlockToken, options.defaultBlockAutoDetect);
dispatch(state.update({
dispatch(state.update({
changes: {
from: block.content.to,
insert: delimText,
},
selection: EditorSelection.cursor(block.content.to + delimText.length)
selection: EditorSelection.cursor(block.content.to + delimText.length),
annotations: [codeBlockEvent.of(ADD_NEW_BLOCK)],
}, {
scrollIntoView: true,
userEvent: "input",
userEvent: USER_EVENTS.INPUT,
}));
return true;
@@ -143,26 +148,19 @@ export const addNewBlockAfterLast = (options: EditorOptions): Command => ({ stat
*/
export function changeLanguageTo(state: any, dispatch: any, block: Block, language: string, auto: boolean) {
if (state.readOnly) return false;
const currentDelimiter = state.doc.sliceString(block.delimiter.from, block.delimiter.to);
// 重置正则表达式的 lastIndex
DELIMITER_REGEX.lastIndex = 0;
if (currentDelimiter.match(DELIMITER_REGEX)) {
const newDelimiter = `\n∞∞∞${language}${auto ? '-a' : ''}\n`;
dispatch({
changes: {
from: block.delimiter.from,
to: block.delimiter.to,
insert: newDelimiter,
},
});
return true;
} else {
return false;
}
const newDelimiter = `\n∞∞∞${language}${auto ? '-a' : ''}\n`;
dispatch({
changes: {
from: block.delimiter.from,
to: block.delimiter.to,
insert: newDelimiter,
},
annotations: [codeBlockEvent.of(LANGUAGE_CHANGE)],
});
return true;
}
/**
@@ -189,7 +187,7 @@ function updateSel(sel: EditorSelection, by: (range: any) => any): EditorSelecti
}
function setSel(state: any, selection: EditorSelection) {
return state.update({ selection, scrollIntoView: true, userEvent: "select" });
return state.update({ selection, scrollIntoView: true, userEvent: USER_EVENTS.SELECT });
}
function extendSel(state: any, dispatch: any, how: (range: any) => any) {
@@ -303,10 +301,11 @@ export const deleteBlock = (_options: EditorOptions): Command => ({ state, dispa
to: block.range.to,
insert: ""
},
selection: EditorSelection.cursor(newCursorPos)
selection: EditorSelection.cursor(newCursorPos),
annotations: [codeBlockEvent.of(DELETE_BLOCK)]
}, {
scrollIntoView: true,
userEvent: "delete"
userEvent: USER_EVENTS.DELETE
}));
return true;
@@ -366,10 +365,11 @@ function moveCurrentBlock(state: any, dispatch: any, up: boolean) {
dispatch(state.update({
changes,
selection: EditorSelection.cursor(newCursorPos)
selection: EditorSelection.cursor(newCursorPos),
annotations: [codeBlockEvent.of(MOVE_BLOCK)]
}, {
scrollIntoView: true,
userEvent: "move"
userEvent: USER_EVENTS.MOVE
}));
return true;
@@ -380,4 +380,21 @@ function moveCurrentBlock(state: any, dispatch: any, up: boolean) {
*/
export const formatCurrentBlock: Command = (view) => {
return formatBlockContent(view);
};
};
/**
* 触发一次货币数据刷新,让数学块重新计算
*/
export function triggerCurrenciesLoaded({ state, dispatch }: { state: any; dispatch: any }) {
if (!dispatch || state.readOnly) {
return false;
}
dispatch(state.update({
changes: { from: 0, to: 0, insert: "" },
annotations: [
codeBlockEvent.of(CURRENCIES_LOADED),
Transaction.addToHistory.of(false)
],
}));
return true;
}

View File

@@ -7,6 +7,7 @@ import { EditorState, EditorSelection } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { Command } from "@codemirror/view";
import { LANGUAGES } from "./lang-parser/languages";
import { USER_EVENTS, codeBlockEvent, CONTENT_EDIT } from "./annotation";
/**
* 构建块分隔符正则表达式
@@ -89,7 +90,8 @@ export const codeBlockCopyCut = EditorView.domEventHandlers({
view.dispatch({
changes: ranges,
scrollIntoView: true,
userEvent: "delete.cut"
userEvent: USER_EVENTS.DELETE_CUT,
annotations: [codeBlockEvent.of(CONTENT_EDIT)],
});
}
}
@@ -111,7 +113,8 @@ const copyCut = (view: EditorView, cut: boolean): boolean => {
view.dispatch({
changes: ranges,
scrollIntoView: true,
userEvent: "delete.cut"
userEvent: USER_EVENTS.DELETE_CUT,
annotations: [codeBlockEvent.of(CONTENT_EDIT)],
});
}
@@ -142,8 +145,9 @@ function doPaste(view: EditorView, input: string) {
}
view.dispatch(changes, {
userEvent: "input.paste",
scrollIntoView: true
userEvent: USER_EVENTS.INPUT_PASTE,
scrollIntoView: true,
annotations: [codeBlockEvent.of(CONTENT_EDIT)],
});
}
@@ -186,4 +190,4 @@ export function getCopyPasteExtensions() {
return [
codeBlockCopyCut,
];
}
}

View File

@@ -7,6 +7,7 @@ import { EditorView } from '@codemirror/view';
import { EditorSelection } from '@codemirror/state';
import { blockState } from './state';
import { Block } from './types';
import { USER_EVENTS } from './annotation';
/**
* 二分查找:找到包含指定位置的块
@@ -136,7 +137,7 @@ export function createCursorProtection() {
view.dispatch({
selection: EditorSelection.cursor(adjustedPos),
scrollIntoView: true,
userEvent: 'select'
userEvent: USER_EVENTS.SELECT
});
// 阻止默认行为
@@ -148,4 +149,3 @@ export function createCursorProtection() {
}
});
}

View File

@@ -3,8 +3,9 @@
*/
import { ViewPlugin, EditorView, Decoration, WidgetType, layer, RectangleMarker } from "@codemirror/view";
import { StateField, RangeSetBuilder, EditorState } from "@codemirror/state";
import { StateField, RangeSetBuilder, EditorState, Transaction } from "@codemirror/state";
import { blockState } from "./state";
import { codeBlockEvent, USER_EVENTS } from "./annotation";
/**
* 块开始装饰组件
@@ -180,10 +181,11 @@ const blockLayer = layer({
*/
const preventFirstBlockFromBeingDeleted = EditorState.changeFilter.of((tr: any) => {
const protect: number[] = [];
const internalEvent = tr.annotation(codeBlockEvent);
// 获取块状态并获取第一个块的分隔符大小
const blocks = tr.startState.field(blockState);
if (blocks && blocks.length > 0) {
if (!internalEvent && blocks && blocks.length > 0) {
const firstBlock = blocks[0];
const firstBlockDelimiterSize = firstBlock.delimiter.to;
@@ -194,23 +196,27 @@ const preventFirstBlockFromBeingDeleted = EditorState.changeFilter.of((tr: any)
}
// 如果是搜索替换操作,保护所有块分隔符
if (tr.annotations.some((a: any) => a.value === "input.replace" || a.value === "input.replace.all")) {
blocks.forEach((block: any) => {
const userEvent = tr.annotation(Transaction.userEvent);
if (userEvent && (userEvent === USER_EVENTS.INPUT_REPLACE || userEvent === USER_EVENTS.INPUT_REPLACE_ALL)) {
blocks?.forEach((block: any) => {
if (block.delimiter) {
protect.push(block.delimiter.from, block.delimiter.to);
}
});
}
// 返回保护范围数组,如果没有需要保护的范围则返回 false
return protect.length > 0 ? protect : false;
});
// 返回保护范围数组;若无需保护则返回 true 放行事务
return protect.length > 0 ? protect : true;
})
/**
* 防止选择在第一个块之前
* 使用 transactionFilter 来确保选择不会在第一个块之前
*/
const preventSelectionBeforeFirstBlock = EditorState.transactionFilter.of((tr: any) => {
if (tr.annotation(codeBlockEvent)) {
return tr;
}
// 获取块状态并获取第一个块的分隔符大小
const blocks = tr.startState.field(blockState);
if (!blocks || blocks.length === 0) {
@@ -261,4 +267,4 @@ export function getBlockDecorationExtensions(options: {
}
return extensions;
}
}

View File

@@ -6,6 +6,8 @@
import { EditorSelection, SelectionRange } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { getNoteBlockFromPos } from "./state";
import { codeBlockEvent, CONTENT_EDIT } from "./annotation";
import { USER_EVENTS } from "./annotation";
interface LineBlock {
from: number;
@@ -87,7 +89,8 @@ export const deleteLine = (view: EditorView): boolean => {
changes,
selection,
scrollIntoView: true,
userEvent: "delete.line"
userEvent: USER_EVENTS.DELETE_LINE,
annotations: [codeBlockEvent.of(CONTENT_EDIT)],
});
return true;
@@ -127,8 +130,9 @@ export const deleteLineCommand = ({ state, dispatch }: { state: any; dispatch: a
changes,
selection,
scrollIntoView: true,
userEvent: "delete.line"
userEvent: USER_EVENTS.DELETE_LINE,
annotations: [codeBlockEvent.of(CONTENT_EDIT)],
}));
return true;
};
};

View File

@@ -4,6 +4,7 @@ import * as prettier from "prettier/standalone";
import { getActiveNoteBlock } from "./state";
import { getLanguage } from "./lang-parser/languages";
import { SupportedLanguage } from "./types";
import { USER_EVENTS, codeBlockEvent, CONTENT_EDIT } from "./annotation";
export const formatBlockContent = (view) => {
if (!view || view.state.readOnly)
@@ -87,7 +88,8 @@ export const formatBlockContent = (view) => {
},
selection: EditorSelection.cursor(currentBlockFrom + Math.min(formattedContent.cursorOffset, formattedContent.formatted.length)),
scrollIntoView: true,
userEvent: "input"
userEvent: USER_EVENTS.INPUT,
annotations: [codeBlockEvent.of(CONTENT_EDIT)],
});
return true;
@@ -100,4 +102,4 @@ export const formatBlockContent = (view) => {
// 执行异步格式化
performFormat();
return true; // 立即返回 true表示命令已开始执行
};
};

View File

@@ -6,6 +6,12 @@
import { ViewPlugin, Decoration, WidgetType } from "@codemirror/view";
import { RangeSetBuilder } from "@codemirror/state";
import { getNoteBlockFromPos } from "./state";
import { transactionsHasAnnotation, CURRENCIES_LOADED } from "./annotation";
type MathParserEntry = {
parser: any;
prev?: any;
};
// 声明全局math对象
declare global {
interface Window {
@@ -62,8 +68,7 @@ class MathResult extends WidgetType {
/**
* 数学装饰函数
*/
function mathDeco(view: any): any {
const mathParsers = new WeakMap();
function mathDeco(view: any, parserCache: WeakMap<any, MathParserEntry>): any {
const builder = new RangeSetBuilder();
for (const { from, to } of view.visibleRanges) {
@@ -72,12 +77,17 @@ function mathDeco(view: any): any {
const block = getNoteBlockFromPos(view.state, pos);
if (block && block.language.name === "math") {
// get math.js parser and cache it for this block
let { parser, prev } = mathParsers.get(block) || {};
let entry = parserCache.get(block);
let parser = entry?.parser;
if (!parser) {
if (line.from > block.content.from) {
pos = block.content.from;
continue;
}
if (typeof window.math !== 'undefined') {
parser = window.math.parser();
mathParsers.set(block, { parser, prev });
entry = { parser, prev: undefined };
parserCache.set(block, entry);
}
}
@@ -85,10 +95,15 @@ function mathDeco(view: any): any {
let result: any;
try {
if (parser) {
parser.set("prev", prev);
if (entry && line.from === block.content.from && typeof parser.clear === "function") {
parser.clear();
entry.prev = undefined;
}
const prevValue = entry?.prev;
parser.set("prev", prevValue);
result = parser.evaluate(line.text);
if (result !== undefined) {
mathParsers.set(block, { parser, prev: result });
if (entry && result !== undefined) {
entry.prev = result;
}
}
} catch (e) {
@@ -97,7 +112,7 @@ function mathDeco(view: any): any {
// if we got a result from math.js, add the result decoration
if (result !== undefined) {
const format = parser?.get("format");
const format = parser?.get?.("format");
let resultWidget: MathResult | undefined;
if (typeof(result) === "string") {
@@ -142,15 +157,25 @@ function mathDeco(view: any): any {
*/
export const mathBlock = ViewPlugin.fromClass(class {
decorations: any;
mathParsers: WeakMap<any, MathParserEntry>;
constructor(view: any) {
this.decorations = mathDeco(view);
this.mathParsers = new WeakMap();
this.decorations = mathDeco(view, this.mathParsers);
}
update(update: any) {
// If the document changed, the viewport changed, update the decorations
if (update.docChanged || update.viewportChanged) {
this.decorations = mathDeco(update.view);
const hasCurrencyUpdate = transactionsHasAnnotation(update.transactions, CURRENCIES_LOADED);
if (update.docChanged || hasCurrencyUpdate) {
// 文档结构或汇率变化时重置解析缓存
this.mathParsers = new WeakMap();
}
if (
update.docChanged ||
update.viewportChanged ||
hasCurrencyUpdate
) {
this.decorations = mathDeco(update.view, this.mathParsers);
}
}
}, {

View File

@@ -6,6 +6,8 @@
import { EditorSelection, SelectionRange } from "@codemirror/state";
import { blockState } from "./state";
import { LANGUAGES } from "./lang-parser/languages";
import { codeBlockEvent, CONTENT_EDIT } from "./annotation";
import { USER_EVENTS } from "./annotation";
interface LineBlock {
from: number;
@@ -131,7 +133,8 @@ function moveLine(state: any, dispatch: any, forward: boolean): boolean {
changes,
scrollIntoView: true,
selection: EditorSelection.create(ranges, state.selection.mainIndex),
userEvent: "move.line"
userEvent: USER_EVENTS.MOVE_LINE,
annotations: [codeBlockEvent.of(CONTENT_EDIT)],
}));
return true;
@@ -157,4 +160,4 @@ export const moveLineUp = ({ state, dispatch }: { state: any; dispatch: any }):
*/
export const moveLineDown = ({ state, dispatch }: { state: any; dispatch: any }): boolean => {
return moveLine(state, dispatch, true);
};
};

View File

@@ -3,7 +3,8 @@
*/
import { EditorState } from '@codemirror/state';
import { syntaxTree, syntaxTreeAvailable } from '@codemirror/language';
import { syntaxTree, ensureSyntaxTree } from '@codemirror/language';
import type { Tree } from '@lezer/common';
import { Block as BlockNode, BlockDelimiter, BlockContent, BlockLanguage } from './lang-parser/parser.terms.js';
import {
SupportedLanguage,
@@ -15,51 +16,47 @@ import {
} from './types';
import { LANGUAGES } from './lang-parser/languages';
const DEFAULT_LANGUAGE = (LANGUAGES[0]?.token || 'text') as string;
/**
* 从语法树解析代码块
*/
export function getBlocksFromSyntaxTree(state: EditorState): Block[] | null {
if (!syntaxTreeAvailable(state)) {
const tree = syntaxTree(state);
if (!tree) {
return null;
}
return collectBlocksFromTree(tree, state);
}
const tree = syntaxTree(state);
function collectBlocksFromTree(tree: Tree, state: EditorState): Block[] | null {
const blocks: Block[] = [];
const doc = state.doc;
// 遍历语法树中的所有块
tree.iterate({
enter(node) {
if (node.type.id === BlockNode) {
// 查找块的分隔符和内容
let delimiter: { from: number; to: number } | null = null;
let content: { from: number; to: number } | null = null;
let language = 'text';
let language: string = DEFAULT_LANGUAGE;
let auto = false;
// 遍历块的子节点
const blockNode = node.node;
blockNode.firstChild?.cursor().iterate(child => {
if (child.type.id === BlockDelimiter) {
delimiter = { from: child.from, to: child.to };
// 解析整个分隔符文本来获取语言和自动检测标记
const delimiterText = doc.sliceString(child.from, child.to);
// 使用正则表达式解析分隔符
const match = delimiterText.match(/∞∞∞([a-zA-Z0-9_-]+)(-a)?\n/);
if (match) {
language = match[1] || 'text';
language = match[1] || DEFAULT_LANGUAGE;
auto = match[2] === '-a';
} else {
// 回退到逐个解析子节点
child.node.firstChild?.cursor().iterate(langChild => {
if (langChild.type.id === BlockLanguage) {
const langText = doc.sliceString(langChild.from, langChild.to);
language = langText || 'text';
language = langText || DEFAULT_LANGUAGE;
}
// 检查是否有自动检测标记
if (doc.sliceString(langChild.from, langChild.to) === '-a') {
if (doc.sliceString(langChild.from, langChild.to) === AUTO_DETECT_SUFFIX) {
auto = true;
}
});
@@ -88,7 +85,6 @@ export function getBlocksFromSyntaxTree(state: EditorState): Block[] | null {
});
if (blocks.length > 0) {
// 设置第一个块分隔符的大小
firstBlockDelimiterSize = blocks[0].delimiter.to;
return blocks;
}
@@ -104,203 +100,78 @@ export let firstBlockDelimiterSize: number | undefined;
*/
export function getBlocksFromString(state: EditorState): Block[] {
const blocks: Block[] = [];
const doc = state.doc;
const doc = state.doc;
if (doc.length === 0) {
// 如果文档为空,创建一个默认的文本块
return [{
language: {
name: 'text',
auto: false,
},
content: {
from: 0,
to: 0,
},
delimiter: {
from: 0,
to: 0,
},
range: {
from: 0,
to: 0,
},
}];
}
return [createPlainTextBlock(0, 0)];
}
const content = doc.sliceString(0, doc.length);
const delim = "\n∞∞∞";
let pos = 0;
const delimiter = DELIMITER_PREFIX;
const suffixLength = DELIMITER_SUFFIX.length;
// 检查文档是否以分隔符开始(不带前导换行符)
if (content.startsWith("∞∞∞")) {
// 文档直接以分隔符开始,调整为标准格式
pos = 0;
} else if (content.startsWith("\n∞∞∞")) {
// 文档以换行符+分隔符开始这是标准格式从位置0开始解析
pos = 0;
} else {
// 如果文档不以分隔符开始,查找第一个分隔符
const firstDelimPos = content.indexOf(delim);
if (firstDelimPos === -1) {
// 如果没有找到分隔符,整个文档作为一个文本块
firstBlockDelimiterSize = 0;
return [{
language: {
name: 'text',
auto: false,
},
content: {
from: 0,
to: doc.length,
},
delimiter: {
from: 0,
to: 0,
},
range: {
from: 0,
to: doc.length,
},
}];
}
let pos = content.indexOf(delimiter);
if (pos === -1) {
firstBlockDelimiterSize = 0;
return [createPlainTextBlock(0, doc.length)];
}
if (pos > 0) {
blocks.push(createPlainTextBlock(0, pos));
}
while (pos !== -1 && pos < doc.length) {
const blockStart = pos;
const langStart = blockStart + delimiter.length;
const delimiterEnd = content.indexOf(DELIMITER_SUFFIX, langStart);
if (delimiterEnd === -1) break;
const delimiterText = content.slice(blockStart, delimiterEnd + suffixLength);
const delimiterInfo = parseDelimiter(delimiterText);
if (!delimiterInfo) break;
const contentStart = delimiterEnd + suffixLength;
const nextDelimiter = content.indexOf(delimiter, contentStart);
const contentEnd = nextDelimiter === -1 ? doc.length : nextDelimiter;
// 创建第一个块(分隔符之前的内容)
blocks.push({
language: {
name: 'text',
auto: false,
},
content: {
from: 0,
to: firstDelimPos,
},
delimiter: {
from: 0,
to: 0,
},
range: {
from: 0,
to: firstDelimPos,
},
language: { name: delimiterInfo.language, auto: delimiterInfo.auto },
content: { from: contentStart, to: contentEnd },
delimiter: { from: blockStart, to: delimiterEnd + suffixLength },
range: { from: blockStart, to: contentEnd },
});
pos = firstDelimPos;
firstBlockDelimiterSize = 0;
pos = nextDelimiter;
}
while (pos < doc.length) {
let blockStart: number;
if (pos === 0 && content.startsWith("∞∞∞")) {
// 处理文档开头直接是分隔符的情况(不带前导换行符)
blockStart = 0;
} else if (pos === 0 && content.startsWith("\n∞∞∞")) {
// 处理文档开头是换行符+分隔符的情况(标准格式)
blockStart = 0;
} else {
blockStart = content.indexOf(delim, pos);
if (blockStart !== pos) {
// 如果在当前位置没有找到分隔符,可能是文档结尾
break;
}
}
// 确定语言开始位置
let langStart: number;
if (pos === 0 && content.startsWith("∞∞∞")) {
// 文档直接以分隔符开始,跳过 ∞∞∞
langStart = blockStart + 3;
} else {
// 标准情况,跳过 \n∞∞∞
langStart = blockStart + delim.length;
}
const delimiterEnd = content.indexOf("\n", langStart);
if (delimiterEnd < 0) {
console.error("Error parsing blocks. Delimiter didn't end with newline");
break;
}
const langFull = content.substring(langStart, delimiterEnd);
let auto = false;
let lang = langFull;
if (langFull.endsWith("-a")) {
auto = true;
lang = langFull.substring(0, langFull.length - 2);
}
const contentFrom = delimiterEnd + 1;
let blockEnd = content.indexOf(delim, contentFrom);
if (blockEnd < 0) {
blockEnd = doc.length;
}
const block: Block = {
language: {
name: lang || 'text',
auto: auto,
},
content: {
from: contentFrom,
to: blockEnd,
},
delimiter: {
from: blockStart,
to: delimiterEnd + 1,
},
range: {
from: blockStart,
to: blockEnd,
},
};
blocks.push(block);
pos = blockEnd;
}
// 如果没有找到任何块,创建一个默认块
if (blocks.length === 0) {
blocks.push({
language: {
name: 'text',
auto: false,
},
content: {
from: 0,
to: doc.length,
},
delimiter: {
from: 0,
to: 0,
},
range: {
from: 0,
to: doc.length,
},
});
blocks.push(createPlainTextBlock(0, doc.length));
firstBlockDelimiterSize = 0;
} else {
// 设置第一个块分隔符的大小
firstBlockDelimiterSize = blocks[0].delimiter.to;
}
return blocks;
return blocks;
}
/**
* 获取文档中的所有块
*/
export function getBlocks(state: EditorState): Block[] {
// 优先使用语法树解析
const syntaxTreeBlocks = getBlocksFromSyntaxTree(state);
if (syntaxTreeBlocks) {
return syntaxTreeBlocks;
let blocks = getBlocksFromSyntaxTree(state);
if (blocks) {
return blocks;
}
const ensuredTree = ensureSyntaxTree(state, state.doc.length, 200);
if (ensuredTree) {
blocks = collectBlocksFromTree(ensuredTree, state);
if (blocks) {
return blocks;
}
}
// 如果语法树不可用,回退到字符串解析
return getBlocksFromString(state);
}
@@ -396,10 +267,21 @@ export function parseDelimiter(delimiterText: string): { language: SupportedLang
const validLanguage = LANGUAGES.some(lang => lang.token === languageName)
? languageName as SupportedLanguage
: 'text';
: DEFAULT_LANGUAGE as SupportedLanguage;
return {
language: validLanguage,
auto: isAuto
};
}
}
function createPlainTextBlock(from: number, to: number): Block {
return {
language: { name: DEFAULT_LANGUAGE, auto: false },
content: { from, to },
delimiter: { from: 0, to: 0 },
range: { from, to },
};
}

View File

@@ -7,6 +7,7 @@ import { StateField, StateEffect, RangeSetBuilder, EditorSelection, EditorState,
import { selectAll as defaultSelectAll } from "@codemirror/commands";
import { Command } from "@codemirror/view";
import { getActiveNoteBlock, blockState } from "./state";
import { USER_EVENTS, codeBlockEvent, CONTENT_EDIT } from "./annotation";
/**
* 当用户按下 Ctrl+A 时,我们希望首先选择整个块。但如果整个块已经被选中,
@@ -115,7 +116,8 @@ export const selectAll: Command = ({ state, dispatch }) => {
// 选择当前块的所有内容
dispatch(state.update({
selection: { anchor: block.content.from, head: block.content.to },
userEvent: "select"
userEvent: USER_EVENTS.SELECT,
annotations: [codeBlockEvent.of(CONTENT_EDIT)],
}));
return true;
@@ -127,7 +129,7 @@ export const selectAll: Command = ({ state, dispatch }) => {
*/
export const blockAwareSelection = EditorState.transactionFilter.of((tr: any) => {
// 只处理选择变化的事务,并且忽略我们自己生成的事务
if (!tr.selection || !tr.selection.ranges || tr.annotation?.(Transaction.userEvent) === "select.block-boundary") {
if (!tr.selection || !tr.selection.ranges || tr.annotation?.(Transaction.userEvent) === USER_EVENTS.SELECT_BLOCK_BOUNDARY) {
return tr;
}
@@ -181,7 +183,7 @@ export const blockAwareSelection = EditorState.transactionFilter.of((tr: any) =>
return {
...tr,
selection: EditorSelection.create(correctedRanges, tr.selection.mainIndex),
annotations: tr.annotations.concat(Transaction.userEvent.of("select.block-boundary"))
annotations: tr.annotations.concat(Transaction.userEvent.of(USER_EVENTS.SELECT_BLOCK_BOUNDARY))
};
}
} catch (error) {
@@ -219,4 +221,4 @@ export function getBlockSelectExtensions() {
return [
emptyBlockSelected,
];
}
}

View File

@@ -5,6 +5,7 @@
import { EditorSelection, findClusterBreak } from "@codemirror/state";
import { getNoteBlockFromPos } from "./state";
import { USER_EVENTS, codeBlockEvent, CONTENT_EDIT } from "./annotation";
/**
* 交换光标前后的字符
@@ -46,8 +47,9 @@ export const transposeChars = ({ state, dispatch }: { state: any; dispatch: any
dispatch(state.update(changes, {
scrollIntoView: true,
userEvent: "move.character"
userEvent: USER_EVENTS.MOVE_CHARACTER,
annotations: [codeBlockEvent.of(CONTENT_EDIT)],
}));
return true;
};
};

View File

@@ -319,7 +319,7 @@ const handlePickerClose = () => {
<style scoped lang="scss">
.settings-page {
max-width: 1000px;
//max-width: 1000px;
}
.select-input {

View File

@@ -192,7 +192,7 @@ const selectSshKeyFile = async () => {
<style scoped lang="scss">
.settings-page {
max-width: 800px;
//max-width: 800px;
}
// 统一的输入控件样式

View File

@@ -222,7 +222,7 @@ const handleAutoSaveDelayChange = async (event: Event) => {
<style scoped lang="scss">
.settings-page {
max-width: 800px;
//max-width: 800px;
}
.number-control {

View File

@@ -265,7 +265,7 @@ const getSelectOptions = (extensionId: ExtensionID, configKey: string): SelectOp
<style scoped lang="scss">
.settings-page {
max-width: 1000px;
//max-width: 1000px;
}
.extension-item {

View File

@@ -440,7 +440,7 @@ onUnmounted(() => {
<style scoped lang="scss">
.settings-page {
max-width: 800px;
//max-width: 800px;
}
.hotkey-selector {

View File

@@ -232,7 +232,7 @@ const parseKeyBinding = (keyStr: string, command?: string): string[] => {
<style scoped lang="scss">
.settings-page {
max-width: 800px;
//max-width: 800px;
}
.key-bindings-container {

View File

@@ -176,7 +176,7 @@ const clearAll = async () => {
<style scoped lang="scss">
.settings-page {
padding: 20px 0 20px 0;
//padding: 20px 0 20px 0;
}
.dev-description {

View File

@@ -151,8 +151,7 @@ const currentVersion = computed(() => {
<style scoped lang="scss">
.settings-page {
max-width: 800px;
width: 100%; // 确保在小屏幕上也能占满可用空间
//max-width: 800px;
}
.check-button {

View File

@@ -1 +1 @@
VERSION=1.5.3
VERSION=1.5.4