package astisub import ( "fmt" "math" "os" "path/filepath" "strconv" "strings" "time" "github.com/pkg/errors" ) // 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 } // 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 = errors.Wrapf(err, "astisub: opening %s failed", o.Filename) return } defer f.Close() // Parse the content switch filepath.Ext(o.Filename) { case ".srt": s, err = ReadFromSRT(f) case ".ssa", ".ass": s, err = ReadFromSSA(f) case ".stl": s, err = ReadFromSTL(f) 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 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 = errors.Wrapf(err, "parsing int %s with base %d failed", s, base) 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)) } // 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 *int // pixels SSAOutlineColour *Color SSAPrimaryColour *Color SSAScaleX *float64 // % SSAScaleY *float64 // % SSASecondaryColour *Color SSAShadow *int // pixels SSASpacing *int // pixels SSAStrikeout *bool SSAUnderline *bool STLBoxing *bool STLItalics *bool 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 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() {} func (sa *StyleAttributes) propagateTeletextAttributes() { if sa.TeletextColor != nil { sa.TTMLColor = "#" + sa.TeletextColor.TTMLString() } } func (sa *StyleAttributes) propagateTTMLAttributes() {} 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 STLPublisher string 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 var swapped = true for swapped { swapped = false for index := 1; index < len(s.Items); index++ { if s.Items[index-1].StartAt > s.Items[index].StartAt { var tmp = s.Items[index-1] s.Items[index-1] = s.Items[index] s.Items[index] = tmp swapped = true } } } } // 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 } // 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 { s.Items[i].EndAt = s.Items[j].EndAt s.Items = append(s.Items[:j], s.Items[j+1:]...) j-- } } } // Order s.Order() } // 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 = errors.Wrapf(err, "astisub: creating %s failed", dst) return } defer f.Close() // Write the content switch filepath.Ext(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 = errors.Wrapf(err, "astisub: atoi of %s failed", s) 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 = errors.Wrapf(err, "astisub: atoi of %s failed", s) return } // Parse minutes var minutes int s = strings.TrimSpace(partMinutes) if minutes, err = strconv.Atoi(s); err != nil { err = errors.Wrapf(err, "astisub: atoi of %s failed", s) return } // Parse hours var hours int if len(partHours) > 0 { s = strings.TrimSpace(partHours) if hours, err = strconv.Atoi(s); err != nil { err = errors.Wrapf(err, "astisub: atoi of %s failed", s) 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 }