chore: seperate large files

This commit is contained in:
CDN 2025-04-23 19:22:41 +08:00
parent ebbf516689
commit 76e1298ded
Signed by: CDN
GPG key ID: 0C656827F9F80080
44 changed files with 5745 additions and 4173 deletions

View file

@ -1,534 +0,0 @@
package ass
import (
"bufio"
"fmt"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sub-cli/internal/model"
)
// 常量定义
const (
ASSHeader = "[Script Info]"
ASSStylesHeader = "[V4+ Styles]"
ASSEventsHeader = "[Events]"
DefaultFormat = "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text"
)
// Parse 解析ASS文件为ASSFile结构
func Parse(filePath string) (model.ASSFile, error) {
file, err := os.Open(filePath)
if err != nil {
return model.ASSFile{}, fmt.Errorf("打开ASS文件失败: %w", err)
}
defer file.Close()
result := model.NewASSFile()
scanner := bufio.NewScanner(file)
// 当前解析的区块
currentSection := ""
var styleFormat, eventFormat []string
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, ";") {
// 跳过空行和注释行
continue
}
// 检查章节标题
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
currentSection = line
continue
}
switch currentSection {
case ASSHeader:
// 解析脚本信息
if strings.Contains(line, ":") {
parts := strings.SplitN(line, ":", 2)
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
result.ScriptInfo[key] = value
}
case ASSStylesHeader:
// 解析样式格式行和样式定义
if strings.HasPrefix(line, "Format:") {
formatStr := strings.TrimPrefix(line, "Format:")
styleFormat = parseFormatLine(formatStr)
} else if strings.HasPrefix(line, "Style:") {
styleValues := parseStyleLine(line)
if len(styleFormat) > 0 && len(styleValues) > 0 {
style := model.ASSStyle{
Name: styleValues[0], // 第一个值通常是样式名称
Properties: make(map[string]string),
}
// 将原始格式行保存下来
style.Properties["Format"] = "Name, " + strings.Join(styleFormat[1:], ", ")
style.Properties["Style"] = strings.Join(styleValues, ", ")
// 解析各个样式属性
for i := 0; i < len(styleFormat) && i < len(styleValues); i++ {
style.Properties[styleFormat[i]] = styleValues[i]
}
result.Styles = append(result.Styles, style)
}
}
case ASSEventsHeader:
// 解析事件格式行和对话行
if strings.HasPrefix(line, "Format:") {
formatStr := strings.TrimPrefix(line, "Format:")
eventFormat = parseFormatLine(formatStr)
} else if len(eventFormat) > 0 &&
(strings.HasPrefix(line, "Dialogue:") ||
strings.HasPrefix(line, "Comment:")) {
eventType := "Dialogue"
if strings.HasPrefix(line, "Comment:") {
eventType = "Comment"
line = strings.TrimPrefix(line, "Comment:")
} else {
line = strings.TrimPrefix(line, "Dialogue:")
}
values := parseEventLine(line)
if len(values) >= len(eventFormat) {
event := model.NewASSEvent()
event.Type = eventType
// 填充事件属性
for i, format := range eventFormat {
value := values[i]
switch strings.TrimSpace(format) {
case "Layer":
layer, _ := strconv.Atoi(value)
event.Layer = layer
case "Start":
event.StartTime = parseASSTimestamp(value)
case "End":
event.EndTime = parseASSTimestamp(value)
case "Style":
event.Style = value
case "Name":
event.Name = value
case "MarginL":
marginL, _ := strconv.Atoi(value)
event.MarginL = marginL
case "MarginR":
marginR, _ := strconv.Atoi(value)
event.MarginR = marginR
case "MarginV":
marginV, _ := strconv.Atoi(value)
event.MarginV = marginV
case "Effect":
event.Effect = value
case "Text":
// 文本可能包含逗号,所以需要特殊处理
textStartIndex := strings.Index(line, value)
if textStartIndex >= 0 {
event.Text = line[textStartIndex:]
} else {
event.Text = value
}
}
}
result.Events = append(result.Events, event)
}
}
}
}
if err := scanner.Err(); err != nil {
return model.ASSFile{}, fmt.Errorf("读取ASS文件失败: %w", err)
}
return result, nil
}
// Generate 生成ASS文件
func Generate(assFile model.ASSFile, filePath string) error {
// 确保目录存在
dir := filepath.Dir(filePath)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("创建目录失败: %w", err)
}
file, err := os.Create(filePath)
if err != nil {
return fmt.Errorf("创建ASS文件失败: %w", err)
}
defer file.Close()
writer := bufio.NewWriter(file)
// 写入脚本信息
writer.WriteString(ASSHeader + "\n")
for key, value := range assFile.ScriptInfo {
writer.WriteString(fmt.Sprintf("%s: %s\n", key, value))
}
writer.WriteString("\n")
// 写入样式信息
writer.WriteString(ASSStylesHeader + "\n")
if len(assFile.Styles) > 0 {
// 获取样式格式
format := "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding"
if style := assFile.Styles[0]; style.Properties["Format"] != "" {
format = "Format: " + style.Properties["Format"]
}
writer.WriteString(format + "\n")
// 写入各个样式
for _, style := range assFile.Styles {
if style.Properties["Style"] != "" {
writer.WriteString("Style: " + style.Properties["Style"] + "\n")
} else {
// 手动构造样式行
writer.WriteString(fmt.Sprintf("Style: %s,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1\n", style.Name))
}
}
} else {
// 写入默认样式
writer.WriteString("Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n")
writer.WriteString("Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1\n")
}
writer.WriteString("\n")
// 写入事件信息
writer.WriteString(ASSEventsHeader + "\n")
writer.WriteString(DefaultFormat + "\n")
// 写入各个对话行
for _, event := range assFile.Events {
startTime := formatASSTimestamp(event.StartTime)
endTime := formatASSTimestamp(event.EndTime)
line := fmt.Sprintf("%s: %d,%s,%s,%s,%s,%d,%d,%d,%s,%s\n",
event.Type,
event.Layer,
startTime,
endTime,
event.Style,
event.Name,
event.MarginL,
event.MarginR,
event.MarginV,
event.Effect,
event.Text)
writer.WriteString(line)
}
return writer.Flush()
}
// Format 格式化ASS文件
func Format(filePath string) error {
// 解析文件
assFile, err := Parse(filePath)
if err != nil {
return err
}
// 重新生成文件
return Generate(assFile, filePath)
}
// ConvertToSubtitle 将ASS文件转换为通用字幕格式
func ConvertToSubtitle(filePath string) (model.Subtitle, error) {
assFile, err := Parse(filePath)
if err != nil {
return model.Subtitle{}, err
}
subtitle := model.NewSubtitle()
subtitle.Format = "ass"
// 复制脚本信息到元数据
for key, value := range assFile.ScriptInfo {
subtitle.Metadata[key] = value
}
// 复制样式信息到FormatData
styleMap := make(map[string]model.ASSStyle)
for _, style := range assFile.Styles {
styleMap[style.Name] = style
}
subtitle.FormatData["styles"] = styleMap
// 转换事件到字幕条目
for i, event := range assFile.Events {
entry := model.NewSubtitleEntry()
entry.Index = i + 1
entry.StartTime = event.StartTime
entry.EndTime = event.EndTime
entry.Text = event.Text
// 保存ASS特定属性到FormatData
eventData := make(map[string]interface{})
eventData["type"] = event.Type
eventData["layer"] = event.Layer
eventData["style"] = event.Style
eventData["name"] = event.Name
eventData["marginL"] = event.MarginL
eventData["marginR"] = event.MarginR
eventData["marginV"] = event.MarginV
eventData["effect"] = event.Effect
entry.FormatData["ass"] = eventData
// 设置基本样式属性
if style, ok := styleMap[event.Style]; ok {
if bold, exists := style.Properties["Bold"]; exists && bold == "1" {
entry.Styles["bold"] = "true"
}
if italic, exists := style.Properties["Italic"]; exists && italic == "1" {
entry.Styles["italic"] = "true"
}
if underline, exists := style.Properties["Underline"]; exists && underline == "1" {
entry.Styles["underline"] = "true"
}
}
subtitle.Entries = append(subtitle.Entries, entry)
}
return subtitle, nil
}
// ConvertFromSubtitle 将通用字幕格式转换为ASS文件
func ConvertFromSubtitle(subtitle model.Subtitle, filePath string) error {
assFile := model.NewASSFile()
// 复制元数据到脚本信息
for key, value := range subtitle.Metadata {
assFile.ScriptInfo[key] = value
}
// 添加标题(如果有)
if subtitle.Title != "" {
assFile.ScriptInfo["Title"] = subtitle.Title
}
// 从FormatData恢复样式如果有
if styles, ok := subtitle.FormatData["styles"].(map[string]model.ASSStyle); ok {
for _, style := range styles {
assFile.Styles = append(assFile.Styles, style)
}
}
// 转换字幕条目到ASS事件
for _, entry := range subtitle.Entries {
event := model.NewASSEvent()
event.StartTime = entry.StartTime
event.EndTime = entry.EndTime
event.Text = entry.Text
// 从FormatData恢复ASS特定属性如果有
if assData, ok := entry.FormatData["ass"].(map[string]interface{}); ok {
if eventType, ok := assData["type"].(string); ok {
event.Type = eventType
}
if layer, ok := assData["layer"].(int); ok {
event.Layer = layer
}
if style, ok := assData["style"].(string); ok {
event.Style = style
}
if name, ok := assData["name"].(string); ok {
event.Name = name
}
if marginL, ok := assData["marginL"].(int); ok {
event.MarginL = marginL
}
if marginR, ok := assData["marginR"].(int); ok {
event.MarginR = marginR
}
if marginV, ok := assData["marginV"].(int); ok {
event.MarginV = marginV
}
if effect, ok := assData["effect"].(string); ok {
event.Effect = effect
}
} else {
// 根据基本样式设置ASS样式
if _, ok := entry.Styles["bold"]; ok {
// 创建一个加粗样式(如果尚未存在)
styleName := "Bold"
found := false
for _, style := range assFile.Styles {
if style.Name == styleName {
found = true
break
}
}
if !found {
boldStyle := model.ASSStyle{
Name: styleName,
Properties: map[string]string{
"Bold": "1",
},
}
assFile.Styles = append(assFile.Styles, boldStyle)
}
event.Style = styleName
}
if _, ok := entry.Styles["italic"]; ok {
// 创建一个斜体样式(如果尚未存在)
styleName := "Italic"
found := false
for _, style := range assFile.Styles {
if style.Name == styleName {
found = true
break
}
}
if !found {
italicStyle := model.ASSStyle{
Name: styleName,
Properties: map[string]string{
"Italic": "1",
},
}
assFile.Styles = append(assFile.Styles, italicStyle)
}
event.Style = styleName
}
if _, ok := entry.Styles["underline"]; ok {
// 创建一个下划线样式(如果尚未存在)
styleName := "Underline"
found := false
for _, style := range assFile.Styles {
if style.Name == styleName {
found = true
break
}
}
if !found {
underlineStyle := model.ASSStyle{
Name: styleName,
Properties: map[string]string{
"Underline": "1",
},
}
assFile.Styles = append(assFile.Styles, underlineStyle)
}
event.Style = styleName
}
}
assFile.Events = append(assFile.Events, event)
}
// 生成ASS文件
return Generate(assFile, filePath)
}
// 辅助函数
// parseFormatLine 解析格式行中的各个字段
func parseFormatLine(formatStr string) []string {
fields := strings.Split(formatStr, ",")
result := make([]string, 0, len(fields))
for _, field := range fields {
result = append(result, strings.TrimSpace(field))
}
return result
}
// parseStyleLine 解析样式行
func parseStyleLine(line string) []string {
// 去掉"Style:"前缀
styleStr := strings.TrimPrefix(line, "Style:")
return splitCSV(styleStr)
}
// parseEventLine 解析事件行
func parseEventLine(line string) []string {
return splitCSV(line)
}
// splitCSV 拆分CSV格式的字符串但保留Text字段中的逗号
func splitCSV(line string) []string {
var result []string
inText := false
current := ""
for _, char := range line {
if char == ',' && !inText {
result = append(result, strings.TrimSpace(current))
current = ""
} else {
current += string(char)
// 这是个简化处理实际ASS格式更复杂
// 当处理到足够数量的字段后剩余部分都当作Text字段
if len(result) >= 9 {
inText = true
}
}
}
if current != "" {
result = append(result, strings.TrimSpace(current))
}
return result
}
// parseASSTimestamp 解析ASS格式的时间戳 (h:mm:ss.cc)
func parseASSTimestamp(timeStr string) model.Timestamp {
// 匹配 h:mm:ss.cc 格式
re := regexp.MustCompile(`(\d+):(\d+):(\d+)\.(\d+)`)
matches := re.FindStringSubmatch(timeStr)
if len(matches) == 5 {
hours, _ := strconv.Atoi(matches[1])
minutes, _ := strconv.Atoi(matches[2])
seconds, _ := strconv.Atoi(matches[3])
// ASS使用厘秒1/100秒需要转换为毫秒
centiseconds, _ := strconv.Atoi(matches[4])
milliseconds := centiseconds * 10
return model.Timestamp{
Hours: hours,
Minutes: minutes,
Seconds: seconds,
Milliseconds: milliseconds,
}
}
// 返回零时间戳,如果解析失败
return model.Timestamp{}
}
// formatASSTimestamp 格式化为ASS格式的时间戳 (h:mm:ss.cc)
func formatASSTimestamp(timestamp model.Timestamp) string {
// ASS使用厘秒1/100秒
centiseconds := timestamp.Milliseconds / 10
return fmt.Sprintf("%d:%02d:%02d.%02d",
timestamp.Hours,
timestamp.Minutes,
timestamp.Seconds,
centiseconds)
}

View file

@ -1,529 +0,0 @@
package ass
import (
"os"
"path/filepath"
"strings"
"testing"
"sub-cli/internal/model"
)
func TestParse(t *testing.T) {
// Create temporary test file
content := `[Script Info]
ScriptType: v4.00+
Title: Test ASS File
PlayResX: 640
PlayResY: 480
[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1
Style: Bold,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,1,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1
[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,This is the first subtitle line.
Dialogue: 0,0:00:05.00,0:00:08.00,Bold,,0,0,0,,This is the second subtitle line with bold style.
Comment: 0,0:00:09.00,0:00:12.00,Default,,0,0,0,,This is a comment.
`
tempDir := t.TempDir()
testFile := filepath.Join(tempDir, "test.ass")
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
// Test parsing
assFile, err := Parse(testFile)
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
// Verify results
// Script info
if assFile.ScriptInfo["Title"] != "Test ASS File" {
t.Errorf("Title mismatch: expected 'Test ASS File', got '%s'", assFile.ScriptInfo["Title"])
}
if assFile.ScriptInfo["ScriptType"] != "v4.00+" {
t.Errorf("Script type mismatch: expected 'v4.00+', got '%s'", assFile.ScriptInfo["ScriptType"])
}
// Styles
if len(assFile.Styles) != 3 {
t.Errorf("Expected 3 styles, got %d", len(assFile.Styles))
} else {
// Find Bold style
var boldStyle *model.ASSStyle
for i, style := range assFile.Styles {
if style.Name == "Bold" {
boldStyle = &assFile.Styles[i]
break
}
}
if boldStyle == nil {
t.Errorf("Bold style not found")
} else {
boldValue, exists := boldStyle.Properties["Bold"]
if !exists || boldValue != "1" {
t.Errorf("Bold style Bold property mismatch: expected '1', got '%s'", boldValue)
}
}
}
// Events
if len(assFile.Events) != 3 {
t.Errorf("Expected 3 events, got %d", len(assFile.Events))
} else {
// Check first dialogue line
if assFile.Events[0].Type != "Dialogue" {
t.Errorf("First event type mismatch: expected 'Dialogue', got '%s'", assFile.Events[0].Type)
}
if assFile.Events[0].StartTime.Seconds != 1 || assFile.Events[0].StartTime.Milliseconds != 0 {
t.Errorf("First event start time mismatch: expected 00:00:01.00, got %d:%02d:%02d.%03d",
assFile.Events[0].StartTime.Hours, assFile.Events[0].StartTime.Minutes,
assFile.Events[0].StartTime.Seconds, assFile.Events[0].StartTime.Milliseconds)
}
if assFile.Events[0].Text != "This is the first subtitle line." {
t.Errorf("First event text mismatch: expected 'This is the first subtitle line.', got '%s'", assFile.Events[0].Text)
}
// Check second dialogue line (bold style)
if assFile.Events[1].Style != "Bold" {
t.Errorf("Second event style mismatch: expected 'Bold', got '%s'", assFile.Events[1].Style)
}
// Check comment line
if assFile.Events[2].Type != "Comment" {
t.Errorf("Third event type mismatch: expected 'Comment', got '%s'", assFile.Events[2].Type)
}
}
}
func TestGenerate(t *testing.T) {
// Create test ASS file structure
assFile := model.NewASSFile()
assFile.ScriptInfo["Title"] = "Generation Test"
// Add a custom style
boldStyle := model.ASSStyle{
Name: "Bold",
Properties: map[string]string{
"Format": "Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding",
"Style": "Bold,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,1,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1",
"Bold": "1",
},
}
assFile.Styles = append(assFile.Styles, boldStyle)
// Add two dialogue events
event1 := model.NewASSEvent()
event1.StartTime = model.Timestamp{Seconds: 1}
event1.EndTime = model.Timestamp{Seconds: 4}
event1.Text = "This is the first line."
event2 := model.NewASSEvent()
event2.StartTime = model.Timestamp{Seconds: 5}
event2.EndTime = model.Timestamp{Seconds: 8}
event2.Style = "Bold"
event2.Text = "This is the second line with bold style."
assFile.Events = append(assFile.Events, event1, event2)
// Generate ASS file
tempDir := t.TempDir()
outputFile := filepath.Join(tempDir, "output.ass")
err := Generate(assFile, outputFile)
if err != nil {
t.Fatalf("Generation failed: %v", err)
}
// Verify generated content
content, err := os.ReadFile(outputFile)
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}
contentStr := string(content)
// Check script info
if !strings.Contains(contentStr, "Title: Generation Test") {
t.Errorf("Output file should contain title 'Title: Generation Test'")
}
// Check styles
if !strings.Contains(contentStr, "Style: Bold,Arial,20") {
t.Errorf("Output file should contain Bold style")
}
// Check dialogue lines
if !strings.Contains(contentStr, "Dialogue: 0,0:00:01.00,0:00:04.00,Default") {
t.Errorf("Output file should contain first dialogue line")
}
if !strings.Contains(contentStr, "This is the first line.") {
t.Errorf("Output file should contain first line text")
}
if !strings.Contains(contentStr, "Dialogue: 0,0:00:05.00,0:00:08.00,Bold") {
t.Errorf("Output file should contain second dialogue line")
}
if !strings.Contains(contentStr, "This is the second line with bold style.") {
t.Errorf("Output file should contain second line text")
}
}
func TestFormat(t *testing.T) {
// Create test file (intentionally with mixed formatting)
content := `[Script Info]
ScriptType:v4.00+
Title: Formatting Test
[V4+ Styles]
Format:Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style:Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1
[Events]
Format:Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
Dialogue:0,0:0:1.0,0:0:4.0,Default,,0,0,0,,Text before formatting.
`
tempDir := t.TempDir()
testFile := filepath.Join(tempDir, "format_test.ass")
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
// Test formatting
err := Format(testFile)
if err != nil {
t.Fatalf("Formatting failed: %v", err)
}
// Verify formatted file
formattedContent, err := os.ReadFile(testFile)
if err != nil {
t.Fatalf("Failed to read formatted file: %v", err)
}
formattedStr := string(formattedContent)
// Check formatting
if !strings.Contains(formattedStr, "ScriptType: v4.00+") {
t.Errorf("Formatted file should contain standardized ScriptType line")
}
if !strings.Contains(formattedStr, "Title: Formatting Test") {
t.Errorf("Formatted file should contain standardized Title line")
}
// Check timestamp formatting
if !strings.Contains(formattedStr, "0:00:01.00,0:00:04.00") {
t.Errorf("Formatted file should contain standardized timestamp format (0:00:01.00,0:00:04.00)")
}
}
func TestConvertToSubtitle(t *testing.T) {
// Create test file
content := `[Script Info]
ScriptType: v4.00+
Title: Conversion Test
[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1
Style: Bold,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,1,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1
Style: Italic,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,1,0,0,100,100,0,0,1,2,2,2,10,10,10,1
[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,Normal text.
Dialogue: 0,0:00:05.00,0:00:08.00,Bold,,0,0,0,,Bold text.
Dialogue: 0,0:00:09.00,0:00:12.00,Italic,,0,0,0,,Italic text.
`
tempDir := t.TempDir()
testFile := filepath.Join(tempDir, "convert_test.ass")
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
// Convert to subtitle
subtitle, err := ConvertToSubtitle(testFile)
if err != nil {
t.Fatalf("Conversion failed: %v", err)
}
// Verify results
if subtitle.Format != "ass" {
t.Errorf("Expected format 'ass', got '%s'", subtitle.Format)
}
if subtitle.Metadata["Title"] != "Conversion Test" {
t.Errorf("Expected title 'Conversion Test', got '%s'", subtitle.Metadata["Title"])
}
if len(subtitle.Entries) != 3 {
t.Errorf("Expected 3 entries, got %d", len(subtitle.Entries))
} else {
// Check first entry
if subtitle.Entries[0].Text != "Normal text." {
t.Errorf("First entry text mismatch: expected 'Normal text.', got '%s'", subtitle.Entries[0].Text)
}
// Check second entry (bold)
if subtitle.Entries[1].Text != "Bold text." {
t.Errorf("Second entry text mismatch: expected 'Bold text.', got '%s'", subtitle.Entries[1].Text)
}
bold, ok := subtitle.Entries[1].Styles["bold"]
if !ok || bold != "true" {
t.Errorf("Second entry should have bold=true style")
}
// Check third entry (italic)
if subtitle.Entries[2].Text != "Italic text." {
t.Errorf("Third entry text mismatch: expected 'Italic text.', got '%s'", subtitle.Entries[2].Text)
}
italic, ok := subtitle.Entries[2].Styles["italic"]
if !ok || italic != "true" {
t.Errorf("Third entry should have italic=true style")
}
}
}
func TestConvertFromSubtitle(t *testing.T) {
// Create test subtitle
subtitle := model.NewSubtitle()
subtitle.Format = "ass"
subtitle.Title = "Conversion from Subtitle Test"
// Create a normal entry
entry1 := model.NewSubtitleEntry()
entry1.Index = 1
entry1.StartTime = model.Timestamp{Seconds: 1}
entry1.EndTime = model.Timestamp{Seconds: 4}
entry1.Text = "Normal text."
// Create a bold entry
entry2 := model.NewSubtitleEntry()
entry2.Index = 2
entry2.StartTime = model.Timestamp{Seconds: 5}
entry2.EndTime = model.Timestamp{Seconds: 8}
entry2.Text = "Bold text."
entry2.Styles["bold"] = "true"
// Create an italic entry
entry3 := model.NewSubtitleEntry()
entry3.Index = 3
entry3.StartTime = model.Timestamp{Seconds: 9}
entry3.EndTime = model.Timestamp{Seconds: 12}
entry3.Text = "Italic text."
entry3.Styles["italic"] = "true"
subtitle.Entries = append(subtitle.Entries, entry1, entry2, entry3)
// Convert from subtitle
tempDir := t.TempDir()
outputFile := filepath.Join(tempDir, "convert_from_subtitle.ass")
err := ConvertFromSubtitle(subtitle, outputFile)
if err != nil {
t.Fatalf("Conversion failed: %v", err)
}
// Verify converted ASS file
assFile, err := Parse(outputFile)
if err != nil {
t.Fatalf("Failed to parse converted file: %v", err)
}
// Check script info
if assFile.ScriptInfo["Title"] != "Conversion from Subtitle Test" {
t.Errorf("Expected title 'Conversion from Subtitle Test', got '%s'", assFile.ScriptInfo["Title"])
}
// Check events
if len(assFile.Events) != 3 {
t.Errorf("Expected 3 events, got %d", len(assFile.Events))
} else {
// Check first dialogue line
if assFile.Events[0].Text != "Normal text." {
t.Errorf("First event text mismatch: expected 'Normal text.', got '%s'", assFile.Events[0].Text)
}
// Check second dialogue line (bold)
if assFile.Events[1].Text != "Bold text." {
t.Errorf("Second event text mismatch: expected 'Bold text.', got '%s'", assFile.Events[1].Text)
}
if assFile.Events[1].Style != "Bold" {
t.Errorf("Second event should use Bold style, got '%s'", assFile.Events[1].Style)
}
// Check third dialogue line (italic)
if assFile.Events[2].Text != "Italic text." {
t.Errorf("Third event text mismatch: expected 'Italic text.', got '%s'", assFile.Events[2].Text)
}
if assFile.Events[2].Style != "Italic" {
t.Errorf("Third event should use Italic style, got '%s'", assFile.Events[2].Style)
}
}
// Check styles
styleNames := make(map[string]bool)
for _, style := range assFile.Styles {
styleNames[style.Name] = true
}
if !styleNames["Bold"] {
t.Errorf("Should contain Bold style")
}
if !styleNames["Italic"] {
t.Errorf("Should contain Italic style")
}
}
func TestParse_EdgeCases(t *testing.T) {
// Test empty file
tempDir := t.TempDir()
emptyFile := filepath.Join(tempDir, "empty.ass")
if err := os.WriteFile(emptyFile, []byte{}, 0644); err != nil {
t.Fatalf("Failed to create empty test file: %v", err)
}
assFile, err := Parse(emptyFile)
if err != nil {
t.Fatalf("Failed to parse empty file: %v", err)
}
if len(assFile.Events) != 0 {
t.Errorf("Empty file should have 0 events, got %d", len(assFile.Events))
}
// Test file missing required sections
malformedContent := `[Script Info]
Title: Missing Sections Test
`
malformedFile := filepath.Join(tempDir, "malformed.ass")
if err := os.WriteFile(malformedFile, []byte(malformedContent), 0644); err != nil {
t.Fatalf("Failed to create malformed file: %v", err)
}
assFile, err = Parse(malformedFile)
if err != nil {
t.Fatalf("Failed to parse malformed file: %v", err)
}
if assFile.ScriptInfo["Title"] != "Missing Sections Test" {
t.Errorf("Should correctly parse the title")
}
if len(assFile.Events) != 0 {
t.Errorf("File missing Events section should have 0 events")
}
}
func TestParse_FileError(t *testing.T) {
// Test non-existent file
_, err := Parse("/nonexistent/file.ass")
if err == nil {
t.Error("Parsing non-existent file should return an error")
}
}
func TestGenerate_FileError(t *testing.T) {
// Test invalid path
assFile := model.NewASSFile()
err := Generate(assFile, "/nonexistent/directory/file.ass")
if err == nil {
t.Error("Generating to invalid path should return an error")
}
}
func TestConvertToSubtitle_FileError(t *testing.T) {
// Test non-existent file
_, err := ConvertToSubtitle("/nonexistent/file.ass")
if err == nil {
t.Error("Converting non-existent file should return an error")
}
}
func TestConvertFromSubtitle_FileError(t *testing.T) {
// Test invalid path
subtitle := model.NewSubtitle()
err := ConvertFromSubtitle(subtitle, "/nonexistent/directory/file.ass")
if err == nil {
t.Error("Converting to invalid path should return an error")
}
}
func TestParseASSTimestamp(t *testing.T) {
testCases := []struct {
name string
input string
expected model.Timestamp
}{
{
name: "Standard format",
input: "0:00:01.00",
expected: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0},
},
{
name: "With centiseconds",
input: "0:00:01.50",
expected: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 500},
},
{
name: "Complete hours, minutes, seconds",
input: "1:02:03.45",
expected: model.Timestamp{Hours: 1, Minutes: 2, Seconds: 3, Milliseconds: 450},
},
{
name: "Invalid format",
input: "invalid",
expected: model.Timestamp{},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := parseASSTimestamp(tc.input)
if result != tc.expected {
t.Errorf("For input '%s', expected %+v, got %+v", tc.input, tc.expected, result)
}
})
}
}
func TestFormatASSTimestamp(t *testing.T) {
testCases := []struct {
name string
input model.Timestamp
expected string
}{
{
name: "Zero timestamp",
input: model.Timestamp{},
expected: "0:00:00.00",
},
{
name: "Simple seconds",
input: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0},
expected: "0:00:01.00",
},
{
name: "With milliseconds",
input: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 500},
expected: "0:00:01.50",
},
{
name: "Complete timestamp",
input: model.Timestamp{Hours: 1, Minutes: 2, Seconds: 3, Milliseconds: 450},
expected: "1:02:03.45",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := formatASSTimestamp(tc.input)
if result != tc.expected {
t.Errorf("For timestamp %+v, expected '%s', got '%s'", tc.input, tc.expected, result)
}
})
}
}

View file

@ -0,0 +1,186 @@
package ass
import (
"fmt"
"sub-cli/internal/model"
)
// ConvertToSubtitle 将ASS文件转换为通用字幕格式
func ConvertToSubtitle(filePath string) (model.Subtitle, error) {
// 解析ASS文件
assFile, err := Parse(filePath)
if err != nil {
return model.Subtitle{}, fmt.Errorf("解析ASS文件失败: %w", err)
}
// 创建通用字幕结构
subtitle := model.NewSubtitle()
subtitle.Format = "ass"
// 转换标题
if title, ok := assFile.ScriptInfo["Title"]; ok {
subtitle.Title = title
}
// 转换事件为字幕条目
for i, event := range assFile.Events {
// 只转换对话类型的事件
if event.Type == "Dialogue" {
entry := model.SubtitleEntry{
Index: i + 1,
StartTime: event.StartTime,
EndTime: event.EndTime,
Text: event.Text,
Styles: make(map[string]string),
Metadata: make(map[string]string),
}
// 记录样式信息
entry.Styles["style"] = event.Style
// 记录ASS特有信息
entry.Metadata["Layer"] = fmt.Sprintf("%d", event.Layer)
entry.Metadata["Name"] = event.Name
entry.Metadata["MarginL"] = fmt.Sprintf("%d", event.MarginL)
entry.Metadata["MarginR"] = fmt.Sprintf("%d", event.MarginR)
entry.Metadata["MarginV"] = fmt.Sprintf("%d", event.MarginV)
entry.Metadata["Effect"] = event.Effect
subtitle.Entries = append(subtitle.Entries, entry)
}
}
return subtitle, nil
}
// ConvertFromSubtitle 将通用字幕格式转换为ASS文件
func ConvertFromSubtitle(subtitle model.Subtitle, filePath string) error {
// 创建ASS文件结构
assFile := model.NewASSFile()
// 设置标题
if subtitle.Title != "" {
assFile.ScriptInfo["Title"] = subtitle.Title
}
// 转换字幕条目为ASS事件
for _, entry := range subtitle.Entries {
event := model.NewASSEvent()
event.Type = "Dialogue"
event.StartTime = entry.StartTime
event.EndTime = entry.EndTime
event.Text = entry.Text
// 检查是否有ASS特有的元数据
if layer, ok := entry.Metadata["Layer"]; ok {
fmt.Sscanf(layer, "%d", &event.Layer)
}
if name, ok := entry.Metadata["Name"]; ok {
event.Name = name
}
if marginL, ok := entry.Metadata["MarginL"]; ok {
fmt.Sscanf(marginL, "%d", &event.MarginL)
}
if marginR, ok := entry.Metadata["MarginR"]; ok {
fmt.Sscanf(marginR, "%d", &event.MarginR)
}
if marginV, ok := entry.Metadata["MarginV"]; ok {
fmt.Sscanf(marginV, "%d", &event.MarginV)
}
if effect, ok := entry.Metadata["Effect"]; ok {
event.Effect = effect
}
// 处理样式
if style, ok := entry.Styles["style"]; ok {
event.Style = style
} else {
// 根据基本样式设置ASS样式
if _, ok := entry.Styles["bold"]; ok {
// 创建一个加粗样式(如果尚未存在)
styleName := "Bold"
found := false
for _, style := range assFile.Styles {
if style.Name == styleName {
found = true
break
}
}
if !found {
boldStyle := model.ASSStyle{
Name: styleName,
Properties: map[string]string{
"Format": "Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding",
"Style": "Bold,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,1,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1",
},
}
assFile.Styles = append(assFile.Styles, boldStyle)
}
event.Style = styleName
}
if _, ok := entry.Styles["italic"]; ok {
// 创建一个斜体样式(如果尚未存在)
styleName := "Italic"
found := false
for _, style := range assFile.Styles {
if style.Name == styleName {
found = true
break
}
}
if !found {
italicStyle := model.ASSStyle{
Name: styleName,
Properties: map[string]string{
"Format": "Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding",
"Style": "Italic,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,1,0,0,100,100,0,0,1,2,2,2,10,10,10,1",
},
}
assFile.Styles = append(assFile.Styles, italicStyle)
}
event.Style = styleName
}
if _, ok := entry.Styles["underline"]; ok {
// 创建一个下划线样式(如果尚未存在)
styleName := "Underline"
found := false
for _, style := range assFile.Styles {
if style.Name == styleName {
found = true
break
}
}
if !found {
underlineStyle := model.ASSStyle{
Name: styleName,
Properties: map[string]string{
"Format": "Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding",
"Style": "Underline,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,1,0,100,100,0,0,1,2,2,2,10,10,10,1",
},
}
assFile.Styles = append(assFile.Styles, underlineStyle)
}
event.Style = styleName
}
}
assFile.Events = append(assFile.Events, event)
}
// 生成ASS文件
return Generate(assFile, filePath)
}

View file

@ -0,0 +1,210 @@
package ass
import (
"os"
"path/filepath"
"strings"
"testing"
"sub-cli/internal/model"
)
func TestConvertToSubtitle(t *testing.T) {
// Create test ASS file
content := `[Script Info]
ScriptType: v4.00+
Title: Test ASS File
PlayResX: 640
PlayResY: 480
[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1
Style: Bold,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,1,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1
[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
Dialogue: 0,0:00:01.00,0:00:04.00,Default,Character,10,20,30,Fade,This is the first subtitle line.
Dialogue: 1,0:00:05.00,0:00:08.00,Bold,Character,15,25,35,,This is the second subtitle line with bold style.
Comment: 0,0:00:09.00,0:00:12.00,Default,,0,0,0,,This is a comment.
`
tempDir := t.TempDir()
testFile := filepath.Join(tempDir, "convert_test.ass")
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
// Test conversion to Subtitle
subtitle, err := ConvertToSubtitle(testFile)
if err != nil {
t.Fatalf("ConvertToSubtitle failed: %v", err)
}
// Verify results
if subtitle.Format != "ass" {
t.Errorf("Format should be 'ass', got '%s'", subtitle.Format)
}
if subtitle.Title != "Test ASS File" {
t.Errorf("Title should be 'Test ASS File', got '%s'", subtitle.Title)
}
// Only dialogue events should be converted
if len(subtitle.Entries) != 2 {
t.Errorf("Expected 2 subtitle entries, got %d", len(subtitle.Entries))
} else {
// Check first entry
if subtitle.Entries[0].Text != "This is the first subtitle line." {
t.Errorf("First entry text mismatch: got '%s'", subtitle.Entries[0].Text)
}
if subtitle.Entries[0].StartTime.Seconds != 1 || subtitle.Entries[0].EndTime.Seconds != 4 {
t.Errorf("First entry timing mismatch: got %+v - %+v",
subtitle.Entries[0].StartTime, subtitle.Entries[0].EndTime)
}
// Check style conversion
if subtitle.Entries[0].Styles["style"] != "Default" {
t.Errorf("First entry style mismatch: got '%s'", subtitle.Entries[0].Styles["style"])
}
// Check metadata conversion
if subtitle.Entries[0].Metadata["Layer"] != "0" {
t.Errorf("First entry layer mismatch: got '%s'", subtitle.Entries[0].Metadata["Layer"])
}
if subtitle.Entries[0].Metadata["Name"] != "Character" {
t.Errorf("First entry name mismatch: got '%s'", subtitle.Entries[0].Metadata["Name"])
}
if subtitle.Entries[0].Metadata["MarginL"] != "10" ||
subtitle.Entries[0].Metadata["MarginR"] != "20" ||
subtitle.Entries[0].Metadata["MarginV"] != "30" {
t.Errorf("First entry margins mismatch: got L=%s, R=%s, V=%s",
subtitle.Entries[0].Metadata["MarginL"],
subtitle.Entries[0].Metadata["MarginR"],
subtitle.Entries[0].Metadata["MarginV"])
}
if subtitle.Entries[0].Metadata["Effect"] != "Fade" {
t.Errorf("First entry effect mismatch: got '%s'", subtitle.Entries[0].Metadata["Effect"])
}
// Check second entry (Bold style)
if subtitle.Entries[1].Styles["style"] != "Bold" {
t.Errorf("Second entry style mismatch: got '%s'", subtitle.Entries[1].Styles["style"])
}
if subtitle.Entries[1].Metadata["Layer"] != "1" {
t.Errorf("Second entry layer mismatch: got '%s'", subtitle.Entries[1].Metadata["Layer"])
}
}
}
func TestConvertFromSubtitle(t *testing.T) {
// Create test subtitle
subtitle := model.NewSubtitle()
subtitle.Format = "ass"
subtitle.Title = "Test Conversion"
// Create entries
entry1 := model.SubtitleEntry{
Index: 1,
StartTime: model.Timestamp{Seconds: 1},
EndTime: model.Timestamp{Seconds: 4},
Text: "This is the first subtitle line.",
Styles: map[string]string{"style": "Default"},
Metadata: map[string]string{
"Layer": "0",
"Name": "Character",
"MarginL": "10",
"MarginR": "20",
"MarginV": "30",
"Effect": "Fade",
},
}
entry2 := model.SubtitleEntry{
Index: 2,
StartTime: model.Timestamp{Seconds: 5},
EndTime: model.Timestamp{Seconds: 8},
Text: "This is the second subtitle line.",
Styles: map[string]string{"bold": "1"},
}
subtitle.Entries = append(subtitle.Entries, entry1, entry2)
// Convert back to ASS
tempDir := t.TempDir()
outputFile := filepath.Join(tempDir, "convert_back.ass")
err := ConvertFromSubtitle(subtitle, outputFile)
if err != nil {
t.Fatalf("ConvertFromSubtitle failed: %v", err)
}
// Read the generated file
content, err := os.ReadFile(outputFile)
if err != nil {
t.Fatalf("Failed to read generated file: %v", err)
}
contentStr := string(content)
// Verify file content
if !strings.Contains(contentStr, "Title: Test Conversion") {
t.Errorf("Missing or incorrect title in generated file")
}
// Check that both entries were converted correctly
if !strings.Contains(contentStr, "Dialogue: 0,0:00:01.00,0:00:04.00,Default,Character,10,20,30,Fade,This is the first subtitle line.") {
t.Errorf("First entry not converted correctly")
}
// Check that bold style was created and applied
if !strings.Contains(contentStr, "Style: Bold") {
t.Errorf("Bold style not created")
}
if !strings.Contains(contentStr, "Dialogue: 0,0:00:05.00,0:00:08.00,Bold") {
t.Errorf("Second entry not converted with Bold style")
}
// Parse the file again to check structure
assFile, err := Parse(outputFile)
if err != nil {
t.Fatalf("Failed to parse the generated file: %v", err)
}
if len(assFile.Events) != 2 {
t.Errorf("Expected 2 events, got %d", len(assFile.Events))
}
// Check style conversion
var boldStyleFound bool
for _, style := range assFile.Styles {
if style.Name == "Bold" {
boldStyleFound = true
break
}
}
if !boldStyleFound {
t.Errorf("Bold style not found in generated file")
}
}
func TestConvertToSubtitle_FileError(t *testing.T) {
// Test non-existent file
_, err := ConvertToSubtitle("/nonexistent/file.ass")
if err == nil {
t.Error("Converting non-existent file should return an error")
}
}
func TestConvertFromSubtitle_FileError(t *testing.T) {
// Test invalid path
subtitle := model.NewSubtitle()
err := ConvertFromSubtitle(subtitle, "/nonexistent/directory/file.ass")
if err == nil {
t.Error("Converting to invalid path should return an error")
}
}

View file

@ -0,0 +1,17 @@
package ass
import (
"fmt"
)
// Format 格式化ASS文件
func Format(filePath string) error {
// 读取ASS文件
assFile, err := Parse(filePath)
if err != nil {
return fmt.Errorf("解析ASS文件失败: %w", err)
}
// 写回格式化后的ASS文件
return Generate(assFile, filePath)
}

View file

@ -0,0 +1,99 @@
package ass
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestFormat(t *testing.T) {
// Create a test ASS file with non-standard formatting
content := `[Script Info]
ScriptType:v4.00+
Title: Format Test
PlayResX:640
PlayResY: 480
[V4+ Styles]
Format: Name, Fontname,Fontsize,PrimaryColour,SecondaryColour,OutlineColour,BackColour,Bold,Italic,Underline,StrikeOut,ScaleX,ScaleY,Spacing,Angle,BorderStyle,Outline,Shadow,Alignment,MarginL,MarginR,MarginV,Encoding
Style:Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1
[Events]
Format:Layer, Start,End,Style,Name,MarginL,MarginR,MarginV,Effect,Text
Dialogue:0,0:00:01.00,0:00:04.00,Default,,0,0,0,,This is the first subtitle line.
Dialogue: 0,0:00:05.00,0:00:08.00,Default,,0,0,0,,This is the second subtitle line.
`
tempDir := t.TempDir()
testFile := filepath.Join(tempDir, "format_test.ass")
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
// Test format
err := Format(testFile)
if err != nil {
t.Fatalf("Format failed: %v", err)
}
// Read the formatted file
formattedContent, err := os.ReadFile(testFile)
if err != nil {
t.Fatalf("Failed to read formatted file: %v", err)
}
contentStr := string(formattedContent)
// Check for consistency and proper spacing
if !strings.Contains(contentStr, "Title: Format Test") {
t.Errorf("Title should be properly formatted, got: %s", contentStr)
}
// Check style section formatting
if !strings.Contains(contentStr, "Format: Name, Fontname, Fontsize") {
t.Errorf("Style format should be properly spaced, got: %s", contentStr)
}
// Check event section formatting
if !strings.Contains(contentStr, "Dialogue: 0,") {
t.Errorf("Dialogue should be properly formatted, got: %s", contentStr)
}
// Parse formatted file to ensure it's valid
assFile, err := Parse(testFile)
if err != nil {
t.Fatalf("Failed to parse formatted file: %v", err)
}
// Verify basic structure remains intact
if assFile.ScriptInfo["Title"] != "Format Test" {
t.Errorf("Title mismatch after formatting: expected 'Format Test', got '%s'", assFile.ScriptInfo["Title"])
}
if len(assFile.Events) != 2 {
t.Errorf("Expected 2 events after formatting, got %d", len(assFile.Events))
}
}
func TestFormat_NonExistentFile(t *testing.T) {
err := Format("/nonexistent/file.ass")
if err == nil {
t.Error("Formatting non-existent file should return an error")
}
}
func TestFormat_InvalidWritable(t *testing.T) {
// Create a directory instead of a file
tempDir := t.TempDir()
dirAsFile := filepath.Join(tempDir, "dir_as_file")
if err := os.Mkdir(dirAsFile, 0755); err != nil {
t.Fatalf("Failed to create test directory: %v", err)
}
// Try to format a directory
err := Format(dirAsFile)
if err == nil {
t.Error("Formatting a directory should return an error")
}
}

View file

@ -0,0 +1,122 @@
package ass
import (
"fmt"
"os"
"path/filepath"
"strings"
"sub-cli/internal/model"
)
// Generate 生成ASS文件
func Generate(assFile model.ASSFile, filePath string) error {
// 确保目录存在
dir := filepath.Dir(filePath)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("创建目录失败: %w", err)
}
// 创建或覆盖文件
file, err := os.Create(filePath)
if err != nil {
return fmt.Errorf("创建ASS文件失败: %w", err)
}
defer file.Close()
// 写入脚本信息
if _, err := file.WriteString(ASSHeader + "\n"); err != nil {
return fmt.Errorf("写入文件失败: %w", err)
}
for key, value := range assFile.ScriptInfo {
if _, err := file.WriteString(fmt.Sprintf("%s: %s\n", key, value)); err != nil {
return fmt.Errorf("写入文件失败: %w", err)
}
}
// 写入样式信息
if _, err := file.WriteString("\n" + ASSStylesHeader + "\n"); err != nil {
return fmt.Errorf("写入文件失败: %w", err)
}
// 写入样式格式行
if len(assFile.Styles) > 0 {
var formatString string
for _, style := range assFile.Styles {
if formatString == "" && style.Properties["Format"] != "" {
formatString = style.Properties["Format"]
if _, err := file.WriteString(fmt.Sprintf("Format: %s\n", formatString)); err != nil {
return fmt.Errorf("写入文件失败: %w", err)
}
break
}
}
// 如果没有找到格式行,写入默认格式
if formatString == "" {
defaultFormat := "Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding"
if _, err := file.WriteString(fmt.Sprintf("Format: %s\n", defaultFormat)); err != nil {
return fmt.Errorf("写入文件失败: %w", err)
}
}
// 写入样式定义
for _, style := range assFile.Styles {
if style.Properties["Style"] != "" {
if _, err := file.WriteString(fmt.Sprintf("Style: %s\n", style.Properties["Style"])); err != nil {
return fmt.Errorf("写入文件失败: %w", err)
}
}
}
}
// 写入事件信息
if _, err := file.WriteString("\n" + ASSEventsHeader + "\n"); err != nil {
return fmt.Errorf("写入文件失败: %w", err)
}
// 写入事件格式行
if _, err := file.WriteString(DefaultFormat + "\n"); err != nil {
return fmt.Errorf("写入文件失败: %w", err)
}
// 写入事件行
for _, event := range assFile.Events {
eventLine := formatEventLine(event)
if _, err := file.WriteString(eventLine + "\n"); err != nil {
return fmt.Errorf("写入文件失败: %w", err)
}
}
return nil
}
// formatEventLine 将事件格式化为ASS文件中的一行
func formatEventLine(event model.ASSEvent) string {
// 格式化时间戳
startTime := formatASSTimestamp(event.StartTime)
endTime := formatASSTimestamp(event.EndTime)
// 构建事件行
var builder strings.Builder
if event.Type == "Comment" {
builder.WriteString("Comment: ")
} else {
builder.WriteString("Dialogue: ")
}
builder.WriteString(fmt.Sprintf("%d,%s,%s,%s,%s,%d,%d,%d,%s,%s",
event.Layer,
startTime,
endTime,
event.Style,
event.Name,
event.MarginL,
event.MarginR,
event.MarginV,
event.Effect,
event.Text))
return builder.String()
}

View file

@ -0,0 +1,131 @@
package ass
import (
"os"
"path/filepath"
"strings"
"testing"
"sub-cli/internal/model"
)
func TestGenerate(t *testing.T) {
// Create test ASS file structure
assFile := model.NewASSFile()
assFile.ScriptInfo["Title"] = "Generation Test"
// Add a custom style
boldStyle := model.ASSStyle{
Name: "Bold",
Properties: map[string]string{
"Format": "Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding",
"Style": "Bold,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,1,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1",
"Bold": "1",
},
}
assFile.Styles = append(assFile.Styles, boldStyle)
// Add dialogue events
event1 := model.NewASSEvent()
event1.Type = "Dialogue"
event1.StartTime = model.Timestamp{Seconds: 1}
event1.EndTime = model.Timestamp{Seconds: 4}
event1.Style = "Default"
event1.Text = "This is a test subtitle."
event2 := model.NewASSEvent()
event2.Type = "Dialogue"
event2.StartTime = model.Timestamp{Seconds: 5}
event2.EndTime = model.Timestamp{Seconds: 8}
event2.Style = "Bold"
event2.Text = "This is a bold subtitle."
assFile.Events = append(assFile.Events, event1, event2)
// Generate ASS file
tempDir := t.TempDir()
outputFile := filepath.Join(tempDir, "output.ass")
err := Generate(assFile, outputFile)
if err != nil {
t.Fatalf("Generate failed: %v", err)
}
// Read the generated file
content, err := os.ReadFile(outputFile)
if err != nil {
t.Fatalf("Failed to read generated file: %v", err)
}
contentStr := string(content)
// Verify file structure and content
// Check Script Info section
if !strings.Contains(contentStr, "[Script Info]") {
t.Errorf("Missing [Script Info] section")
}
if !strings.Contains(contentStr, "Title: Generation Test") {
t.Errorf("Missing Title in Script Info")
}
// Check Styles section
if !strings.Contains(contentStr, "[V4+ Styles]") {
t.Errorf("Missing [V4+ Styles] section")
}
if !strings.Contains(contentStr, "Style: Bold,Arial,20") {
t.Errorf("Missing Bold style definition")
}
// Check Events section
if !strings.Contains(contentStr, "[Events]") {
t.Errorf("Missing [Events] section")
}
if !strings.Contains(contentStr, "Format: Layer, Start, End, Style,") {
t.Errorf("Missing Format line in Events section")
}
if !strings.Contains(contentStr, "Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,This is a test subtitle.") {
t.Errorf("Missing first dialogue event")
}
if !strings.Contains(contentStr, "Dialogue: 0,0:00:05.00,0:00:08.00,Bold,,0,0,0,,This is a bold subtitle.") {
t.Errorf("Missing second dialogue event")
}
}
func TestGenerate_FileError(t *testing.T) {
// Test invalid path
assFile := model.NewASSFile()
err := Generate(assFile, "/nonexistent/directory/file.ass")
if err == nil {
t.Error("Generating to invalid path should return an error")
}
}
func TestFormatEventLine(t *testing.T) {
event := model.ASSEvent{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Seconds: 1},
EndTime: model.Timestamp{Seconds: 4},
Style: "Default",
Name: "Character",
MarginL: 10,
MarginR: 10,
MarginV: 10,
Effect: "Fade",
Text: "Test text",
}
expected := "Dialogue: 0,0:00:01.00,0:00:04.00,Default,Character,10,10,10,Fade,Test text"
result := formatEventLine(event)
if result != expected {
t.Errorf("Expected: '%s', got: '%s'", expected, result)
}
// Test Comment type
event.Type = "Comment"
expected = "Comment: 0,0:00:01.00,0:00:04.00,Default,Character,10,10,10,Fade,Test text"
result = formatEventLine(event)
if result != expected {
t.Errorf("Expected: '%s', got: '%s'", expected, result)
}
}

View file

@ -0,0 +1,152 @@
package ass
import (
"bufio"
"fmt"
"os"
"strconv"
"strings"
"sub-cli/internal/model"
)
// 常量定义
const (
ASSHeader = "[Script Info]"
ASSStylesHeader = "[V4+ Styles]"
ASSEventsHeader = "[Events]"
DefaultFormat = "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text"
)
// Parse 解析ASS文件为ASSFile结构
func Parse(filePath string) (model.ASSFile, error) {
file, err := os.Open(filePath)
if err != nil {
return model.ASSFile{}, fmt.Errorf("打开ASS文件失败: %w", err)
}
defer file.Close()
result := model.NewASSFile()
scanner := bufio.NewScanner(file)
// 当前解析的区块
currentSection := ""
var styleFormat, eventFormat []string
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, ";") {
// 跳过空行和注释行
continue
}
// 检查章节标题
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
currentSection = line
continue
}
switch currentSection {
case ASSHeader:
// 解析脚本信息
if strings.Contains(line, ":") {
parts := strings.SplitN(line, ":", 2)
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
result.ScriptInfo[key] = value
}
case ASSStylesHeader:
// 解析样式格式行和样式定义
if strings.HasPrefix(line, "Format:") {
formatStr := strings.TrimPrefix(line, "Format:")
styleFormat = parseFormatLine(formatStr)
} else if strings.HasPrefix(line, "Style:") {
styleValues := parseStyleLine(line)
if len(styleFormat) > 0 && len(styleValues) > 0 {
style := model.ASSStyle{
Name: styleValues[0], // 第一个值通常是样式名称
Properties: make(map[string]string),
}
// 将原始格式行保存下来
style.Properties["Format"] = "Name, " + strings.Join(styleFormat[1:], ", ")
style.Properties["Style"] = strings.Join(styleValues, ", ")
// 解析各个样式属性
for i := 0; i < len(styleFormat) && i < len(styleValues); i++ {
style.Properties[styleFormat[i]] = styleValues[i]
}
result.Styles = append(result.Styles, style)
}
}
case ASSEventsHeader:
// 解析事件格式行和对话行
if strings.HasPrefix(line, "Format:") {
formatStr := strings.TrimPrefix(line, "Format:")
eventFormat = parseFormatLine(formatStr)
} else if len(eventFormat) > 0 &&
(strings.HasPrefix(line, "Dialogue:") ||
strings.HasPrefix(line, "Comment:")) {
eventType := "Dialogue"
if strings.HasPrefix(line, "Comment:") {
eventType = "Comment"
line = strings.TrimPrefix(line, "Comment:")
} else {
line = strings.TrimPrefix(line, "Dialogue:")
}
values := parseEventLine(line)
if len(values) >= len(eventFormat) {
event := model.NewASSEvent()
event.Type = eventType
// 填充事件属性
for i, format := range eventFormat {
value := values[i]
switch strings.TrimSpace(format) {
case "Layer":
layer, _ := strconv.Atoi(value)
event.Layer = layer
case "Start":
event.StartTime = parseASSTimestamp(value)
case "End":
event.EndTime = parseASSTimestamp(value)
case "Style":
event.Style = value
case "Name":
event.Name = value
case "MarginL":
marginL, _ := strconv.Atoi(value)
event.MarginL = marginL
case "MarginR":
marginR, _ := strconv.Atoi(value)
event.MarginR = marginR
case "MarginV":
marginV, _ := strconv.Atoi(value)
event.MarginV = marginV
case "Effect":
event.Effect = value
case "Text":
// 文本可能包含逗号,所以需要特殊处理
textStartIndex := strings.Index(line, value)
if textStartIndex >= 0 {
event.Text = line[textStartIndex:]
} else {
event.Text = value
}
}
}
result.Events = append(result.Events, event)
}
}
}
}
return result, nil
}

View file

@ -0,0 +1,148 @@
package ass
import (
"os"
"path/filepath"
"testing"
"sub-cli/internal/model"
)
func TestParse(t *testing.T) {
// Create temporary test file
content := `[Script Info]
ScriptType: v4.00+
Title: Test ASS File
PlayResX: 640
PlayResY: 480
[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1
Style: Bold,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,1,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1
[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,This is the first subtitle line.
Dialogue: 0,0:00:05.00,0:00:08.00,Bold,,0,0,0,,This is the second subtitle line with bold style.
Comment: 0,0:00:09.00,0:00:12.00,Default,,0,0,0,,This is a comment.
`
tempDir := t.TempDir()
testFile := filepath.Join(tempDir, "test.ass")
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
// Test parsing
assFile, err := Parse(testFile)
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
// Verify results
// Script info
if assFile.ScriptInfo["Title"] != "Test ASS File" {
t.Errorf("Title mismatch: expected 'Test ASS File', got '%s'", assFile.ScriptInfo["Title"])
}
if assFile.ScriptInfo["ScriptType"] != "v4.00+" {
t.Errorf("Script type mismatch: expected 'v4.00+', got '%s'", assFile.ScriptInfo["ScriptType"])
}
// Styles
if len(assFile.Styles) != 3 {
t.Errorf("Expected 3 styles, got %d", len(assFile.Styles))
} else {
// Find Bold style
var boldStyle *model.ASSStyle
for i, style := range assFile.Styles {
if style.Name == "Bold" {
boldStyle = &assFile.Styles[i]
break
}
}
if boldStyle == nil {
t.Errorf("Bold style not found")
} else {
boldValue, exists := boldStyle.Properties["Bold"]
if !exists || boldValue != "1" {
t.Errorf("Bold style Bold property mismatch: expected '1', got '%s'", boldValue)
}
}
}
// Events
if len(assFile.Events) != 3 {
t.Errorf("Expected 3 events, got %d", len(assFile.Events))
} else {
// Check first dialogue line
if assFile.Events[0].Type != "Dialogue" {
t.Errorf("First event type mismatch: expected 'Dialogue', got '%s'", assFile.Events[0].Type)
}
if assFile.Events[0].StartTime.Seconds != 1 || assFile.Events[0].StartTime.Milliseconds != 0 {
t.Errorf("First event start time mismatch: expected 00:00:01.00, got %d:%02d:%02d.%03d",
assFile.Events[0].StartTime.Hours, assFile.Events[0].StartTime.Minutes,
assFile.Events[0].StartTime.Seconds, assFile.Events[0].StartTime.Milliseconds)
}
if assFile.Events[0].Text != "This is the first subtitle line." {
t.Errorf("First event text mismatch: expected 'This is the first subtitle line.', got '%s'", assFile.Events[0].Text)
}
// Check second dialogue line (bold style)
if assFile.Events[1].Style != "Bold" {
t.Errorf("Second event style mismatch: expected 'Bold', got '%s'", assFile.Events[1].Style)
}
// Check comment line
if assFile.Events[2].Type != "Comment" {
t.Errorf("Third event type mismatch: expected 'Comment', got '%s'", assFile.Events[2].Type)
}
}
}
func TestParse_EdgeCases(t *testing.T) {
// Test empty file
tempDir := t.TempDir()
emptyFile := filepath.Join(tempDir, "empty.ass")
if err := os.WriteFile(emptyFile, []byte{}, 0644); err != nil {
t.Fatalf("Failed to create empty test file: %v", err)
}
assFile, err := Parse(emptyFile)
if err != nil {
t.Fatalf("Failed to parse empty file: %v", err)
}
if len(assFile.Events) != 0 {
t.Errorf("Empty file should have 0 events, got %d", len(assFile.Events))
}
// Test file missing required sections
malformedContent := `[Script Info]
Title: Missing Sections Test
`
malformedFile := filepath.Join(tempDir, "malformed.ass")
if err := os.WriteFile(malformedFile, []byte(malformedContent), 0644); err != nil {
t.Fatalf("Failed to create malformed file: %v", err)
}
assFile, err = Parse(malformedFile)
if err != nil {
t.Fatalf("Failed to parse malformed file: %v", err)
}
if assFile.ScriptInfo["Title"] != "Missing Sections Test" {
t.Errorf("Should correctly parse the title")
}
if len(assFile.Events) != 0 {
t.Errorf("File missing Events section should have 0 events")
}
}
func TestParse_FileError(t *testing.T) {
// Test non-existent file
_, err := Parse("/nonexistent/file.ass")
if err == nil {
t.Error("Parsing non-existent file should return an error")
}
}

View file

@ -0,0 +1,98 @@
package ass
import (
"fmt"
"regexp"
"strconv"
"strings"
"sub-cli/internal/model"
)
// parseFormatLine 解析格式行中的各个字段
func parseFormatLine(formatStr string) []string {
fields := strings.Split(formatStr, ",")
result := make([]string, 0, len(fields))
for _, field := range fields {
result = append(result, strings.TrimSpace(field))
}
return result
}
// parseStyleLine 解析样式行
func parseStyleLine(line string) []string {
// 去掉"Style:"前缀
styleStr := strings.TrimPrefix(line, "Style:")
return splitCSV(styleStr)
}
// parseEventLine 解析事件行
func parseEventLine(line string) []string {
return splitCSV(line)
}
// splitCSV 拆分CSV格式的字符串但保留Text字段中的逗号
func splitCSV(line string) []string {
var result []string
inText := false
current := ""
for _, char := range line {
if char == ',' && !inText {
result = append(result, strings.TrimSpace(current))
current = ""
} else {
current += string(char)
// 这是个简化处理实际ASS格式更复杂
// 当处理到足够数量的字段后剩余部分都当作Text字段
if len(result) >= 9 {
inText = true
}
}
}
if current != "" {
result = append(result, strings.TrimSpace(current))
}
return result
}
// parseASSTimestamp 解析ASS格式的时间戳 (h:mm:ss.cc)
func parseASSTimestamp(timeStr string) model.Timestamp {
// 匹配 h:mm:ss.cc 格式
re := regexp.MustCompile(`(\d+):(\d+):(\d+)\.(\d+)`)
matches := re.FindStringSubmatch(timeStr)
if len(matches) == 5 {
hours, _ := strconv.Atoi(matches[1])
minutes, _ := strconv.Atoi(matches[2])
seconds, _ := strconv.Atoi(matches[3])
// ASS使用厘秒1/100秒需要转换为毫秒
centiseconds, _ := strconv.Atoi(matches[4])
milliseconds := centiseconds * 10
return model.Timestamp{
Hours: hours,
Minutes: minutes,
Seconds: seconds,
Milliseconds: milliseconds,
}
}
// 返回零时间戳,如果解析失败
return model.Timestamp{}
}
// formatASSTimestamp 格式化为ASS格式的时间戳 (h:mm:ss.cc)
func formatASSTimestamp(timestamp model.Timestamp) string {
// ASS使用厘秒1/100秒
centiseconds := timestamp.Milliseconds / 10
return fmt.Sprintf("%d:%02d:%02d.%02d",
timestamp.Hours,
timestamp.Minutes,
timestamp.Seconds,
centiseconds)
}

View file

@ -0,0 +1,139 @@
package ass
import (
"testing"
"sub-cli/internal/model"
)
func TestParseASSTimestamp(t *testing.T) {
testCases := []struct {
name string
input string
expected model.Timestamp
}{
{
name: "Standard format",
input: "0:00:01.00",
expected: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0},
},
{
name: "With centiseconds",
input: "0:00:01.50",
expected: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 500},
},
{
name: "Complete hours, minutes, seconds",
input: "1:02:03.45",
expected: model.Timestamp{Hours: 1, Minutes: 2, Seconds: 3, Milliseconds: 450},
},
{
name: "Invalid format",
input: "invalid",
expected: model.Timestamp{},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := parseASSTimestamp(tc.input)
if result != tc.expected {
t.Errorf("For input '%s', expected %+v, got %+v", tc.input, tc.expected, result)
}
})
}
}
func TestFormatASSTimestamp(t *testing.T) {
testCases := []struct {
name string
input model.Timestamp
expected string
}{
{
name: "Zero timestamp",
input: model.Timestamp{},
expected: "0:00:00.00",
},
{
name: "Simple seconds",
input: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0},
expected: "0:00:01.00",
},
{
name: "With milliseconds",
input: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 500},
expected: "0:00:01.50",
},
{
name: "Complete timestamp",
input: model.Timestamp{Hours: 1, Minutes: 2, Seconds: 3, Milliseconds: 450},
expected: "1:02:03.45",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := formatASSTimestamp(tc.input)
if result != tc.expected {
t.Errorf("For timestamp %+v, expected '%s', got '%s'", tc.input, tc.expected, result)
}
})
}
}
func TestSplitCSV(t *testing.T) {
testCases := []struct {
name string
input string
expected []string
}{
{
name: "Simple CSV",
input: "Value1, Value2, Value3",
expected: []string{"Value1", "Value2", "Value3"},
},
{
name: "Text field with commas",
input: "0, 00:00:01.00, 00:00:05.00, Default, Name, 0, 0, 0, Effect, Text with, commas",
expected: []string{"0", "00:00:01.00", "00:00:05.00", "Default", "Name", "0", "0", "0", "Effect", "Text with, commas"},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := splitCSV(tc.input)
// Check result length
if len(result) != len(tc.expected) {
t.Errorf("Expected %d values, got %d: %v", len(tc.expected), len(result), result)
return
}
// Check content
for i := range result {
if result[i] != tc.expected[i] {
t.Errorf("At index %d, expected '%s', got '%s'", i, tc.expected[i], result[i])
}
}
})
}
}
func TestParseFormatLine(t *testing.T) {
input := " Name, Fontname, Fontsize, PrimaryColour"
expected := []string{"Name", "Fontname", "Fontsize", "PrimaryColour"}
result := parseFormatLine(input)
if len(result) != len(expected) {
t.Errorf("Expected %d values, got %d: %v", len(expected), len(result), result)
return
}
for i := range result {
if result[i] != expected[i] {
t.Errorf("At index %d, expected '%s', got '%s'", i, expected[i], result[i])
}
}
}