summaryrefslogtreecommitdiff
path: root/mkvlib/parser
diff options
context:
space:
mode:
authorb5f0d6c3 <[email protected]>2022-04-29 08:31:56 +0800
committerb5f0d6c3 <[email protected]>2022-04-29 08:31:56 +0800
commit9fd8a4735bb7d9127daeb21fa98c78c7ad9e5a7f (patch)
tree01839ecc8526e23aaf8a55654684fc41216d6ef8 /mkvlib/parser
parent31c46c2f40b69911be11fe5adad39dfc295a4f2b (diff)
update mkvlib:new parser
Diffstat (limited to 'mkvlib/parser')
-rw-r--r--mkvlib/parser/ssa.go1298
-rw-r--r--mkvlib/parser/subtitles.go783
2 files changed, 2081 insertions, 0 deletions
diff --git a/mkvlib/parser/ssa.go b/mkvlib/parser/ssa.go
new file mode 100644
index 0000000..9c37ec4
--- /dev/null
+++ b/mkvlib/parser/ssa.go
@@ -0,0 +1,1298 @@
+package parser
+
+import (
+ "bufio"
+ "fmt"
+ "io"
+ "log"
+ "regexp"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/asticode/go-astikit"
+)
+
+// https://www.matroska.org/technical/specs/subtitles/ssa.html
+// http://moodub.free.fr/video/ass-specs.doc
+// https://en.wikipedia.org/wiki/SubStation_Alpha
+
+// SSA alignment
+const (
+ ssaAlignmentCentered = 2
+ ssaAlignmentLeft = 1
+ ssaAlignmentLeftJustifiedTopTitle = 5
+ ssaAlignmentMidTitle = 8
+ ssaAlignmentRight = 3
+ ssaAlignmentTopTitle = 4
+)
+
+// SSA border styles
+const (
+ ssaBorderStyleOpaqueBox = 3
+ ssaBorderStyleOutlineAndDropShadow = 1
+)
+
+// SSA collisions
+const (
+ ssaCollisionsNormal = "Normal"
+ ssaCollisionsReverse = "Reverse"
+)
+
+// SSA event categories
+const (
+ ssaEventCategoryCommand = "Command"
+ ssaEventCategoryComment = "Comment"
+ ssaEventCategoryDialogue = "Dialogue"
+ ssaEventCategoryMovie = "Movie"
+ ssaEventCategoryPicture = "Picture"
+ ssaEventCategorySound = "Sound"
+)
+
+// SSA event format names
+const (
+ ssaEventFormatNameEffect = "Effect"
+ ssaEventFormatNameEnd = "End"
+ ssaEventFormatNameLayer = "Layer"
+ ssaEventFormatNameMarginL = "MarginL"
+ ssaEventFormatNameMarginR = "MarginR"
+ ssaEventFormatNameMarginV = "MarginV"
+ ssaEventFormatNameMarked = "Marked"
+ ssaEventFormatNameName = "Name"
+ ssaEventFormatNameStart = "Start"
+ ssaEventFormatNameStyle = "Style"
+ ssaEventFormatNameText = "Text"
+)
+
+// SSA script info names
+const (
+ ssaScriptInfoNameCollisions = "Collisions"
+ ssaScriptInfoNameOriginalEditing = "Original Editing"
+ ssaScriptInfoNameOriginalScript = "Original Script"
+ ssaScriptInfoNameOriginalTiming = "Original Timing"
+ ssaScriptInfoNameOriginalTranslation = "Original Translation"
+ ssaScriptInfoNamePlayDepth = "PlayDepth"
+ ssaScriptInfoNamePlayResX = "PlayResX"
+ ssaScriptInfoNamePlayResY = "PlayResY"
+ ssaScriptInfoNameScriptType = "ScriptType"
+ ssaScriptInfoNameScriptUpdatedBy = "Script Updated By"
+ ssaScriptInfoNameSynchPoint = "Synch Point"
+ ssaScriptInfoNameTimer = "Timer"
+ ssaScriptInfoNameTitle = "Title"
+ ssaScriptInfoNameUpdateDetails = "Update Details"
+ ssaScriptInfoNameWrapStyle = "WrapStyle"
+)
+
+// SSA section names
+const (
+ ssaSectionNameEvents = "events"
+ ssaSectionNameScriptInfo = "script.info"
+ ssaSectionNameStyles = "styles"
+ ssaSectionNameUnknown = "unknown"
+)
+
+// SSA style format names
+const (
+ ssaStyleFormatNameAlignment = "Alignment"
+ ssaStyleFormatNameAlphaLevel = "AlphaLevel"
+ ssaStyleFormatNameAngle = "Angle"
+ ssaStyleFormatNameBackColour = "BackColour"
+ ssaStyleFormatNameBold = "Bold"
+ ssaStyleFormatNameBorderStyle = "BorderStyle"
+ ssaStyleFormatNameEncoding = "Encoding"
+ ssaStyleFormatNameFontName = "Fontname"
+ ssaStyleFormatNameFontSize = "Fontsize"
+ ssaStyleFormatNameItalic = "Italic"
+ ssaStyleFormatNameMarginL = "MarginL"
+ ssaStyleFormatNameMarginR = "MarginR"
+ ssaStyleFormatNameMarginV = "MarginV"
+ ssaStyleFormatNameName = "Name"
+ ssaStyleFormatNameOutline = "Outline"
+ ssaStyleFormatNameOutlineColour = "OutlineColour"
+ ssaStyleFormatNamePrimaryColour = "PrimaryColour"
+ ssaStyleFormatNameScaleX = "ScaleX"
+ ssaStyleFormatNameScaleY = "ScaleY"
+ ssaStyleFormatNameSecondaryColour = "SecondaryColour"
+ ssaStyleFormatNameShadow = "Shadow"
+ ssaStyleFormatNameSpacing = "Spacing"
+ ssaStyleFormatNameStrikeout = "Strikeout"
+ ssaStyleFormatNameTertiaryColour = "TertiaryColour"
+ ssaStyleFormatNameUnderline = "Underline"
+)
+
+// SSA wrap style
+const (
+ ssaWrapStyleEndOfLineWordWrapping = "1"
+ ssaWrapStyleNoWordWrapping = "2"
+ ssaWrapStyleSmartWrapping = "0"
+ ssaWrapStyleSmartWrappingWithLowerLinesGettingWider = "3"
+)
+
+// SSA regexp
+var ssaRegexpEffect = regexp.MustCompile(`\{[^\{]+\}`)
+
+// ReadFromSSA parses an .ssa content
+func ReadFromSSA(i io.Reader) (o *Subtitles, err error) {
+ o, err = ReadFromSSAWithOptions(i, defaultSSAOptions())
+ return o, err
+}
+
+// ReadFromSSAWithOptions parses an .ssa content
+func ReadFromSSAWithOptions(i io.Reader, opts SSAOptions) (o *Subtitles, err error) {
+ // Init
+ o = NewSubtitles()
+ var scanner = bufio.NewScanner(i)
+ var si = &ssaScriptInfo{}
+ var ss = []*ssaStyle{}
+ var es = []*ssaEvent{}
+
+ // Scan
+ var line, sectionName string
+ var format map[int]string
+ isFirstLine := true
+ scanner.Buffer(make([]byte, 0), 1024*1024*1024)
+ for scanner.Scan() {
+ // Fetch line
+ line = strings.TrimSpace(scanner.Text())
+
+ // Remove BOM header
+ if isFirstLine {
+ line = strings.TrimPrefix(line, string(BytesBOM))
+ isFirstLine = false
+ }
+
+ // Empty line
+ if len(line) == 0 {
+ continue
+ }
+
+ // Section name
+ if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
+ switch strings.ToLower(line[1 : len(line)-1]) {
+ case "events":
+ sectionName = ssaSectionNameEvents
+ format = make(map[int]string)
+ continue
+ case "script info":
+ sectionName = ssaSectionNameScriptInfo
+ continue
+ case "v4 styles", "v4+ styles", "v4 styles+":
+ sectionName = ssaSectionNameStyles
+ format = make(map[int]string)
+ continue
+ default:
+ if opts.OnUnknownSectionName != nil {
+ opts.OnUnknownSectionName(line)
+ }
+ sectionName = ssaSectionNameUnknown
+ continue
+ }
+ }
+
+ // Unknown section
+ if sectionName == ssaSectionNameUnknown {
+ continue
+ }
+
+ // Comment
+ if len(line) > 0 && line[0] == ';' {
+ si.comments = append(si.comments, strings.TrimSpace(line[1:]))
+ continue
+ }
+
+ // Split on ":"
+ var split = strings.Split(line, ":")
+ if len(split) < 2 || split[0] == "" {
+ if opts.OnInvalidLine != nil {
+ opts.OnInvalidLine(line)
+ }
+ continue
+ }
+ var header = strings.TrimSpace(split[0])
+ var content = strings.TrimSpace(strings.Join(split[1:], ":"))
+
+ // Switch on section name
+ switch sectionName {
+ case ssaSectionNameScriptInfo:
+ if err = si.parse(header, content); err != nil {
+ err = fmt.Errorf("astisub: parsing script info block failed: %w", err)
+ return
+ }
+ case ssaSectionNameEvents, ssaSectionNameStyles:
+ // Parse format
+ if header == "Format" {
+ for idx, item := range strings.Split(content, ",") {
+ format[idx] = strings.TrimSpace(item)
+ }
+ } else {
+ // No format provided
+ if len(format) == 0 {
+ err = fmt.Errorf("astisub: no %s format provided", sectionName)
+ return
+ }
+
+ // Switch on section name
+ switch sectionName {
+ case ssaSectionNameEvents:
+ var e *ssaEvent
+ if e, err = newSSAEventFromString(header, content, format); err != nil {
+ err = fmt.Errorf("astisub: building new ssa event failed: %w", err)
+ return
+ }
+ es = append(es, e)
+ case ssaSectionNameStyles:
+ var s *ssaStyle
+ if s, err = newSSAStyleFromString(content, format); err != nil {
+ err = fmt.Errorf("astisub: building new ssa style failed: %w", err)
+ return
+ }
+ ss = append(ss, s)
+ }
+ }
+ }
+ }
+
+ // Set metadata
+ o.Metadata = si.metadata()
+
+ // Loop through styles
+ for _, s := range ss {
+ var st = s.style()
+ o.Styles[st.ID] = st
+ }
+
+ // Loop through events
+ for _, e := range es {
+ // Only process dialogues
+ if e.category == ssaEventCategoryDialogue {
+ // Build item
+ var item *Item
+ if item, err = e.item(o.Styles); err != nil {
+ return
+ }
+
+ // Append item
+ o.Items = append(o.Items, item)
+ }
+ }
+ return
+}
+
+// newColorFromSSAColor builds a new color based on an SSA color
+func newColorFromSSAColor(i string) (_ *Color, _ error) {
+ // Empty
+ if len(i) == 0 {
+ return
+ }
+
+ // Check whether input is decimal or hexadecimal
+ var s = i
+ var base = 10
+ if strings.HasPrefix(i, "&H") {
+ s = i[2:]
+ base = 16
+ }
+ return newColorFromSSAString(s, base)
+}
+
+// newSSAColorFromColor builds a new SSA color based on a color
+func newSSAColorFromColor(i *Color) string {
+ return "&H" + i.SSAString()
+}
+
+// ssaScriptInfo represents an SSA script info block
+type ssaScriptInfo struct {
+ collisions string
+ comments []string
+ originalEditing string
+ originalScript string
+ originalTiming string
+ originalTranslation string
+ playDepth *int
+ playResX, playResY *int
+ scriptType string
+ scriptUpdatedBy string
+ synchPoint string
+ timer *float64
+ title string
+ updateDetails string
+ wrapStyle string
+}
+
+// newSSAScriptInfo builds an SSA script info block based on metadata
+func newSSAScriptInfo(m *Metadata) (o *ssaScriptInfo) {
+ // Init
+ o = &ssaScriptInfo{}
+
+ // Add metadata
+ if m != nil {
+ o.collisions = m.SSACollisions
+ o.comments = m.Comments
+ o.originalEditing = m.SSAOriginalEditing
+ o.originalScript = m.SSAOriginalScript
+ o.originalTiming = m.SSAOriginalTiming
+ o.originalTranslation = m.SSAOriginalTranslation
+ o.playDepth = m.SSAPlayDepth
+ o.playResX = m.SSAPlayResX
+ o.playResY = m.SSAPlayResY
+ o.scriptType = m.SSAScriptType
+ o.scriptUpdatedBy = m.SSAScriptUpdatedBy
+ o.synchPoint = m.SSASynchPoint
+ o.timer = m.SSATimer
+ o.title = m.Title
+ o.updateDetails = m.SSAUpdateDetails
+ o.wrapStyle = m.SSAWrapStyle
+ }
+ return
+}
+
+// parse parses a script info header/content
+func (b *ssaScriptInfo) parse(header, content string) (err error) {
+ switch header {
+ case ssaScriptInfoNameCollisions:
+ b.collisions = content
+ case ssaScriptInfoNameOriginalEditing:
+ b.originalEditing = content
+ case ssaScriptInfoNameOriginalScript:
+ b.originalScript = content
+ case ssaScriptInfoNameOriginalTiming:
+ b.originalTiming = content
+ case ssaScriptInfoNameOriginalTranslation:
+ b.originalTranslation = content
+ case ssaScriptInfoNameScriptType:
+ b.scriptType = content
+ case ssaScriptInfoNameScriptUpdatedBy:
+ b.scriptUpdatedBy = content
+ case ssaScriptInfoNameSynchPoint:
+ b.synchPoint = content
+ case ssaScriptInfoNameTitle:
+ b.title = content
+ case ssaScriptInfoNameUpdateDetails:
+ b.updateDetails = content
+ case ssaScriptInfoNameWrapStyle:
+ b.wrapStyle = content
+ // Int
+ case ssaScriptInfoNamePlayResX, ssaScriptInfoNamePlayResY, ssaScriptInfoNamePlayDepth:
+ var v int
+ if v, err = strconv.Atoi(content); err != nil {
+ err = fmt.Errorf("astisub: atoi of %s failed: %w", content, err)
+ }
+ switch header {
+ case ssaScriptInfoNamePlayDepth:
+ b.playDepth = astikit.IntPtr(v)
+ case ssaScriptInfoNamePlayResX:
+ b.playResX = astikit.IntPtr(v)
+ case ssaScriptInfoNamePlayResY:
+ b.playResY = astikit.IntPtr(v)
+ }
+ // Float
+ case ssaScriptInfoNameTimer:
+ var v float64
+ if v, err = strconv.ParseFloat(strings.Replace(content, ",", ".", -1), 64); err != nil {
+ err = fmt.Errorf("astisub: parseFloat of %s failed: %w", content, err)
+ }
+ b.timer = astikit.Float64Ptr(v)
+ }
+ return
+}
+
+// metadata returns the block as Metadata
+func (b *ssaScriptInfo) metadata() *Metadata {
+ return &Metadata{
+ Comments: b.comments,
+ SSACollisions: b.collisions,
+ SSAOriginalEditing: b.originalEditing,
+ SSAOriginalScript: b.originalScript,
+ SSAOriginalTiming: b.originalTiming,
+ SSAOriginalTranslation: b.originalTranslation,
+ SSAPlayDepth: b.playDepth,
+ SSAPlayResX: b.playResX,
+ SSAPlayResY: b.playResY,
+ SSAScriptType: b.scriptType,
+ SSAScriptUpdatedBy: b.scriptUpdatedBy,
+ SSASynchPoint: b.synchPoint,
+ SSATimer: b.timer,
+ SSAUpdateDetails: b.updateDetails,
+ SSAWrapStyle: b.wrapStyle,
+ Title: b.title,
+ }
+}
+
+// bytes returns the block as bytes
+func (b *ssaScriptInfo) bytes() (o []byte) {
+ o = []byte("[Script Info]")
+ o = append(o, bytesLineSeparator...)
+ for _, c := range b.comments {
+ o = appendStringToBytesWithNewLine(o, "; "+c)
+ }
+ if len(b.collisions) > 0 {
+ o = appendStringToBytesWithNewLine(o, ssaScriptInfoNameCollisions+": "+b.collisions)
+ }
+ if len(b.originalEditing) > 0 {
+ o = appendStringToBytesWithNewLine(o, ssaScriptInfoNameOriginalEditing+": "+b.originalEditing)
+ }
+ if len(b.originalScript) > 0 {
+ o = appendStringToBytesWithNewLine(o, ssaScriptInfoNameOriginalScript+": "+b.originalScript)
+ }
+ if len(b.originalTiming) > 0 {
+ o = appendStringToBytesWithNewLine(o, ssaScriptInfoNameOriginalTiming+": "+b.originalTiming)
+ }
+ if len(b.originalTranslation) > 0 {
+ o = appendStringToBytesWithNewLine(o, ssaScriptInfoNameOriginalTranslation+": "+b.originalTranslation)
+ }
+ if b.playDepth != nil {
+ o = appendStringToBytesWithNewLine(o, ssaScriptInfoNamePlayDepth+": "+strconv.Itoa(*b.playDepth))
+ }
+ if b.playResX != nil {
+ o = appendStringToBytesWithNewLine(o, ssaScriptInfoNamePlayResX+": "+strconv.Itoa(*b.playResX))
+ }
+ if b.playResY != nil {
+ o = appendStringToBytesWithNewLine(o, ssaScriptInfoNamePlayResY+": "+strconv.Itoa(*b.playResY))
+ }
+ if len(b.scriptType) > 0 {
+ o = appendStringToBytesWithNewLine(o, ssaScriptInfoNameScriptType+": "+b.scriptType)
+ }
+ if len(b.scriptUpdatedBy) > 0 {
+ o = appendStringToBytesWithNewLine(o, ssaScriptInfoNameScriptUpdatedBy+": "+b.scriptUpdatedBy)
+ }
+ if len(b.synchPoint) > 0 {
+ o = appendStringToBytesWithNewLine(o, ssaScriptInfoNameSynchPoint+": "+b.synchPoint)
+ }
+ if b.timer != nil {
+ o = appendStringToBytesWithNewLine(o, ssaScriptInfoNameTimer+": "+strings.Replace(strconv.FormatFloat(*b.timer, 'f', -1, 64), ".", ",", -1))
+ }
+ if len(b.title) > 0 {
+ o = appendStringToBytesWithNewLine(o, ssaScriptInfoNameTitle+": "+b.title)
+ }
+ if len(b.updateDetails) > 0 {
+ o = appendStringToBytesWithNewLine(o, ssaScriptInfoNameUpdateDetails+": "+b.updateDetails)
+ }
+ if len(b.wrapStyle) > 0 {
+ o = appendStringToBytesWithNewLine(o, ssaScriptInfoNameWrapStyle+": "+b.wrapStyle)
+ }
+ return
+}
+
+// ssaStyle represents an SSA style
+type ssaStyle struct {
+ alignment *int
+ alphaLevel *float64
+ angle *float64 // degrees
+ backColour *Color
+ bold *bool
+ borderStyle *int
+ encoding *int
+ fontName string
+ fontSize *float64
+ italic *bool
+ outline *float64 // pixels
+ outlineColour *Color
+ marginLeft *int // pixels
+ marginRight *int // pixels
+ marginVertical *int // pixels
+ name string
+ primaryColour *Color
+ scaleX *float64 // %
+ scaleY *float64 // %
+ secondaryColour *Color
+ shadow *float64 // pixels
+ spacing *float64 // pixels
+ strikeout *bool
+ underline *bool
+}
+
+// newSSAStyleFromStyle returns an SSA style based on a Style
+func newSSAStyleFromStyle(i Style) *ssaStyle {
+ return &ssaStyle{
+ alignment: i.InlineStyle.SSAAlignment,
+ alphaLevel: i.InlineStyle.SSAAlphaLevel,
+ angle: i.InlineStyle.SSAAngle,
+ backColour: i.InlineStyle.SSABackColour,
+ bold: i.InlineStyle.SSABold,
+ borderStyle: i.InlineStyle.SSABorderStyle,
+ encoding: i.InlineStyle.SSAEncoding,
+ fontName: i.InlineStyle.SSAFontName,
+ fontSize: i.InlineStyle.SSAFontSize,
+ italic: i.InlineStyle.SSAItalic,
+ outline: i.InlineStyle.SSAOutline,
+ outlineColour: i.InlineStyle.SSAOutlineColour,
+ marginLeft: i.InlineStyle.SSAMarginLeft,
+ marginRight: i.InlineStyle.SSAMarginRight,
+ marginVertical: i.InlineStyle.SSAMarginVertical,
+ name: i.ID,
+ primaryColour: i.InlineStyle.SSAPrimaryColour,
+ scaleX: i.InlineStyle.SSAScaleX,
+ scaleY: i.InlineStyle.SSAScaleY,
+ secondaryColour: i.InlineStyle.SSASecondaryColour,
+ shadow: i.InlineStyle.SSAShadow,
+ spacing: i.InlineStyle.SSASpacing,
+ strikeout: i.InlineStyle.SSAStrikeout,
+ underline: i.InlineStyle.SSAUnderline,
+ }
+}
+
+// newSSAStyleFromString returns an SSA style based on an input string and a format
+func newSSAStyleFromString(content string, format map[int]string) (s *ssaStyle, err error) {
+ // Split content
+ var items = strings.Split(content, ",")
+
+ // Not enough items
+ if len(items) < len(format) {
+ err = fmt.Errorf("astisub: content has %d items whereas style format has %d items", len(items), len(format))
+ return
+ }
+
+ // Loop through items
+ s = &ssaStyle{}
+ for idx, item := range items {
+ // Index not found in format
+ var attr string
+ var ok bool
+ if attr, ok = format[idx]; !ok {
+ err = fmt.Errorf("astisub: index %d not found in style format %+v", idx, format)
+ return
+ }
+
+ // Switch on attribute name
+ switch attr {
+ // Bool
+ case ssaStyleFormatNameBold, ssaStyleFormatNameItalic, ssaStyleFormatNameStrikeout,
+ ssaStyleFormatNameUnderline:
+ var b = item == "-1"
+ switch attr {
+ case ssaStyleFormatNameBold:
+ s.bold = astikit.BoolPtr(b)
+ case ssaStyleFormatNameItalic:
+ s.italic = astikit.BoolPtr(b)
+ case ssaStyleFormatNameStrikeout:
+ s.strikeout = astikit.BoolPtr(b)
+ case ssaStyleFormatNameUnderline:
+ s.underline = astikit.BoolPtr(b)
+ }
+ // Color
+ case ssaStyleFormatNamePrimaryColour, ssaStyleFormatNameSecondaryColour,
+ ssaStyleFormatNameTertiaryColour, ssaStyleFormatNameOutlineColour, ssaStyleFormatNameBackColour:
+ // Build color
+ var c *Color
+ if c, err = newColorFromSSAColor(item); err != nil {
+ err = fmt.Errorf("astisub: building new %s from ssa color %s failed: %w", attr, item, err)
+ return
+ }
+
+ // Set color
+ switch attr {
+ case ssaStyleFormatNameBackColour:
+ s.backColour = c
+ case ssaStyleFormatNamePrimaryColour:
+ s.primaryColour = c
+ case ssaStyleFormatNameSecondaryColour:
+ s.secondaryColour = c
+ case ssaStyleFormatNameTertiaryColour, ssaStyleFormatNameOutlineColour:
+ s.outlineColour = c
+ }
+ // Float
+ case ssaStyleFormatNameAlphaLevel, ssaStyleFormatNameAngle, ssaStyleFormatNameFontSize,
+ ssaStyleFormatNameScaleX, ssaStyleFormatNameScaleY,
+ ssaStyleFormatNameOutline, ssaStyleFormatNameShadow, ssaStyleFormatNameSpacing:
+ // Parse float
+ var f float64
+ if f, err = strconv.ParseFloat(item, 64); err != nil {
+ err = fmt.Errorf("astisub: parsing float %s failed: %w", item, err)
+ return
+ }
+
+ // Set float
+ switch attr {
+ case ssaStyleFormatNameAlphaLevel:
+ s.alphaLevel = astikit.Float64Ptr(f)
+ case ssaStyleFormatNameAngle:
+ s.angle = astikit.Float64Ptr(f)
+ case ssaStyleFormatNameFontSize:
+ s.fontSize = astikit.Float64Ptr(f)
+ case ssaStyleFormatNameScaleX:
+ s.scaleX = astikit.Float64Ptr(f)
+ case ssaStyleFormatNameScaleY:
+ s.scaleY = astikit.Float64Ptr(f)
+ case ssaStyleFormatNameOutline:
+ s.outline = astikit.Float64Ptr(f)
+ case ssaStyleFormatNameShadow:
+ s.shadow = astikit.Float64Ptr(f)
+ case ssaStyleFormatNameSpacing:
+ s.spacing = astikit.Float64Ptr(f)
+ }
+ // Int
+ case ssaStyleFormatNameAlignment, ssaStyleFormatNameBorderStyle, ssaStyleFormatNameEncoding,
+ ssaStyleFormatNameMarginL, ssaStyleFormatNameMarginR, ssaStyleFormatNameMarginV:
+ // Parse int
+ var i int
+ if i, err = strconv.Atoi(item); err != nil {
+ err = fmt.Errorf("astisub: atoi of %s failed: %w", item, err)
+ return
+ }
+
+ // Set int
+ switch attr {
+ case ssaStyleFormatNameAlignment:
+ s.alignment = astikit.IntPtr(i)
+ case ssaStyleFormatNameBorderStyle:
+ s.borderStyle = astikit.IntPtr(i)
+ case ssaStyleFormatNameEncoding:
+ s.encoding = astikit.IntPtr(i)
+ case ssaStyleFormatNameMarginL:
+ s.marginLeft = astikit.IntPtr(i)
+ case ssaStyleFormatNameMarginR:
+ s.marginRight = astikit.IntPtr(i)
+ case ssaStyleFormatNameMarginV:
+ s.marginVertical = astikit.IntPtr(i)
+ }
+ // String
+ case ssaStyleFormatNameFontName, ssaStyleFormatNameName:
+ switch attr {
+ case ssaStyleFormatNameFontName:
+ s.fontName = item
+ case ssaStyleFormatNameName:
+ s.name = item
+ }
+ }
+ }
+ return
+}
+
+// ssaUpdateFormat updates an SSA format
+func ssaUpdateFormat(n string, formatMap map[string]bool, format []string) []string {
+ if _, ok := formatMap[n]; !ok {
+ formatMap[n] = true
+ format = append(format, n)
+ }
+ return format
+}
+
+// updateFormat updates the format based on the non empty fields
+func (s ssaStyle) updateFormat(formatMap map[string]bool, format []string) []string {
+ if s.alignment != nil {
+ format = ssaUpdateFormat(ssaStyleFormatNameAlignment, formatMap, format)
+ }
+ if s.alphaLevel != nil {
+ format = ssaUpdateFormat(ssaStyleFormatNameAlphaLevel, formatMap, format)
+ }
+ if s.angle != nil {
+ format = ssaUpdateFormat(ssaStyleFormatNameAngle, formatMap, format)
+ }
+ if s.backColour != nil {
+ format = ssaUpdateFormat(ssaStyleFormatNameBackColour, formatMap, format)
+ }
+ if s.bold != nil {
+ format = ssaUpdateFormat(ssaStyleFormatNameBold, formatMap, format)
+ }
+ if s.borderStyle != nil {
+ format = ssaUpdateFormat(ssaStyleFormatNameBorderStyle, formatMap, format)
+ }
+ if s.encoding != nil {
+ format = ssaUpdateFormat(ssaStyleFormatNameEncoding, formatMap, format)
+ }
+ if len(s.fontName) > 0 {
+ format = ssaUpdateFormat(ssaStyleFormatNameFontName, formatMap, format)
+ }
+ if s.fontSize != nil {
+ format = ssaUpdateFormat(ssaStyleFormatNameFontSize, formatMap, format)
+ }
+ if s.italic != nil {
+ format = ssaUpdateFormat(ssaStyleFormatNameItalic, formatMap, format)
+ }
+ if s.marginLeft != nil {
+ format = ssaUpdateFormat(ssaStyleFormatNameMarginL, formatMap, format)
+ }
+ if s.marginRight != nil {
+ format = ssaUpdateFormat(ssaStyleFormatNameMarginR, formatMap, format)
+ }
+ if s.marginVertical != nil {
+ format = ssaUpdateFormat(ssaStyleFormatNameMarginV, formatMap, format)
+ }
+ if s.outline != nil {
+ format = ssaUpdateFormat(ssaStyleFormatNameOutline, formatMap, format)
+ }
+ if s.outlineColour != nil {
+ format = ssaUpdateFormat(ssaStyleFormatNameOutlineColour, formatMap, format)
+ }
+ if s.primaryColour != nil {
+ format = ssaUpdateFormat(ssaStyleFormatNamePrimaryColour, formatMap, format)
+ }
+ if s.scaleX != nil {
+ format = ssaUpdateFormat(ssaStyleFormatNameScaleX, formatMap, format)
+ }
+ if s.scaleY != nil {
+ format = ssaUpdateFormat(ssaStyleFormatNameScaleY, formatMap, format)
+ }
+ if s.secondaryColour != nil {
+ format = ssaUpdateFormat(ssaStyleFormatNameSecondaryColour, formatMap, format)
+ }
+ if s.shadow != nil {
+ format = ssaUpdateFormat(ssaStyleFormatNameShadow, formatMap, format)
+ }
+ if s.spacing != nil {
+ format = ssaUpdateFormat(ssaStyleFormatNameSpacing, formatMap, format)
+ }
+ if s.strikeout != nil {
+ format = ssaUpdateFormat(ssaStyleFormatNameStrikeout, formatMap, format)
+ }
+ if s.underline != nil {
+ format = ssaUpdateFormat(ssaStyleFormatNameUnderline, formatMap, format)
+ }
+ return format
+}
+
+// string returns the block as a string
+func (s ssaStyle) string(format []string) string {
+ var ss = []string{s.name}
+ for _, attr := range format {
+ var v string
+ var found = true
+ switch attr {
+ // Bool
+ case ssaStyleFormatNameBold, ssaStyleFormatNameItalic, ssaStyleFormatNameStrikeout,
+ ssaStyleFormatNameUnderline:
+ var b *bool
+ switch attr {
+ case ssaStyleFormatNameBold:
+ b = s.bold
+ case ssaStyleFormatNameItalic:
+ b = s.italic
+ case ssaStyleFormatNameStrikeout:
+ b = s.strikeout
+ case ssaStyleFormatNameUnderline:
+ b = s.underline
+ }
+ if b != nil {
+ v = "0"
+ if *b {
+ v = "1"
+ }
+ }
+ // Color
+ case ssaStyleFormatNamePrimaryColour, ssaStyleFormatNameSecondaryColour,
+ ssaStyleFormatNameOutlineColour, ssaStyleFormatNameBackColour:
+ var c *Color
+ switch attr {
+ case ssaStyleFormatNameBackColour:
+ c = s.backColour
+ case ssaStyleFormatNamePrimaryColour:
+ c = s.primaryColour
+ case ssaStyleFormatNameSecondaryColour:
+ c = s.secondaryColour
+ case ssaStyleFormatNameOutlineColour:
+ c = s.outlineColour
+ }
+ if c != nil {
+ v = newSSAColorFromColor(c)
+ }
+ // Float
+ case ssaStyleFormatNameAlphaLevel, ssaStyleFormatNameAngle, ssaStyleFormatNameFontSize,
+ ssaStyleFormatNameScaleX, ssaStyleFormatNameScaleY,
+ ssaStyleFormatNameOutline, ssaStyleFormatNameShadow, ssaStyleFormatNameSpacing:
+ var f *float64
+ switch attr {
+ case ssaStyleFormatNameAlphaLevel:
+ f = s.alphaLevel
+ case ssaStyleFormatNameAngle:
+ f = s.angle
+ case ssaStyleFormatNameFontSize:
+ f = s.fontSize
+ case ssaStyleFormatNameScaleX:
+ f = s.scaleX
+ case ssaStyleFormatNameScaleY:
+ f = s.scaleY
+ case ssaStyleFormatNameOutline:
+ f = s.outline
+ case ssaStyleFormatNameShadow:
+ f = s.shadow
+ case ssaStyleFormatNameSpacing:
+ f = s.spacing
+ }
+ if f != nil {
+ v = strconv.FormatFloat(*f, 'f', 3, 64)
+ }
+ // Int
+ case ssaStyleFormatNameAlignment, ssaStyleFormatNameBorderStyle, ssaStyleFormatNameEncoding,
+ ssaStyleFormatNameMarginL, ssaStyleFormatNameMarginR, ssaStyleFormatNameMarginV:
+ var i *int
+ switch attr {
+ case ssaStyleFormatNameAlignment:
+ i = s.alignment
+ case ssaStyleFormatNameBorderStyle:
+ i = s.borderStyle
+ case ssaStyleFormatNameEncoding:
+ i = s.encoding
+ case ssaStyleFormatNameMarginL:
+ i = s.marginLeft
+ case ssaStyleFormatNameMarginR:
+ i = s.marginRight
+ case ssaStyleFormatNameMarginV:
+ i = s.marginVertical
+ }
+ if i != nil {
+ v = strconv.Itoa(*i)
+ }
+ // String
+ case ssaStyleFormatNameFontName:
+ switch attr {
+ case ssaStyleFormatNameFontName:
+ v = s.fontName
+ }
+ default:
+ found = false
+ }
+ if found {
+ ss = append(ss, v)
+ }
+ }
+ return strings.Join(ss, ",")
+}
+
+// style converts ssaStyle to Style
+func (s ssaStyle) style() (o *Style) {
+ o = &Style{
+ ID: s.name,
+ InlineStyle: &StyleAttributes{
+ SSAAlignment: s.alignment,
+ SSAAlphaLevel: s.alphaLevel,
+ SSAAngle: s.angle,
+ SSABackColour: s.backColour,
+ SSABold: s.bold,
+ SSABorderStyle: s.borderStyle,
+ SSAEncoding: s.encoding,
+ SSAFontName: s.fontName,
+ SSAFontSize: s.fontSize,
+ SSAItalic: s.italic,
+ SSAOutline: s.outline,
+ SSAOutlineColour: s.outlineColour,
+ SSAMarginLeft: s.marginLeft,
+ SSAMarginRight: s.marginRight,
+ SSAMarginVertical: s.marginVertical,
+ SSAPrimaryColour: s.primaryColour,
+ SSAScaleX: s.scaleX,
+ SSAScaleY: s.scaleY,
+ SSASecondaryColour: s.secondaryColour,
+ SSAShadow: s.shadow,
+ SSASpacing: s.spacing,
+ SSAStrikeout: s.strikeout,
+ SSAUnderline: s.underline,
+ },
+ }
+ o.InlineStyle.propagateSSAAttributes()
+ return
+}
+
+// ssaEvent represents an SSA event
+type ssaEvent struct {
+ category string
+ effect string
+ end time.Duration
+ layer *int
+ marked *bool
+ marginLeft *int // pixels
+ marginRight *int // pixels
+ marginVertical *int // pixels
+ name string
+ start time.Duration
+ style string
+ text string
+}
+
+// newSSAEventFromItem returns an SSA Event based on an input item
+func newSSAEventFromItem(i Item) (e *ssaEvent) {
+ // Init
+ e = &ssaEvent{
+ category: ssaEventCategoryDialogue,
+ end: i.EndAt,
+ start: i.StartAt,
+ }
+
+ // Style
+ if i.Style != nil {
+ e.style = i.Style.ID
+ }
+
+ // Inline style
+ if i.InlineStyle != nil {
+ e.effect = i.InlineStyle.SSAEffect
+ e.layer = i.InlineStyle.SSALayer
+ e.marginLeft = i.InlineStyle.SSAMarginLeft
+ e.marginRight = i.InlineStyle.SSAMarginRight
+ e.marginVertical = i.InlineStyle.SSAMarginVertical
+ e.marked = i.InlineStyle.SSAMarked
+ }
+
+ // Text
+ var lines []string
+ for _, l := range i.Lines {
+ var items []string
+ for _, item := range l.Items {
+ var s string
+ if item.InlineStyle != nil && len(item.InlineStyle.SSAEffect) > 0 {
+ s += item.InlineStyle.SSAEffect
+ }
+ s += item.Text
+ items = append(items, s)
+ }
+ if len(l.VoiceName) > 0 {
+ e.name = l.VoiceName
+ }
+ lines = append(lines, strings.Join(items, " "))
+ }
+ e.text = strings.Join(lines, "\\n")
+ return
+}
+
+// newSSAEventFromString returns an SSA event based on an input string and a format
+func newSSAEventFromString(header, content string, format map[int]string) (e *ssaEvent, err error) {
+ // Split content
+ var items = strings.Split(content, ",")
+
+ // Not enough items
+ if len(items) < len(format) {
+ err = fmt.Errorf("astisub: content has %d items whereas style format has %d items", len(items), len(format))
+ return
+ }
+
+ // Last item may contain commas, therefore we need to fix it
+ items[len(format)-1] = strings.Join(items[len(format)-1:], ",")
+ items = items[:len(format)]
+
+ // Loop through items
+ e = &ssaEvent{category: header}
+ for idx, item := range items {
+ // Index not found in format
+ var attr string
+ var ok bool
+ if attr, ok = format[idx]; !ok {
+ err = fmt.Errorf("astisub: index %d not found in event format %+v", idx, format)
+ return
+ }
+
+ // Switch on attribute name
+ switch attr {
+ // Duration
+ case ssaEventFormatNameStart, ssaEventFormatNameEnd:
+ // Parse duration
+ var d time.Duration
+ if d, err = parseDurationSSA(item); err != nil {
+ err = fmt.Errorf("astisub: parsing ssa duration %s failed: %w", item, err)
+ return
+ }
+
+ // Set duration
+ switch attr {
+ case ssaEventFormatNameEnd:
+ e.end = d
+ case ssaEventFormatNameStart:
+ e.start = d
+ }
+ // Int
+ case ssaEventFormatNameLayer, ssaEventFormatNameMarginL, ssaEventFormatNameMarginR,
+ ssaEventFormatNameMarginV:
+ // Parse int
+ var i int
+ if i, err = strconv.Atoi(item); err != nil {
+ err = fmt.Errorf("astisub: atoi of %s failed: %w", item, err)
+ return
+ }
+
+ // Set int
+ switch attr {
+ case ssaEventFormatNameLayer:
+ e.layer = astikit.IntPtr(i)
+ case ssaEventFormatNameMarginL:
+ e.marginLeft = astikit.IntPtr(i)
+ case ssaEventFormatNameMarginR:
+ e.marginRight = astikit.IntPtr(i)
+ case ssaEventFormatNameMarginV:
+ e.marginVertical = astikit.IntPtr(i)
+ }
+ // String
+ case ssaEventFormatNameEffect, ssaEventFormatNameName, ssaEventFormatNameStyle, ssaEventFormatNameText:
+ switch attr {
+ case ssaEventFormatNameEffect:
+ e.effect = item
+ case ssaEventFormatNameName:
+ e.name = item
+ case ssaEventFormatNameStyle:
+ // *Default is reserved
+ // http://www.tcax.org/docs/ass-specs.htm
+ if item == "*Default" {
+ e.style = "Default"
+ } else {
+ e.style = item
+ }
+ case ssaEventFormatNameText:
+ e.text = strings.TrimSpace(item)
+ }
+ // Marked
+ case ssaEventFormatNameMarked:
+ if item == "Marked=1" {
+ e.marked = astikit.BoolPtr(true)
+ } else {
+ e.marked = astikit.BoolPtr(false)
+ }
+ }
+ }
+ return
+}
+
+// item converts an SSA event to an Item
+func (e *ssaEvent) item(styles map[string]*Style) (i *Item, err error) {
+ // Init item
+ i = &Item{
+ EndAt: e.end,
+ InlineStyle: &StyleAttributes{
+ SSAEffect: e.effect,
+ SSALayer: e.layer,
+ SSAMarginLeft: e.marginLeft,
+ SSAMarginRight: e.marginRight,
+ SSAMarginVertical: e.marginVertical,
+ SSAMarked: e.marked,
+ },
+ StartAt: e.start,
+ }
+
+ // Set style
+ if len(e.style) > 0 {
+ var ok bool
+ if i.Style, ok = styles[e.style]; !ok {
+ err = fmt.Errorf("astisub: style %s not found", e.style)
+ return
+ }
+ }
+
+ // Loop through lines
+ for _, s := range strings.Split(e.text, "\\n") {
+ // Init
+ s = strings.TrimSpace(s)
+ var l = Line{VoiceName: e.name}
+
+ // Extract effects
+ var matches = ssaRegexpEffect.FindAllStringIndex(s, -1)
+ if len(matches) > 0 {
+ // Loop through matches
+ var lineItem *LineItem
+ var previousEffectEndOffset int
+ for _, idxs := range matches {
+ if lineItem != nil {
+ lineItem.Text = s[previousEffectEndOffset:idxs[0]]
+ l.Items = append(l.Items, *lineItem)
+ } else if idxs[0] > 0 {
+ l.Items = append(l.Items, LineItem{Text: s[previousEffectEndOffset:idxs[0]]})
+ }
+ previousEffectEndOffset = idxs[1]
+ lineItem = &LineItem{InlineStyle: &StyleAttributes{SSAEffect: s[idxs[0]:idxs[1]]}}
+ }
+ lineItem.Text = s[previousEffectEndOffset:]
+ l.Items = append(l.Items, *lineItem)
+ } else {
+ l.Items = append(l.Items, LineItem{Text: s})
+ }
+
+ // Add line
+ i.Lines = append(i.Lines, l)
+ }
+ return
+}
+
+// updateFormat updates the format based on the non empty fields
+func (e ssaEvent) updateFormat(formatMap map[string]bool, format []string) []string {
+ if len(e.effect) > 0 {
+ format = ssaUpdateFormat(ssaEventFormatNameEffect, formatMap, format)
+ }
+ if e.layer != nil {
+ format = ssaUpdateFormat(ssaEventFormatNameLayer, formatMap, format)
+ }
+ if e.marginLeft != nil {
+ format = ssaUpdateFormat(ssaEventFormatNameMarginL, formatMap, format)
+ }
+ if e.marginRight != nil {
+ format = ssaUpdateFormat(ssaEventFormatNameMarginR, formatMap, format)
+ }
+ if e.marginVertical != nil {
+ format = ssaUpdateFormat(ssaEventFormatNameMarginV, formatMap, format)
+ }
+ if e.marked != nil {
+ format = ssaUpdateFormat(ssaEventFormatNameMarked, formatMap, format)
+ }
+ if len(e.name) > 0 {
+ format = ssaUpdateFormat(ssaEventFormatNameName, formatMap, format)
+ }
+ if len(e.style) > 0 {
+ format = ssaUpdateFormat(ssaEventFormatNameStyle, formatMap, format)
+ }
+ return format
+}
+
+// formatDurationSSA formats an .ssa duration
+func formatDurationSSA(i time.Duration) string {
+ return formatDuration(i, ".", 2)
+}
+
+// string returns the block as a string
+func (e *ssaEvent) string(format []string) string {
+ var ss []string
+ for _, attr := range format {
+ var v string
+ var found = true
+ switch attr {
+ // Duration
+ case ssaEventFormatNameEnd, ssaEventFormatNameStart:
+ switch attr {
+ case ssaEventFormatNameEnd:
+ v = formatDurationSSA(e.end)
+ case ssaEventFormatNameStart:
+ v = formatDurationSSA(e.start)
+ }
+ // Marked
+ case ssaEventFormatNameMarked:
+ if e.marked != nil {
+ if *e.marked {
+ v = "Marked=1"
+ } else {
+ v = "Marked=0"
+ }
+ }
+ // Int
+ case ssaEventFormatNameLayer, ssaEventFormatNameMarginL, ssaEventFormatNameMarginR,
+ ssaEventFormatNameMarginV:
+ var i *int
+ switch attr {
+ case ssaEventFormatNameLayer:
+ i = e.layer
+ case ssaEventFormatNameMarginL:
+ i = e.marginLeft
+ case ssaEventFormatNameMarginR:
+ i = e.marginRight
+ case ssaEventFormatNameMarginV:
+ i = e.marginVertical
+ }
+ if i != nil {
+ v = strconv.Itoa(*i)
+ }
+ // String
+ case ssaEventFormatNameEffect, ssaEventFormatNameName, ssaEventFormatNameStyle, ssaEventFormatNameText:
+ switch attr {
+ case ssaEventFormatNameEffect:
+ v = e.effect
+ case ssaEventFormatNameName:
+ v = e.name
+ case ssaEventFormatNameStyle:
+ v = e.style
+ case ssaEventFormatNameText:
+ v = e.text
+ }
+ default:
+ found = false
+ }
+ if found {
+ ss = append(ss, v)
+ }
+ }
+ return strings.Join(ss, ",")
+}
+
+// parseDurationSSA parses an .ssa duration
+func parseDurationSSA(i string) (time.Duration, error) {
+ return parseDuration(i, ".", 3)
+}
+
+// WriteToSSA writes subtitles in .ssa format
+func (s Subtitles) WriteToSSA(o io.Writer) (err error) {
+ // Do not write anything if no subtitles
+ if len(s.Items) == 0 {
+ err = ErrNoSubtitlesToWrite
+ return
+ }
+
+ // Write Script Info block
+ var si = newSSAScriptInfo(s.Metadata)
+ if _, err = o.Write(si.bytes()); err != nil {
+ err = fmt.Errorf("astisub: writing script info block failed: %w", err)
+ return
+ }
+
+ // Write Styles block
+ if len(s.Styles) > 0 {
+ // Header
+ var b = []byte("\n[V4 Styles]\n")
+
+ // Format
+ var formatMap = make(map[string]bool)
+ var format = []string{ssaStyleFormatNameName}
+ var styles = make(map[string]*ssaStyle)
+ var styleNames []string
+ for _, s := range s.Styles {
+ var ss = newSSAStyleFromStyle(*s)
+ format = ss.updateFormat(formatMap, format)
+ styles[ss.name] = ss
+ styleNames = append(styleNames, ss.name)
+ }
+ b = append(b, []byte("Format: "+strings.Join(format, ", ")+"\n")...)
+
+ // Styles
+ sort.Strings(styleNames)
+ for _, n := range styleNames {
+ b = append(b, []byte("Style: "+styles[n].string(format)+"\n")...)
+ }
+
+ // Write
+ if _, err = o.Write(b); err != nil {
+ err = fmt.Errorf("astisub: writing styles block failed: %w", err)
+ return
+ }
+ }
+
+ // Write Events block
+ if len(s.Items) > 0 {
+ // Header
+ var b = []byte("\n[Events]\n")
+
+ // Format
+ var formatMap = make(map[string]bool)
+ var format = []string{
+ ssaEventFormatNameStart,
+ ssaEventFormatNameEnd,
+ }
+ var events []*ssaEvent
+ for _, i := range s.Items {
+ var e = newSSAEventFromItem(*i)
+ format = e.updateFormat(formatMap, format)
+ events = append(events, e)
+ }
+ format = append(format, ssaEventFormatNameText)
+ b = append(b, []byte("Format: "+strings.Join(format, ", ")+"\n")...)
+
+ // Styles
+ for _, e := range events {
+ b = append(b, []byte(ssaEventCategoryDialogue+": "+e.string(format)+"\n")...)
+ }
+
+ // Write
+ if _, err = o.Write(b); err != nil {
+ err = fmt.Errorf("astisub: writing events block failed: %w", err)
+ return
+ }
+ }
+ return
+}
+
+// SSAOptions
+type SSAOptions struct {
+ OnUnknownSectionName func(name string)
+ OnInvalidLine func(line string)
+}
+
+func defaultSSAOptions() SSAOptions {
+ return SSAOptions{
+ OnUnknownSectionName: func(name string) {
+ log.Printf("astisub: unknown section: %s", name)
+ },
+ OnInvalidLine: func(line string) {
+ log.Printf("astisub: not understood: '%s', ignoring", line)
+ },
+ }
+}
diff --git a/mkvlib/parser/subtitles.go b/mkvlib/parser/subtitles.go
new file mode 100644
index 0000000..82da90d
--- /dev/null
+++ b/mkvlib/parser/subtitles.go
@@ -0,0 +1,783 @@
+package parser
+
+import (
+ "errors"
+ "fmt"
+ "github.com/asticode/go-astikit"
+ "math"
+ "os"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// Bytes
+var (
+ BytesBOM = []byte{239, 187, 191}
+ bytesLineSeparator = []byte("\n")
+ bytesSpace = []byte(" ")
+)
+
+// Colors
+var (
+ ColorBlack = &Color{}
+ ColorBlue = &Color{Blue: 255}
+ ColorCyan = &Color{Blue: 255, Green: 255}
+ ColorGray = &Color{Blue: 128, Green: 128, Red: 128}
+ ColorGreen = &Color{Green: 128}
+ ColorLime = &Color{Green: 255}
+ ColorMagenta = &Color{Blue: 255, Red: 255}
+ ColorMaroon = &Color{Red: 128}
+ ColorNavy = &Color{Blue: 128}
+ ColorOlive = &Color{Green: 128, Red: 128}
+ ColorPurple = &Color{Blue: 128, Red: 128}
+ ColorRed = &Color{Red: 255}
+ ColorSilver = &Color{Blue: 192, Green: 192, Red: 192}
+ ColorTeal = &Color{Blue: 128, Green: 128}
+ ColorYellow = &Color{Green: 255, Red: 255}
+ ColorWhite = &Color{Blue: 255, Green: 255, Red: 255}
+)
+
+// Errors
+var (
+ ErrInvalidExtension = errors.New("astisub: invalid extension")
+ ErrNoSubtitlesToWrite = errors.New("astisub: no subtitles to write")
+)
+
+// Now allows testing functions using it
+var Now = func() time.Time {
+ return time.Now()
+}
+
+// Options represents open or write options
+type Options struct {
+ Filename string
+ /*Teletext TeletextOptions
+ STL STLOptions*/
+}
+
+// Open opens a subtitle reader based on options
+func Open(o Options) (s *Subtitles, err error) {
+ // Open the file
+ var f *os.File
+ if f, err = os.Open(o.Filename); err != nil {
+ err = fmt.Errorf("astisub: opening %s failed: %w", o.Filename, err)
+ return
+ }
+ defer f.Close()
+
+ // Parse the content
+ switch filepath.Ext(strings.ToLower(o.Filename)) {
+ /*case ".srt":
+ s, err = ReadFromSRT(f)*/
+ case ".ssa", ".ass":
+ s, err = ReadFromSSA(f)
+ /*case ".stl":
+ s, err = ReadFromSTL(f, o.STL)
+ case ".ts":
+ s, err = ReadFromTeletext(f, o.Teletext)
+ case ".ttml":
+ s, err = ReadFromTTML(f)
+ case ".vtt":
+ s, err = ReadFromWebVTT(f)*/
+ default:
+ err = ErrInvalidExtension
+ }
+ return
+}
+
+// OpenFile opens a file regardless of other options
+func OpenFile(filename string) (*Subtitles, error) {
+ return Open(Options{Filename: filename})
+}
+
+// Subtitles represents an ordered list of items with formatting
+type Subtitles struct {
+ Items []*Item
+ Metadata *Metadata
+ Regions map[string]*Region
+ Styles map[string]*Style
+}
+
+// NewSubtitles creates new subtitles
+func NewSubtitles() *Subtitles {
+ return &Subtitles{
+ Regions: make(map[string]*Region),
+ Styles: make(map[string]*Style),
+ }
+}
+
+// Item represents a text to show between 2 time boundaries with formatting
+type Item struct {
+ Comments []string
+ Index int
+ EndAt time.Duration
+ InlineStyle *StyleAttributes
+ Lines []Line
+ Region *Region
+ StartAt time.Duration
+ Style *Style
+}
+
+// String implements the Stringer interface
+func (i Item) String() string {
+ var os []string
+ for _, l := range i.Lines {
+ os = append(os, l.String())
+ }
+ return strings.Join(os, " - ")
+}
+
+// Color represents a color
+type Color struct {
+ Alpha, Blue, Green, Red uint8
+}
+
+// newColorFromSSAString builds a new color based on an SSA string
+func newColorFromSSAString(s string, base int) (c *Color, err error) {
+ var i int64
+ if i, err = strconv.ParseInt(s, base, 64); err != nil {
+ err = fmt.Errorf("parsing int %s with base %d failed: %w", s, base, err)
+ return
+ }
+ c = &Color{
+ Alpha: uint8(i>>24) & 0xff,
+ Blue: uint8(i>>16) & 0xff,
+ Green: uint8(i>>8) & 0xff,
+ Red: uint8(i) & 0xff,
+ }
+ return
+}
+
+// SSAString expresses the color as an SSA string
+func (c *Color) SSAString() string {
+ return fmt.Sprintf("%.8x", uint32(c.Alpha)<<24|uint32(c.Blue)<<16|uint32(c.Green)<<8|uint32(c.Red))
+}
+
+// TTMLString expresses the color as a TTML string
+func (c *Color) TTMLString() string {
+ return fmt.Sprintf("%.6x", uint32(c.Red)<<16|uint32(c.Green)<<8|uint32(c.Blue))
+}
+
+type Justification int
+
+var (
+ JustificationUnchanged = Justification(1)
+ JustificationLeft = Justification(2)
+ JustificationCentered = Justification(3)
+ JustificationRight = Justification(4)
+)
+
+// StyleAttributes represents style attributes
+type StyleAttributes struct {
+ SSAAlignment *int
+ SSAAlphaLevel *float64
+ SSAAngle *float64 // degrees
+ SSABackColour *Color
+ SSABold *bool
+ SSABorderStyle *int
+ SSAEffect string
+ SSAEncoding *int
+ SSAFontName string
+ SSAFontSize *float64
+ SSAItalic *bool
+ SSALayer *int
+ SSAMarginLeft *int // pixels
+ SSAMarginRight *int // pixels
+ SSAMarginVertical *int // pixels
+ SSAMarked *bool
+ SSAOutline *float64 // pixels
+ SSAOutlineColour *Color
+ SSAPrimaryColour *Color
+ SSAScaleX *float64 // %
+ SSAScaleY *float64 // %
+ SSASecondaryColour *Color
+ SSAShadow *float64 // pixels
+ SSASpacing *float64 // pixels
+ SSAStrikeout *bool
+ SSAUnderline *bool
+ STLBoxing *bool
+ STLItalics *bool
+ STLJustification *Justification
+ //STLPosition *STLPosition
+ STLUnderline *bool
+ TeletextColor *Color
+ TeletextDoubleHeight *bool
+ TeletextDoubleSize *bool
+ TeletextDoubleWidth *bool
+ TeletextSpacesAfter *int
+ TeletextSpacesBefore *int
+ // TODO Use pointers with real types below
+ TTMLBackgroundColor *string // https://htmlcolorcodes.com/fr/
+ TTMLColor *string
+ TTMLDirection *string
+ TTMLDisplay *string
+ TTMLDisplayAlign *string
+ TTMLExtent *string
+ TTMLFontFamily *string
+ TTMLFontSize *string
+ TTMLFontStyle *string
+ TTMLFontWeight *string
+ TTMLLineHeight *string
+ TTMLOpacity *string
+ TTMLOrigin *string
+ TTMLOverflow *string
+ TTMLPadding *string
+ TTMLShowBackground *string
+ TTMLTextAlign *string
+ TTMLTextDecoration *string
+ TTMLTextOutline *string
+ TTMLUnicodeBidi *string
+ TTMLVisibility *string
+ TTMLWrapOption *string
+ TTMLWritingMode *string
+ TTMLZIndex *int
+ WebVTTAlign string
+ WebVTTItalics bool
+ WebVTTLine string
+ WebVTTLines int
+ WebVTTPosition string
+ WebVTTRegionAnchor string
+ WebVTTScroll string
+ WebVTTSize string
+ WebVTTVertical string
+ WebVTTViewportAnchor string
+ WebVTTWidth string
+}
+
+func (sa *StyleAttributes) propagateSSAAttributes() {}
+
+func (sa *StyleAttributes) propagateSTLAttributes() {
+ if sa.STLJustification != nil {
+ switch *sa.STLJustification {
+ case JustificationCentered:
+ // default to middle anyway?
+ case JustificationRight:
+ sa.WebVTTAlign = "right"
+ case JustificationLeft:
+ sa.WebVTTAlign = "left"
+ }
+ }
+}
+
+func (sa *StyleAttributes) propagateTeletextAttributes() {
+ if sa.TeletextColor != nil {
+ sa.TTMLColor = astikit.StrPtr("#" + sa.TeletextColor.TTMLString())
+ }
+}
+
+//reference for migration: https://w3c.github.io/ttml-webvtt-mapping/
+func (sa *StyleAttributes) propagateTTMLAttributes() {
+ if sa.TTMLTextAlign != nil {
+ sa.WebVTTAlign = *sa.TTMLTextAlign
+ }
+ if sa.TTMLExtent != nil {
+ //region settings
+ lineHeight := 5 //assuming height of line as 5.33vh
+ dimensions := strings.Split(*sa.TTMLExtent, " ")
+ if len(dimensions) > 1 {
+ sa.WebVTTWidth = dimensions[0]
+ if height, err := strconv.Atoi(strings.ReplaceAll(dimensions[1], "%", "")); err == nil {
+ sa.WebVTTLines = height / lineHeight
+ }
+ //cue settings
+ //default TTML WritingMode is lrtb i.e. left to right, top to bottom
+ sa.WebVTTSize = dimensions[1]
+ if sa.TTMLWritingMode != nil && strings.HasPrefix(*sa.TTMLWritingMode, "tb") {
+ sa.WebVTTSize = dimensions[0]
+ }
+ }
+ }
+ if sa.TTMLOrigin != nil {
+ //region settings
+ sa.WebVTTRegionAnchor = "0%,0%"
+ sa.WebVTTViewportAnchor = strings.ReplaceAll(strings.TrimSpace(*sa.TTMLOrigin), " ", ",")
+ sa.WebVTTScroll = "up"
+ //cue settings
+ coordinates := strings.Split(*sa.TTMLOrigin, " ")
+ if len(coordinates) > 1 {
+ sa.WebVTTLine = coordinates[0]
+ sa.WebVTTPosition = coordinates[1]
+ if sa.TTMLWritingMode != nil && strings.HasPrefix(*sa.TTMLWritingMode, "tb") {
+ sa.WebVTTLine = coordinates[1]
+ sa.WebVTTPosition = coordinates[0]
+ }
+ }
+ }
+}
+
+func (sa *StyleAttributes) propagateWebVTTAttributes() {}
+
+// Metadata represents metadata
+// TODO Merge attributes
+type Metadata struct {
+ Comments []string
+ Framerate int
+ Language string
+ SSACollisions string
+ SSAOriginalEditing string
+ SSAOriginalScript string
+ SSAOriginalTiming string
+ SSAOriginalTranslation string
+ SSAPlayDepth *int
+ SSAPlayResX, SSAPlayResY *int
+ SSAScriptType string
+ SSAScriptUpdatedBy string
+ SSASynchPoint string
+ SSATimer *float64
+ SSAUpdateDetails string
+ SSAWrapStyle string
+ STLCountryOfOrigin string
+ STLCreationDate *time.Time
+ STLDisplayStandardCode string
+ STLMaximumNumberOfDisplayableCharactersInAnyTextRow *int
+ STLMaximumNumberOfDisplayableRows *int
+ STLPublisher string
+ STLRevisionDate *time.Time
+ STLSubtitleListReferenceCode string
+ STLTimecodeStartOfProgramme time.Duration
+ Title string
+ TTMLCopyright string
+}
+
+// Region represents a subtitle's region
+type Region struct {
+ ID string
+ InlineStyle *StyleAttributes
+ Style *Style
+}
+
+// Style represents a subtitle's style
+type Style struct {
+ ID string
+ InlineStyle *StyleAttributes
+ Style *Style
+}
+
+// Line represents a set of formatted line items
+type Line struct {
+ Items []LineItem
+ VoiceName string
+}
+
+// String implement the Stringer interface
+func (l Line) String() string {
+ var texts []string
+ for _, i := range l.Items {
+ texts = append(texts, i.Text)
+ }
+ return strings.Join(texts, " ")
+}
+
+// LineItem represents a formatted line item
+type LineItem struct {
+ InlineStyle *StyleAttributes
+ Style *Style
+ Text string
+}
+
+// Add adds a duration to each time boundaries. As in the time package, duration can be negative.
+func (s *Subtitles) Add(d time.Duration) {
+ for idx := 0; idx < len(s.Items); idx++ {
+ s.Items[idx].EndAt += d
+ s.Items[idx].StartAt += d
+ if s.Items[idx].EndAt <= 0 && s.Items[idx].StartAt <= 0 {
+ s.Items = append(s.Items[:idx], s.Items[idx+1:]...)
+ idx--
+ } else if s.Items[idx].StartAt <= 0 {
+ s.Items[idx].StartAt = time.Duration(0)
+ }
+ }
+}
+
+// Duration returns the subtitles duration
+func (s Subtitles) Duration() time.Duration {
+ if len(s.Items) == 0 {
+ return time.Duration(0)
+ }
+ return s.Items[len(s.Items)-1].EndAt
+}
+
+// ForceDuration updates the subtitles duration.
+// If requested duration is bigger, then we create a dummy item.
+// If requested duration is smaller, then we remove useless items and we cut the last item or add a dummy item.
+func (s *Subtitles) ForceDuration(d time.Duration, addDummyItem bool) {
+ // Requested duration is the same as the subtitles'one
+ if s.Duration() == d {
+ return
+ }
+
+ // Requested duration is bigger than subtitles'one
+ if s.Duration() > d {
+ // Find last item before input duration and update end at
+ var lastIndex = -1
+ for index, i := range s.Items {
+ // Start at is bigger than input duration, we've found the last item
+ if i.StartAt >= d {
+ lastIndex = index
+ break
+ } else if i.EndAt > d {
+ s.Items[index].EndAt = d
+ }
+ }
+
+ // Last index has been found
+ if lastIndex != -1 {
+ s.Items = s.Items[:lastIndex]
+ }
+ }
+
+ // Add dummy item with the minimum duration possible
+ if addDummyItem && s.Duration() < d {
+ s.Items = append(s.Items, &Item{EndAt: d, Lines: []Line{{Items: []LineItem{{Text: "..."}}}}, StartAt: d - time.Millisecond})
+ }
+}
+
+// Fragment fragments subtitles with a specific fragment duration
+func (s *Subtitles) Fragment(f time.Duration) {
+ // Nothing to fragment
+ if len(s.Items) == 0 {
+ return
+ }
+
+ // Here we want to simulate fragments of duration f until there are no subtitles left in that period of time
+ var fragmentStartAt, fragmentEndAt = time.Duration(0), f
+ for fragmentStartAt < s.Items[len(s.Items)-1].EndAt {
+ // We loop through subtitles and process the ones that either contain the fragment start at,
+ // or contain the fragment end at
+ //
+ // It's useless processing subtitles contained between fragment start at and end at
+ // |____________________| <- subtitle
+ // | |
+ // fragment start at fragment end at
+ for i, sub := range s.Items {
+ // Init
+ var newSub = &Item{}
+ *newSub = *sub
+
+ // A switch is more readable here
+ switch {
+ // Subtitle contains fragment start at
+ // |____________________| <- subtitle
+ // | |
+ // fragment start at fragment end at
+ case sub.StartAt < fragmentStartAt && sub.EndAt > fragmentStartAt:
+ sub.StartAt = fragmentStartAt
+ newSub.EndAt = fragmentStartAt
+ // Subtitle contains fragment end at
+ // |____________________| <- subtitle
+ // | |
+ // fragment start at fragment end at
+ case sub.StartAt < fragmentEndAt && sub.EndAt > fragmentEndAt:
+ sub.StartAt = fragmentEndAt
+ newSub.EndAt = fragmentEndAt
+ default:
+ continue
+ }
+
+ // Insert new sub
+ s.Items = append(s.Items[:i], append([]*Item{newSub}, s.Items[i:]...)...)
+ }
+
+ // Update fragments boundaries
+ fragmentStartAt += f
+ fragmentEndAt += f
+ }
+
+ // Order
+ s.Order()
+}
+
+// IsEmpty returns whether the subtitles are empty
+func (s Subtitles) IsEmpty() bool {
+ return len(s.Items) == 0
+}
+
+// Merge merges subtitles i into subtitles
+func (s *Subtitles) Merge(i *Subtitles) {
+ // Append items
+ s.Items = append(s.Items, i.Items...)
+ s.Order()
+
+ // Add regions
+ for _, region := range i.Regions {
+ if _, ok := s.Regions[region.ID]; !ok {
+ s.Regions[region.ID] = region
+ }
+ }
+
+ // Add styles
+ for _, style := range i.Styles {
+ if _, ok := s.Styles[style.ID]; !ok {
+ s.Styles[style.ID] = style
+ }
+ }
+}
+
+// Optimize optimizes subtitles
+func (s *Subtitles) Optimize() {
+ // Nothing to optimize
+ if len(s.Items) == 0 {
+ return
+ }
+
+ // Remove unused regions and style
+ s.removeUnusedRegionsAndStyles()
+}
+
+// removeUnusedRegionsAndStyles removes unused regions and styles
+func (s *Subtitles) removeUnusedRegionsAndStyles() {
+ // Loop through items
+ var usedRegions, usedStyles = make(map[string]bool), make(map[string]bool)
+ for _, item := range s.Items {
+ // Add region
+ if item.Region != nil {
+ usedRegions[item.Region.ID] = true
+ }
+
+ // Add style
+ if item.Style != nil {
+ usedStyles[item.Style.ID] = true
+ }
+
+ // Loop through lines
+ for _, line := range item.Lines {
+ // Loop through line items
+ for _, lineItem := range line.Items {
+ // Add style
+ if lineItem.Style != nil {
+ usedStyles[lineItem.Style.ID] = true
+ }
+ }
+ }
+ }
+
+ // Loop through regions
+ for id, region := range s.Regions {
+ if _, ok := usedRegions[region.ID]; ok {
+ if region.Style != nil {
+ usedStyles[region.Style.ID] = true
+ }
+ } else {
+ delete(s.Regions, id)
+ }
+ }
+
+ // Loop through style
+ for id, style := range s.Styles {
+ if _, ok := usedStyles[style.ID]; !ok {
+ delete(s.Styles, id)
+ }
+ }
+}
+
+// Order orders items
+func (s *Subtitles) Order() {
+ // Nothing to do if less than 1 element
+ if len(s.Items) <= 1 {
+ return
+ }
+
+ // Order
+ sort.Slice(s.Items, func(i, j int) bool {
+ return s.Items[i].StartAt < s.Items[j].StartAt
+ })
+}
+
+// RemoveStyling removes the styling from the subtitles
+func (s *Subtitles) RemoveStyling() {
+ s.Regions = map[string]*Region{}
+ s.Styles = map[string]*Style{}
+ for _, i := range s.Items {
+ i.Region = nil
+ i.Style = nil
+ i.InlineStyle = nil
+ for idxLine, l := range i.Lines {
+ for idxLineItem := range l.Items {
+ i.Lines[idxLine].Items[idxLineItem].InlineStyle = nil
+ i.Lines[idxLine].Items[idxLineItem].Style = nil
+ }
+ }
+ }
+}
+
+// Unfragment unfragments subtitles
+func (s *Subtitles) Unfragment() {
+ // Nothing to do if less than 1 element
+ if len(s.Items) <= 1 {
+ return
+ }
+
+ // Order
+ s.Order()
+
+ // Loop through items
+ for i := 0; i < len(s.Items)-1; i++ {
+ for j := i + 1; j < len(s.Items); j++ {
+ // Items are the same
+ if s.Items[i].String() == s.Items[j].String() && s.Items[i].EndAt >= s.Items[j].StartAt {
+ // Only override end time if longer
+ if s.Items[i].EndAt < s.Items[j].EndAt {
+ s.Items[i].EndAt = s.Items[j].EndAt
+ }
+ s.Items = append(s.Items[:j], s.Items[j+1:]...)
+ j--
+ } else if s.Items[i].EndAt < s.Items[j].StartAt {
+ break
+ }
+ }
+ }
+}
+
+// ApplyLinearCorrection applies linear correction
+func (s *Subtitles) ApplyLinearCorrection(actual1, desired1, actual2, desired2 time.Duration) {
+ // Get parameters
+ a := float64(desired2-desired1) / float64(actual2-actual1)
+ b := time.Duration(float64(desired1) - a*float64(actual1))
+
+ // Loop through items
+ for idx := range s.Items {
+ s.Items[idx].EndAt = time.Duration(a*float64(s.Items[idx].EndAt)) + b
+ s.Items[idx].StartAt = time.Duration(a*float64(s.Items[idx].StartAt)) + b
+ }
+}
+
+// Write writes subtitles to a file
+func (s Subtitles) Write(dst string) (err error) {
+ // Create the file
+ var f *os.File
+ if f, err = os.Create(dst); err != nil {
+ err = fmt.Errorf("astisub: creating %s failed: %w", dst, err)
+ return
+ }
+ defer f.Close()
+
+ // Write the content
+ switch filepath.Ext(strings.ToLower(dst)) {
+ /*case ".srt":
+ err = s.WriteToSRT(f)*/
+ case ".ssa", ".ass":
+ err = s.WriteToSSA(f)
+ /*case ".stl":
+ err = s.WriteToSTL(f)
+ case ".ttml":
+ err = s.WriteToTTML(f)
+ case ".vtt":
+ err = s.WriteToWebVTT(f)*/
+ default:
+ err = ErrInvalidExtension
+ }
+ return
+}
+
+// parseDuration parses a duration in "00:00:00.000", "00:00:00,000" or "0:00:00:00" format
+func parseDuration(i, millisecondSep string, numberOfMillisecondDigits int) (o time.Duration, err error) {
+ // Split milliseconds
+ var parts = strings.Split(i, millisecondSep)
+ var milliseconds int
+ var s string
+ if len(parts) >= 2 {
+ // Invalid number of millisecond digits
+ s = strings.TrimSpace(parts[len(parts)-1])
+ if len(s) > 3 {
+ err = fmt.Errorf("astisub: Invalid number of millisecond digits detected in %s", i)
+ return
+ }
+
+ // Parse milliseconds
+ if milliseconds, err = strconv.Atoi(s); err != nil {
+ err = fmt.Errorf("astisub: atoi of %s failed: %w", s, err)
+ return
+ }
+ milliseconds *= int(math.Pow10(numberOfMillisecondDigits - len(s)))
+ s = strings.Join(parts[:len(parts)-1], millisecondSep)
+ } else {
+ s = i
+ }
+
+ // Split hours, minutes and seconds
+ parts = strings.Split(strings.TrimSpace(s), ":")
+ var partSeconds, partMinutes, partHours string
+ if len(parts) == 2 {
+ partSeconds = parts[1]
+ partMinutes = parts[0]
+ } else if len(parts) == 3 {
+ partSeconds = parts[2]
+ partMinutes = parts[1]
+ partHours = parts[0]
+ } else {
+ err = fmt.Errorf("astisub: No hours, minutes or seconds detected in %s", i)
+ return
+ }
+
+ // Parse seconds
+ var seconds int
+ s = strings.TrimSpace(partSeconds)
+ if seconds, err = strconv.Atoi(s); err != nil {
+ err = fmt.Errorf("astisub: atoi of %s failed: %w", s, err)
+ return
+ }
+
+ // Parse minutes
+ var minutes int
+ s = strings.TrimSpace(partMinutes)
+ if minutes, err = strconv.Atoi(s); err != nil {
+ err = fmt.Errorf("astisub: atoi of %s failed: %w", s, err)
+ return
+ }
+
+ // Parse hours
+ var hours int
+ if len(partHours) > 0 {
+ s = strings.TrimSpace(partHours)
+ if hours, err = strconv.Atoi(s); err != nil {
+ err = fmt.Errorf("astisub: atoi of %s failed: %w", s, err)
+ return
+ }
+ }
+
+ // Generate output
+ o = time.Duration(milliseconds)*time.Millisecond + time.Duration(seconds)*time.Second + time.Duration(minutes)*time.Minute + time.Duration(hours)*time.Hour
+ return
+}
+
+// formatDuration formats a duration
+func formatDuration(i time.Duration, millisecondSep string, numberOfMillisecondDigits int) (s string) {
+ // Parse hours
+ var hours = int(i / time.Hour)
+ var n = i % time.Hour
+ if hours < 10 {
+ s += "0"
+ }
+ s += strconv.Itoa(hours) + ":"
+
+ // Parse minutes
+ var minutes = int(n / time.Minute)
+ n = i % time.Minute
+ if minutes < 10 {
+ s += "0"
+ }
+ s += strconv.Itoa(minutes) + ":"
+
+ // Parse seconds
+ var seconds = int(n / time.Second)
+ n = i % time.Second
+ if seconds < 10 {
+ s += "0"
+ }
+ s += strconv.Itoa(seconds) + millisecondSep
+
+ // Parse milliseconds
+ var milliseconds = float64(n/time.Millisecond) / float64(1000)
+ s += fmt.Sprintf("%."+strconv.Itoa(numberOfMillisecondDigits)+"f", milliseconds)[2:]
+ return
+}
+
+// appendStringToBytesWithNewLine adds a string to bytes then adds a new line
+func appendStringToBytesWithNewLine(i []byte, s string) (o []byte) {
+ o = append(i, []byte(s)...)
+ o = append(o, bytesLineSeparator...)
+ return
+}