feat: basic ass processing (without style)
This commit is contained in:
parent
8897d7ae90
commit
ebbf516689
10 changed files with 2301 additions and 808 deletions
|
@ -1,7 +1,7 @@
|
||||||
package config
|
package config
|
||||||
|
|
||||||
// Version stores the current application version
|
// Version stores the current application version
|
||||||
const Version = "0.5.1"
|
const Version = "0.5.2"
|
||||||
|
|
||||||
// Usage stores the general usage information
|
// Usage stores the general usage information
|
||||||
const Usage = `Usage: sub-cli [command] [options]
|
const Usage = `Usage: sub-cli [command] [options]
|
||||||
|
@ -17,6 +17,8 @@ const SyncUsage = `Usage: sub-cli sync <source> <target>
|
||||||
Currently supports synchronizing between files of the same format:
|
Currently supports synchronizing between files of the same format:
|
||||||
- LRC to LRC
|
- LRC to LRC
|
||||||
- SRT to SRT
|
- SRT to SRT
|
||||||
|
- VTT to VTT
|
||||||
|
- ASS to ASS
|
||||||
If source and target have different numbers of entries, a warning will be shown.`
|
If source and target have different numbers of entries, a warning will be shown.`
|
||||||
|
|
||||||
// ConvertUsage stores the usage information for the convert command
|
// ConvertUsage stores the usage information for the convert command
|
||||||
|
@ -26,4 +28,5 @@ const ConvertUsage = `Usage: sub-cli convert <source> <target>
|
||||||
.txt Plain text format (No meta/timeline tags, only support as target format)
|
.txt Plain text format (No meta/timeline tags, only support as target format)
|
||||||
.srt SubRip Subtitle format
|
.srt SubRip Subtitle format
|
||||||
.lrc LRC format
|
.lrc LRC format
|
||||||
.vtt WebVTT format`
|
.vtt WebVTT format
|
||||||
|
.ass Advanced SubStation Alpha format`
|
||||||
|
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"sub-cli/internal/format/ass"
|
||||||
"sub-cli/internal/format/lrc"
|
"sub-cli/internal/format/lrc"
|
||||||
"sub-cli/internal/format/srt"
|
"sub-cli/internal/format/srt"
|
||||||
"sub-cli/internal/format/txt"
|
"sub-cli/internal/format/txt"
|
||||||
|
@ -45,6 +46,8 @@ func convertToIntermediate(sourceFile, sourceFormat string) (model.Subtitle, err
|
||||||
return srt.ConvertToSubtitle(sourceFile)
|
return srt.ConvertToSubtitle(sourceFile)
|
||||||
case "vtt":
|
case "vtt":
|
||||||
return vtt.ConvertToSubtitle(sourceFile)
|
return vtt.ConvertToSubtitle(sourceFile)
|
||||||
|
case "ass":
|
||||||
|
return ass.ConvertToSubtitle(sourceFile)
|
||||||
default:
|
default:
|
||||||
return model.Subtitle{}, fmt.Errorf("%w: %s", ErrUnsupportedFormat, sourceFormat)
|
return model.Subtitle{}, fmt.Errorf("%w: %s", ErrUnsupportedFormat, sourceFormat)
|
||||||
}
|
}
|
||||||
|
@ -59,6 +62,8 @@ func convertFromIntermediate(subtitle model.Subtitle, targetFile, targetFormat s
|
||||||
return srt.ConvertFromSubtitle(subtitle, targetFile)
|
return srt.ConvertFromSubtitle(subtitle, targetFile)
|
||||||
case "vtt":
|
case "vtt":
|
||||||
return vtt.ConvertFromSubtitle(subtitle, targetFile)
|
return vtt.ConvertFromSubtitle(subtitle, targetFile)
|
||||||
|
case "ass":
|
||||||
|
return ass.ConvertFromSubtitle(subtitle, targetFile)
|
||||||
case "txt":
|
case "txt":
|
||||||
return txt.GenerateFromSubtitle(subtitle, targetFile)
|
return txt.GenerateFromSubtitle(subtitle, targetFile)
|
||||||
default:
|
default:
|
||||||
|
|
534
internal/format/ass/ass.go
Normal file
534
internal/format/ass/ass.go
Normal file
|
@ -0,0 +1,534 @@
|
||||||
|
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)
|
||||||
|
}
|
529
internal/format/ass/ass_test.go
Normal file
529
internal/format/ass/ass_test.go
Normal file
|
@ -0,0 +1,529 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"sub-cli/internal/format/ass"
|
||||||
"sub-cli/internal/format/lrc"
|
"sub-cli/internal/format/lrc"
|
||||||
"sub-cli/internal/format/srt"
|
"sub-cli/internal/format/srt"
|
||||||
"sub-cli/internal/format/vtt"
|
"sub-cli/internal/format/vtt"
|
||||||
|
@ -21,6 +22,8 @@ func Format(filePath string) error {
|
||||||
return srt.Format(filePath)
|
return srt.Format(filePath)
|
||||||
case "vtt":
|
case "vtt":
|
||||||
return vtt.Format(filePath)
|
return vtt.Format(filePath)
|
||||||
|
case "ass":
|
||||||
|
return ass.Format(filePath)
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unsupported format for formatting: %s", ext)
|
return fmt.Errorf("unsupported format for formatting: %s", ext)
|
||||||
}
|
}
|
||||||
|
|
|
@ -53,6 +53,34 @@ type SubtitleRegion struct {
|
||||||
Settings map[string]string
|
Settings map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ASSEvent represents an event entry in an ASS file (dialogue, comment, etc.)
|
||||||
|
type ASSEvent struct {
|
||||||
|
Type string // Dialogue, Comment, etc.
|
||||||
|
Layer int // Layer number (0-based)
|
||||||
|
StartTime Timestamp // Start time
|
||||||
|
EndTime Timestamp // End time
|
||||||
|
Style string // Style name
|
||||||
|
Name string // Character name
|
||||||
|
MarginL int // Left margin override
|
||||||
|
MarginR int // Right margin override
|
||||||
|
MarginV int // Vertical margin override
|
||||||
|
Effect string // Transition effect
|
||||||
|
Text string // The actual text
|
||||||
|
}
|
||||||
|
|
||||||
|
// ASSStyle represents a style definition in an ASS file
|
||||||
|
type ASSStyle struct {
|
||||||
|
Name string // Style name
|
||||||
|
Properties map[string]string // Font name, size, colors, etc.
|
||||||
|
}
|
||||||
|
|
||||||
|
// ASSFile represents an Advanced SubStation Alpha (ASS) file
|
||||||
|
type ASSFile struct {
|
||||||
|
ScriptInfo map[string]string // Format, Title, ScriptType, etc.
|
||||||
|
Styles []ASSStyle // Style definitions
|
||||||
|
Events []ASSEvent // Dialogue lines
|
||||||
|
}
|
||||||
|
|
||||||
// Creates a new empty Subtitle
|
// Creates a new empty Subtitle
|
||||||
func NewSubtitle() Subtitle {
|
func NewSubtitle() Subtitle {
|
||||||
return Subtitle{
|
return Subtitle{
|
||||||
|
@ -82,3 +110,42 @@ func NewSubtitleRegion(id string) SubtitleRegion {
|
||||||
Settings: make(map[string]string),
|
Settings: make(map[string]string),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewASSFile creates a new empty ASS file structure with minimal defaults
|
||||||
|
func NewASSFile() ASSFile {
|
||||||
|
// Create minimal defaults for a valid ASS file
|
||||||
|
scriptInfo := map[string]string{
|
||||||
|
"ScriptType": "v4.00+",
|
||||||
|
"Collisions": "Normal",
|
||||||
|
"PlayResX": "640",
|
||||||
|
"PlayResY": "480",
|
||||||
|
"Timer": "100.0000",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a default style
|
||||||
|
defaultStyle := ASSStyle{
|
||||||
|
Name: "Default",
|
||||||
|
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": "Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return ASSFile{
|
||||||
|
ScriptInfo: scriptInfo,
|
||||||
|
Styles: []ASSStyle{defaultStyle},
|
||||||
|
Events: []ASSEvent{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewASSEvent creates a new ASS event with default values
|
||||||
|
func NewASSEvent() ASSEvent {
|
||||||
|
return ASSEvent{
|
||||||
|
Type: "Dialogue",
|
||||||
|
Layer: 0,
|
||||||
|
Style: "Default",
|
||||||
|
MarginL: 0,
|
||||||
|
MarginR: 0,
|
||||||
|
MarginV: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -2,6 +2,7 @@ package model
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewSubtitle(t *testing.T) {
|
func TestNewSubtitle(t *testing.T) {
|
||||||
|
@ -98,3 +99,117 @@ func TestNewSubtitleRegion(t *testing.T) {
|
||||||
t.Errorf("Expected settings to contain lines=3, got %s", val)
|
t.Errorf("Expected settings to contain lines=3, got %s", val)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNewASSFile(t *testing.T) {
|
||||||
|
assFile := NewASSFile()
|
||||||
|
|
||||||
|
// Test that script info is initialized with defaults
|
||||||
|
if assFile.ScriptInfo == nil {
|
||||||
|
t.Error("Expected ScriptInfo map to be initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check default script info values
|
||||||
|
expectedDefaults := map[string]string{
|
||||||
|
"ScriptType": "v4.00+",
|
||||||
|
"Collisions": "Normal",
|
||||||
|
"PlayResX": "640",
|
||||||
|
"PlayResY": "480",
|
||||||
|
"Timer": "100.0000",
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, expectedValue := range expectedDefaults {
|
||||||
|
if value, exists := assFile.ScriptInfo[key]; !exists || value != expectedValue {
|
||||||
|
t.Errorf("Expected default ScriptInfo[%s] = %s, got %s", key, expectedValue, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test that styles are initialized
|
||||||
|
if assFile.Styles == nil {
|
||||||
|
t.Error("Expected Styles slice to be initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test that at least the Default style exists
|
||||||
|
if len(assFile.Styles) < 1 {
|
||||||
|
t.Error("Expected at least Default style to be created")
|
||||||
|
} else {
|
||||||
|
defaultStyleFound := false
|
||||||
|
for _, style := range assFile.Styles {
|
||||||
|
if style.Name == "Default" {
|
||||||
|
defaultStyleFound = true
|
||||||
|
|
||||||
|
// Check the style properties of the default style
|
||||||
|
styleStr, exists := style.Properties["Style"]
|
||||||
|
if !exists {
|
||||||
|
t.Error("Expected Default style to have a Style property, but it wasn't found")
|
||||||
|
} else if !strings.Contains(styleStr, ",0,0,0,0,") { // Check that Bold, Italic, Underline, StrikeOut are all 0
|
||||||
|
t.Errorf("Expected Default style to have Bold/Italic/Underline/StrikeOut set to 0, got: %s", styleStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !defaultStyleFound {
|
||||||
|
t.Error("Expected to find a Default style")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test that events are initialized as an empty slice
|
||||||
|
if assFile.Events == nil {
|
||||||
|
t.Error("Expected Events slice to be initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(assFile.Events) != 0 {
|
||||||
|
t.Errorf("Expected 0 events, got %d", len(assFile.Events))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewASSEvent(t *testing.T) {
|
||||||
|
event := NewASSEvent()
|
||||||
|
|
||||||
|
// Test default type
|
||||||
|
if event.Type != "Dialogue" {
|
||||||
|
t.Errorf("Expected Type to be 'Dialogue', got '%s'", event.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test default layer
|
||||||
|
if event.Layer != 0 {
|
||||||
|
t.Errorf("Expected Layer to be 0, got %d", event.Layer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test default style
|
||||||
|
if event.Style != "Default" {
|
||||||
|
t.Errorf("Expected Style to be 'Default', got '%s'", event.Style)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test default name
|
||||||
|
if event.Name != "" {
|
||||||
|
t.Errorf("Expected Name to be empty, got '%s'", event.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test default margins
|
||||||
|
if event.MarginL != 0 || event.MarginR != 0 || event.MarginV != 0 {
|
||||||
|
t.Errorf("Expected all margins to be 0, got L:%d, R:%d, V:%d",
|
||||||
|
event.MarginL, event.MarginR, event.MarginV)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test default effect
|
||||||
|
if event.Effect != "" {
|
||||||
|
t.Errorf("Expected Effect to be empty, got '%s'", event.Effect)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test default text
|
||||||
|
if event.Text != "" {
|
||||||
|
t.Errorf("Expected Text to be empty, got '%s'", event.Text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test start and end times
|
||||||
|
zeroTime := Timestamp{}
|
||||||
|
if event.StartTime != zeroTime {
|
||||||
|
t.Errorf("Expected start time to be zero, got %+v", event.StartTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
if event.EndTime != zeroTime {
|
||||||
|
t.Errorf("Expected end time to be zero, got %+v", event.EndTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"sub-cli/internal/format/ass"
|
||||||
"sub-cli/internal/format/lrc"
|
"sub-cli/internal/format/lrc"
|
||||||
"sub-cli/internal/format/srt"
|
"sub-cli/internal/format/srt"
|
||||||
"sub-cli/internal/format/vtt"
|
"sub-cli/internal/format/vtt"
|
||||||
|
@ -23,8 +24,10 @@ func SyncLyrics(sourceFile, targetFile string) error {
|
||||||
return syncSRTFiles(sourceFile, targetFile)
|
return syncSRTFiles(sourceFile, targetFile)
|
||||||
} else if sourceFmt == "vtt" && targetFmt == "vtt" {
|
} else if sourceFmt == "vtt" && targetFmt == "vtt" {
|
||||||
return syncVTTFiles(sourceFile, targetFile)
|
return syncVTTFiles(sourceFile, targetFile)
|
||||||
|
} else if sourceFmt == "ass" && targetFmt == "ass" {
|
||||||
|
return syncASSFiles(sourceFile, targetFile)
|
||||||
} else {
|
} else {
|
||||||
return fmt.Errorf("sync only supports files of the same format (lrc-to-lrc, srt-to-srt, or vtt-to-vtt)")
|
return fmt.Errorf("sync only supports files of the same format (lrc-to-lrc, srt-to-srt, vtt-to-vtt, or ass-to-ass)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -103,6 +106,31 @@ func syncVTTFiles(sourceFile, targetFile string) error {
|
||||||
return vtt.Generate(syncedSubtitle, targetFile)
|
return vtt.Generate(syncedSubtitle, targetFile)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// syncASSFiles synchronizes two ASS files
|
||||||
|
func syncASSFiles(sourceFile, targetFile string) error {
|
||||||
|
sourceSubtitle, err := ass.Parse(sourceFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error parsing source ASS file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
targetSubtitle, err := ass.Parse(targetFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error parsing target ASS file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if entry counts match
|
||||||
|
if len(sourceSubtitle.Events) != len(targetSubtitle.Events) {
|
||||||
|
fmt.Printf("Warning: Source (%d events) and target (%d events) have different event counts. Timeline will be adjusted.\n",
|
||||||
|
len(sourceSubtitle.Events), len(targetSubtitle.Events))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync the timelines
|
||||||
|
syncedSubtitle := syncASSTimeline(sourceSubtitle, targetSubtitle)
|
||||||
|
|
||||||
|
// Write the synced subtitle to the target file
|
||||||
|
return ass.Generate(syncedSubtitle, targetFile)
|
||||||
|
}
|
||||||
|
|
||||||
// syncLRCTimeline applies the timeline from the source lyrics to the target lyrics
|
// syncLRCTimeline applies the timeline from the source lyrics to the target lyrics
|
||||||
func syncLRCTimeline(source, target model.Lyrics) model.Lyrics {
|
func syncLRCTimeline(source, target model.Lyrics) model.Lyrics {
|
||||||
result := model.Lyrics{
|
result := model.Lyrics{
|
||||||
|
@ -131,6 +159,15 @@ func syncSRTTimeline(sourceEntries, targetEntries []model.SRTEntry) []model.SRTE
|
||||||
// Copy target entries
|
// Copy target entries
|
||||||
copy(result, targetEntries)
|
copy(result, targetEntries)
|
||||||
|
|
||||||
|
// If source is empty, just return the target entries as is
|
||||||
|
if len(sourceEntries) == 0 {
|
||||||
|
// Ensure proper sequence numbering
|
||||||
|
for i := range result {
|
||||||
|
result[i].Number = i + 1
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
// If source and target have the same number of entries, directly apply timings
|
// If source and target have the same number of entries, directly apply timings
|
||||||
if len(sourceEntries) == len(targetEntries) {
|
if len(sourceEntries) == len(targetEntries) {
|
||||||
for i := range result {
|
for i := range result {
|
||||||
|
@ -253,6 +290,51 @@ func syncVTTTimeline(source, target model.Subtitle) model.Subtitle {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// syncASSTimeline applies the timing from source ASS subtitle to target ASS subtitle
|
||||||
|
func syncASSTimeline(source, target model.ASSFile) model.ASSFile {
|
||||||
|
result := model.ASSFile{
|
||||||
|
ScriptInfo: target.ScriptInfo,
|
||||||
|
Styles: target.Styles,
|
||||||
|
Events: make([]model.ASSEvent, len(target.Events)),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy target events to preserve content
|
||||||
|
copy(result.Events, target.Events)
|
||||||
|
|
||||||
|
// If there are no events in either source or target, return as is
|
||||||
|
if len(source.Events) == 0 || len(target.Events) == 0 {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a timeline of source start and end times
|
||||||
|
sourceStartTimes := make([]model.Timestamp, len(source.Events))
|
||||||
|
sourceEndTimes := make([]model.Timestamp, len(source.Events))
|
||||||
|
|
||||||
|
for i, event := range source.Events {
|
||||||
|
sourceStartTimes[i] = event.StartTime
|
||||||
|
sourceEndTimes[i] = event.EndTime
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scale the timeline if source and target have different number of events
|
||||||
|
var scaledStartTimes, scaledEndTimes []model.Timestamp
|
||||||
|
|
||||||
|
if len(source.Events) != len(target.Events) {
|
||||||
|
scaledStartTimes = scaleTimeline(sourceStartTimes, len(target.Events))
|
||||||
|
scaledEndTimes = scaleTimeline(sourceEndTimes, len(target.Events))
|
||||||
|
} else {
|
||||||
|
scaledStartTimes = sourceStartTimes
|
||||||
|
scaledEndTimes = sourceEndTimes
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply scaled timeline to target events
|
||||||
|
for i := range result.Events {
|
||||||
|
result.Events[i].StartTime = scaledStartTimes[i]
|
||||||
|
result.Events[i].EndTime = scaledEndTimes[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
// scaleTimeline scales a timeline to match a different number of entries
|
// scaleTimeline scales a timeline to match a different number of entries
|
||||||
func scaleTimeline(timeline []model.Timestamp, targetCount int) []model.Timestamp {
|
func scaleTimeline(timeline []model.Timestamp, targetCount int) []model.Timestamp {
|
||||||
if targetCount <= 0 || len(timeline) == 0 {
|
if targetCount <= 0 || len(timeline) == 0 {
|
||||||
|
|
File diff suppressed because it is too large
Load diff
15
internal/testdata/test.ass
vendored
Normal file
15
internal/testdata/test.ass
vendored
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
[Script Info]
|
||||||
|
ScriptType: v4.00+
|
||||||
|
PlayResX: 640
|
||||||
|
PlayResY: 480
|
||||||
|
Title: ASS Test File
|
||||||
|
|
||||||
|
[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,,First line
|
||||||
|
Dialogue: 0,0:00:05.00,0:00:08.00,Default,,0,0,0,,Second line
|
||||||
|
Dialogue: 0,0:00:09.00,0:00:12.00,Default,,0,0,0,,Third line
|
Loading…
Add table
Add a link
Reference in a new issue