From dc44db4e86359dcec83498b8c59698e0f8acb008 Mon Sep 17 00:00:00 2001 From: Quentin Renard Date: Mon, 10 Jul 2017 11:39:45 +0200 Subject: [PATCH] Added .ssa/.ass --- .gitignore | 1 + README.md | 4 +- srt.go | 16 +- ssa.go | 1208 ++++++++++++++++++++++++++++++++++++ ssa_test.go | 105 ++++ stl.go | 2 +- subtitles.go | 182 ++++-- subtitles_internal_test.go | 48 +- subtitles_test.go | 18 +- testdata/example-in.ssa | 20 + testdata/example-out.ssa | 20 + ttml.go | 104 ++-- ttml_test.go | 22 +- webvtt.go | 68 +- webvtt_test.go | 6 +- 15 files changed, 1629 insertions(+), 195 deletions(-) create mode 100644 ssa.go create mode 100644 ssa_test.go create mode 100644 testdata/example-in.ssa create mode 100644 testdata/example-out.ssa diff --git a/.gitignore b/.gitignore index 7e24459..5be2b41 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ Thumbs.db .idea/ cover* +test diff --git a/README.md b/README.md index 46ce9dc..c4f3268 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ This is a Golang library to manipulate subtitles. -It allows you to manipulate `srt`, `stl`, `ttml` and `webvtt` files for now. +It allows you to manipulate `srt`, `stl`, `ttml`, `ssa/ass` and `webvtt` files for now. Available operations are `parsing`, `writing`, `syncing`, `fragmenting`, `unfragmenting` and `merging`. @@ -78,6 +78,6 @@ If **astisub** has been installed properly you can: - [x] .ttml - [x] .vtt - [x] .stl +- [x] .ssa/.ass - [ ] .teletext -- [ ] .ssa/.ass - [ ] .smi diff --git a/srt.go b/srt.go index 612d180..37b51a0 100644 --- a/srt.go +++ b/srt.go @@ -22,7 +22,7 @@ var ( // parseDurationSRT parses an .srt duration func parseDurationSRT(i string) (time.Duration, error) { - return parseDuration(i, ",") + return parseDuration(i, ",", 3) } // ReadFromSRT parses an .srt content @@ -46,15 +46,15 @@ func ReadFromSRT(i io.Reader) (o *Subtitles, err error) { // Remove trailing empty lines if len(s.Lines) > 0 { for i := len(s.Lines) - 1; i >= 0; i-- { - if len(s.Lines[i]) > 0 { - for j := len(s.Lines[i]) - 1; j >= 0; j-- { - if len(s.Lines[i][j].Text) == 0 { - s.Lines[i] = s.Lines[i][:j] + if len(s.Lines[i].Items) > 0 { + for j := len(s.Lines[i].Items) - 1; j >= 0; j-- { + if len(s.Lines[i].Items[j].Text) == 0 { + s.Lines[i].Items = s.Lines[i].Items[:j] } else { break } } - if len(s.Lines[i]) == 0 { + if len(s.Lines[i].Items) == 0 { s.Lines = s.Lines[:i] } @@ -80,7 +80,7 @@ func ReadFromSRT(i io.Reader) (o *Subtitles, err error) { o.Items = append(o.Items, s) } else { // Add text - s.Lines = append(s.Lines, []LineItem{{Text: line}}) + s.Lines = append(s.Lines, Line{Items: []LineItem{{Text: line}}}) } } return @@ -88,7 +88,7 @@ func ReadFromSRT(i io.Reader) (o *Subtitles, err error) { // formatDurationSRT formats an .srt duration func formatDurationSRT(i time.Duration) string { - return formatDuration(i, ",") + return formatDuration(i, ",", 3) } // WriteToSRT writes subtitles in .srt format diff --git a/ssa.go b/ssa.go new file mode 100644 index 0000000..9245728 --- /dev/null +++ b/ssa.go @@ -0,0 +1,1208 @@ +package astisub + +import ( + "bufio" + "fmt" + "io" + "regexp" + "sort" + "strconv" + "strings" + "time" + + "github.com/asticode/go-astitools/ptr" + "github.com/pkg/errors" +) + +// 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 = "Script Type" + 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" +) + +// 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) { + // 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 + for scanner.Scan() { + // Fetch line + line = strings.TrimSpace(scanner.Text()) + + // Empty line + if len(line) == 0 { + continue + } + + // Section name + switch strings.ToLower(line) { + 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 + } + + // 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 { + err = fmt.Errorf("astisub: line '%s' should contain at least one ':'", line) + return + } + 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 = errors.Wrap(err, "astisub: parsing script info block failed") + 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 = errors.Wrap(err, "astisub: building new ssa event failed") + return + } + es = append(es, e) + case ssaSectionNameStyles: + var s *ssaStyle + if s, err = newSSAStyleFromString(content, format); err != nil { + err = errors.Wrap(err, "astisub: building new ssa style failed") + return + } + ss = append(ss, s) + } + } + } + } + + // Set metadata + o.Metadata = &Metadata{ + Comments: si.comments, + Copyright: si.originalEditing, + Title: si.title, + } + + // 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 newColorFromString(s, base) +} + +// newSSAColorFromColor builds a new SSA color based on a color +func newSSAColorFromColor(i *Color) string { + return "&H" + i.String(16) +} + +// ssaScriptInfo represents an SSA script info block +type ssaScriptInfo struct { + collisions string + comments []string + originalEditing string + originalScript string + originalTiming string + originalTranslation string + playDepth string + playResX, playResY *int + scriptType string + scriptUpdatedBy string + synchPoint string + timer *float64 + title string + updateDetails string + wrapStyle string +} + +// newSSAScriptInfo builds an SSA script info block +func newSSAScriptInfo(s Subtitles) (o *ssaScriptInfo) { + // Init + o = &ssaScriptInfo{} + + // Add metadata + if s.Metadata != nil { + o.comments = s.Metadata.Comments + o.originalEditing = s.Metadata.Copyright + o.title = s.Metadata.Title + } + 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 ssaScriptInfoNamePlayDepth: + b.playDepth = content + case ssaScriptInfoNamePlayResX, ssaScriptInfoNamePlayResY: + var v int + if v, err = strconv.Atoi(content); err != nil { + err = errors.Wrapf(err, "astisub: atoi of %s failed", content) + } + switch header { + case ssaScriptInfoNamePlayResX: + b.playResX = astiptr.Int(v) + case ssaScriptInfoNamePlayResY: + b.playResY = astiptr.Int(v) + } + case ssaScriptInfoNameScriptType: + b.scriptType = content + case ssaScriptInfoNameScriptUpdatedBy: + b.scriptUpdatedBy = content + case ssaScriptInfoNameSynchPoint: + b.synchPoint = content + case ssaScriptInfoNameTimer: + var v float64 + if v, err = strconv.ParseFloat(strings.Replace(content, ",", ".", -1), 64); err != nil { + err = errors.Wrapf(err, "astisub: parseFloat of %s failed", content) + } + b.timer = astiptr.Float(v) + case ssaScriptInfoNameTitle: + b.title = content + case ssaScriptInfoNameUpdateDetails: + b.updateDetails = content + case ssaScriptInfoNameWrapStyle: + b.wrapStyle = content + } + return +} + +// bytes returns the block as bytes +func (b *ssaScriptInfo) bytes() (o []byte) { + o = []byte("[Script Info]") + o = append(o, bytesLineSeparator...) + if len(b.collisions) > 0 { + o = appendStringToBytesWithNewLine(o, ssaScriptInfoNameCollisions+": "+b.collisions) + } + for _, c := range b.comments { + o = appendStringToBytesWithNewLine(o, "; "+c) + } + 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 len(b.playDepth) > 0 { + o = appendStringToBytesWithNewLine(o, ssaScriptInfoNamePlayDepth+": "+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 *int // pixels + outlineColour *Color + marginLeft *int // pixels + marginRight *int // pixels + marginVertical *int // pixels + name string + primaryColour *Color + scaleX *float64 // % + scaleY *float64 // % + secondaryColour *Color + shadow *int // pixels + spacing *int // 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 = astiptr.Bool(b) + case ssaStyleFormatNameItalic: + s.italic = astiptr.Bool(b) + case ssaStyleFormatNameStrikeout: + s.strikeout = astiptr.Bool(b) + case ssaStyleFormatNameUnderline: + s.underline = astiptr.Bool(b) + } + // Color + case ssaStyleFormatNamePrimaryColour, ssaStyleFormatNameSecondaryColour, + ssaStyleFormatNameTertiaryColour, ssaStyleFormatNameOutlineColour, ssaStyleFormatNameBackColour: + // Build color + var c *Color + if c, err = newColorFromSSAColor(item); err != nil { + err = errors.Wrapf(err, "astisub: building new %s from ssa color %s failed", attr, item) + 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: + // Parse float + var f float64 + if f, err = strconv.ParseFloat(item, 64); err != nil { + err = errors.Wrapf(err, "astisub: parsing float %s failed", item) + return + } + + // Set float + switch attr { + case ssaStyleFormatNameAlphaLevel: + s.alphaLevel = astiptr.Float(f) + case ssaStyleFormatNameAngle: + s.angle = astiptr.Float(f) + case ssaStyleFormatNameFontSize: + s.fontSize = astiptr.Float(f) + case ssaStyleFormatNameScaleX: + s.scaleX = astiptr.Float(f) + case ssaStyleFormatNameScaleY: + s.scaleY = astiptr.Float(f) + } + // Int + case ssaStyleFormatNameAlignment, ssaStyleFormatNameBorderStyle, ssaStyleFormatNameEncoding, + ssaStyleFormatNameMarginL, ssaStyleFormatNameMarginR, ssaStyleFormatNameMarginV, + ssaStyleFormatNameOutline, ssaStyleFormatNameShadow, ssaStyleFormatNameSpacing: + // Parse int + var i int + if i, err = strconv.Atoi(item); err != nil { + err = errors.Wrapf(err, "astisub: atoi of %s failed", item) + return + } + + // Set int + switch attr { + case ssaStyleFormatNameAlignment: + s.alignment = astiptr.Int(i) + case ssaStyleFormatNameBorderStyle: + s.borderStyle = astiptr.Int(i) + case ssaStyleFormatNameEncoding: + s.encoding = astiptr.Int(i) + case ssaStyleFormatNameMarginL: + s.marginLeft = astiptr.Int(i) + case ssaStyleFormatNameMarginR: + s.marginRight = astiptr.Int(i) + case ssaStyleFormatNameMarginV: + s.marginVertical = astiptr.Int(i) + case ssaStyleFormatNameOutline: + s.outline = astiptr.Int(i) + case ssaStyleFormatNameShadow: + s.shadow = astiptr.Int(i) + case ssaStyleFormatNameSpacing: + s.spacing = astiptr.Int(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: + 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 + } + if f != nil { + v = strconv.FormatFloat(*f, 'f', 3, 64) + } + // Int + case ssaStyleFormatNameAlignment, ssaStyleFormatNameBorderStyle, ssaStyleFormatNameEncoding, + ssaStyleFormatNameMarginL, ssaStyleFormatNameMarginR, ssaStyleFormatNameMarginV, + ssaStyleFormatNameOutline, ssaStyleFormatNameShadow, ssaStyleFormatNameSpacing: + 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 + case ssaStyleFormatNameOutline: + i = s.outline + case ssaStyleFormatNameShadow: + i = s.shadow + case ssaStyleFormatNameSpacing: + i = s.spacing + } + 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() *Style { + return &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, + }, + } +} + +// 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 = errors.Wrapf(err, "astisub: parsing ssa duration %s failed", item) + 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 = errors.Wrapf(err, "astisub: atoi of %s failed", item) + return + } + + // Set int + switch attr { + case ssaEventFormatNameLayer: + e.layer = astiptr.Int(i) + case ssaEventFormatNameMarginL: + e.marginLeft = astiptr.Int(i) + case ssaEventFormatNameMarginR: + e.marginRight = astiptr.Int(i) + case ssaEventFormatNameMarginV: + e.marginVertical = astiptr.Int(i) + } + // String + case ssaEventFormatNameEffect, ssaEventFormatNameName, ssaEventFormatNameStyle, ssaEventFormatNameText: + switch attr { + case ssaEventFormatNameEffect: + e.effect = item + case ssaEventFormatNameName: + e.name = item + case ssaEventFormatNameStyle: + e.style = item + case ssaEventFormatNameText: + e.text = item + } + // Marked + case ssaEventFormatNameMarked: + if item == "Marked=1" { + e.marked = astiptr.Bool(true) + } else { + e.marked = astiptr.Bool(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) + } + 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) + if _, err = o.Write(si.bytes()); err != nil { + err = errors.Wrap(err, "astisub: writing script info block failed") + 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 = errors.Wrap(err, "astisub: writing styles block failed") + 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 = errors.Wrap(err, "astisub: writing events block failed") + return + } + } + return +} diff --git a/ssa_test.go b/ssa_test.go new file mode 100644 index 0000000..9aa8520 --- /dev/null +++ b/ssa_test.go @@ -0,0 +1,105 @@ +package astisub_test + +import ( + "bytes" + "io/ioutil" + "testing" + + "github.com/asticode/go-astisub" + "github.com/asticode/go-astitools/ptr" + "github.com/stretchr/testify/assert" +) + +func assertSSAStyle(t *testing.T, e, a astisub.Style) { + assert.Equal(t, e.ID, a.ID) + assertSSAStyleAttributes(t, *e.InlineStyle, *a.InlineStyle) +} + +func assertSSAStyleAttributes(t *testing.T, e, a astisub.StyleAttributes) { + if e.SSAAlignment != nil { + assert.Equal(t, *e.SSAAlignment, *a.SSAAlignment) + } + if e.SSAAlphaLevel != nil { + assert.Equal(t, *e.SSAAlphaLevel, *a.SSAAlphaLevel) + } + if e.SSABackColour != nil { + assert.Equal(t, *e.SSABackColour, *a.SSABackColour) + } + if e.SSABold != nil { + assert.Equal(t, *e.SSABold, *a.SSABold) + } + if e.SSABorderStyle != nil { + assert.Equal(t, *e.SSABorderStyle, *a.SSABorderStyle) + } + if e.SSAFontSize != nil { + assert.Equal(t, e.SSAFontName, a.SSAFontName) + } + if e.SSAFontSize != nil { + assert.Equal(t, *e.SSAFontSize, *a.SSAFontSize) + } + if e.SSALayer != nil { + assert.Equal(t, *e.SSALayer, *a.SSALayer) + } + if e.SSAMarked != nil { + assert.Equal(t, *e.SSAMarked, *a.SSAMarked) + } + if e.SSAMarginLeft != nil { + assert.Equal(t, *e.SSAMarginLeft, *a.SSAMarginLeft) + } + if e.SSAMarginRight != nil { + assert.Equal(t, *e.SSAMarginRight, *a.SSAMarginRight) + } + if e.SSAMarginVertical != nil { + assert.Equal(t, *e.SSAMarginVertical, *a.SSAMarginVertical) + } + if e.SSAOutline != nil { + assert.Equal(t, *e.SSAOutline, *a.SSAOutline) + } + if e.SSAOutlineColour != nil { + assert.Equal(t, *e.SSAOutlineColour, *a.SSAOutlineColour) + } + if e.SSAPrimaryColour != nil { + assert.Equal(t, *e.SSAPrimaryColour, *a.SSAPrimaryColour) + } + if e.SSASecondaryColour != nil { + assert.Equal(t, *e.SSASecondaryColour, *a.SSASecondaryColour) + } + if e.SSAShadow != nil { + assert.Equal(t, *e.SSAShadow, *a.SSAShadow) + } +} + +func TestSSA(t *testing.T) { + // Open + s, err := astisub.OpenFile("./testdata/example-in.ssa") + assert.NoError(t, err) + assertSubtitleItems(t, s) + // Metadata + assert.Equal(t, &astisub.Metadata{Comments: []string{"Comment 1", "Comment 2"}, Copyright: "Copyright test", Title: "SSA test"}, s.Metadata) + // Styles + assert.Equal(t, 3, len(s.Styles)) + assertSSAStyle(t, astisub.Style{ID: "1", InlineStyle: &astisub.StyleAttributes{SSAAlignment: astiptr.Int(7), SSAAlphaLevel: astiptr.Float(0.1), SSABackColour: &astisub.Color{Alpha: 128, Red: 8}, SSABold: astiptr.Bool(true), SSABorderStyle: astiptr.Int(7), SSAFontName: "f1", SSAFontSize: astiptr.Float(4), SSAOutline: astiptr.Int(1), SSAOutlineColour: &astisub.Color{Green: 255, Red: 255}, SSAMarginLeft: astiptr.Int(1), SSAMarginRight: astiptr.Int(4), SSAMarginVertical: astiptr.Int(7), SSAPrimaryColour: &astisub.Color{Green: 255, Red: 255}, SSASecondaryColour: &astisub.Color{Green: 255, Red: 255}, SSAShadow: astiptr.Int(4)}}, *s.Styles["1"]) + assertSSAStyle(t, astisub.Style{ID: "2", InlineStyle: &astisub.StyleAttributes{SSAAlignment: astiptr.Int(8), SSAAlphaLevel: astiptr.Float(0.2), SSABackColour: &astisub.Color{Blue: 15, Green: 15, Red: 15}, SSABold: astiptr.Bool(true), SSABorderStyle: astiptr.Int(8), SSAEncoding: astiptr.Int(1), SSAFontName: "f2", SSAFontSize: astiptr.Float(5), SSAOutline: astiptr.Int(2), SSAOutlineColour: &astisub.Color{Green: 255, Red: 255}, SSAMarginLeft: astiptr.Int(2), SSAMarginRight: astiptr.Int(5), SSAMarginVertical: astiptr.Int(8), SSAPrimaryColour: &astisub.Color{Blue: 239, Green: 239, Red: 239}, SSASecondaryColour: &astisub.Color{Green: 255, Red: 255}, SSAShadow: astiptr.Int(5)}}, *s.Styles["2"]) + assertSSAStyle(t, astisub.Style{ID: "3", InlineStyle: &astisub.StyleAttributes{SSAAlignment: astiptr.Int(9), SSAAlphaLevel: astiptr.Float(0.3), SSABackColour: &astisub.Color{Red: 8}, SSABorderStyle: astiptr.Int(9), SSAEncoding: astiptr.Int(2), SSAFontName: "f3", SSAFontSize: astiptr.Float(6), SSAOutline: astiptr.Int(3), SSAOutlineColour: &astisub.Color{Red: 8}, SSAMarginLeft: astiptr.Int(3), SSAMarginRight: astiptr.Int(6), SSAMarginVertical: astiptr.Int(9), SSAPrimaryColour: &astisub.Color{Blue: 180, Green: 252, Red: 252}, SSASecondaryColour: &astisub.Color{Blue: 180, Green: 252, Red: 252}, SSAShadow: astiptr.Int(6)}}, *s.Styles["3"]) + // Items + assertSSAStyleAttributes(t, astisub.StyleAttributes{SSAEffect: "test", SSAMarked: astiptr.Bool(false), SSAMarginLeft: astiptr.Int(1234), SSAMarginRight: astiptr.Int(2345), SSAMarginVertical: astiptr.Int(3456)}, *s.Items[0].InlineStyle) + assert.Equal(t, s.Styles["1"], s.Items[0].Style) + assert.Equal(t, []astisub.Line{{Items: []astisub.LineItem{{InlineStyle: &astisub.StyleAttributes{SSAEffect: "{\\pos(400,570)}"}, Text: "(deep rumbling)"}}, VoiceName: "Cher"}}, s.Items[0].Lines) + assert.Equal(t, s.Styles["2"], s.Items[1].Style) + assert.Equal(t, s.Styles["3"], s.Items[2].Style) + assert.Equal(t, s.Styles["1"], s.Items[3].Style) + assert.Equal(t, s.Styles["2"], s.Items[4].Style) + assert.Equal(t, s.Styles["3"], s.Items[5].Style) + + // No subtitles to write + w := &bytes.Buffer{} + err = astisub.Subtitles{}.WriteToSSA(w) + assert.EqualError(t, err, astisub.ErrNoSubtitlesToWrite.Error()) + + // Write + c, err := ioutil.ReadFile("./testdata/example-out.ssa") + assert.NoError(t, err) + err = s.WriteToSSA(w) + assert.NoError(t, err) + assert.Equal(t, string(c), w.String()) +} diff --git a/stl.go b/stl.go index 15c7f45..49bb04c 100644 --- a/stl.go +++ b/stl.go @@ -227,7 +227,7 @@ func ReadFromSTL(i io.Reader) (o *Subtitles, err error) { if len(text) == 0 { continue } - i.Lines = append(i.Lines, []LineItem{{Text: text}}) + i.Lines = append(i.Lines, Line{Items: []LineItem{{Text: text}}}) } // Append item diff --git a/subtitles.go b/subtitles.go index 60e30f2..8ffd2e7 100644 --- a/subtitles.go +++ b/subtitles.go @@ -2,6 +2,7 @@ package astisub import ( "fmt" + "math" "os" "path/filepath" "strconv" @@ -50,6 +51,8 @@ func Open(o Options) (s *Subtitles, err error) { switch filepath.Ext(o.Src) { case ".srt": s, err = ReadFromSRT(f) + case ".ssa", ".ass": + s, err = ReadFromSSA(f) case ".stl": s, err = ReadFromSTL(f) case ".ts": @@ -105,47 +108,104 @@ func (i Item) String() string { return strings.Join(os, " - ") } +// Color represents a color +type Color struct { + Alpha, Blue, Green, Red uint8 +} + +// newColorFromString builds a new color based on a string +func newColorFromString(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 +} + +// String expresses the color as a string for a specific base +func (c *Color) String(base int) string { + var i = uint32(c.Alpha)<<24 | uint32(c.Blue)<<16 | uint32(c.Green)<<8 | uint32(c.Red) + if base == 16 { + return fmt.Sprintf("%.8x", i) + } + return strconv.Itoa(int(i)) +} + // StyleAttributes represents style attributes -// TODO Need more .ttml, .vtt, .stl, etc. style examples to get common patterns +// TODO Merge attributes type StyleAttributes struct { - Align string // WebVTT - BackgroundColor string // TTML - Color string // TTML - Direction string // TTML - Display string // TTML - DisplayAlign string // TTML - Extent string // TTML - FontFamily string // TTML - FontSize string // TTML - FontStyle string // TTML - FontWeight string // TTML - Line string // WebVTT - LineHeight string // TTML - Lines int // WebVTT - Opacity string // TTML - Origin string // TTML - Overflow string // TTML - Padding string // TTML - Position string // WebVTT - RegionAnchor string // WebVTT - Scroll string // WebVTT - ShowBackground string // TTML - Size string // WebVTT - TextAlign string // TTML - TextDecoration string // TTML - TextOutline string // TTML - UnicodeBidi string // TTML - Vertical string // WebVTT - ViewportAnchor string // WebVTT - Visibility string // TTML - Width string // WebVTT - WrapOption string // TTML - WritingMode string // TTML - ZIndex int // TTML + 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 + TTMLBackgroundColor string + 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 } // Metadata represents metadata type Metadata struct { + Comments []string Copyright string Framerate int Language string @@ -167,12 +227,15 @@ type Style struct { } // Line represents a set of formatted line items -type Line []LineItem +type Line struct { + Items []LineItem + VoiceName string +} // String implement the Stringer interface func (l Line) String() string { var texts []string - for _, i := range l { + for _, i := range l.Items { texts = append(texts, i.Text) } return strings.Join(texts, " ") @@ -232,7 +295,7 @@ func (s *Subtitles) ForceDuration(d time.Duration) { // Add dummy item with the minimum duration possible if s.Duration() < d { - s.Items = append(s.Items, &Item{EndAt: d, Lines: []Line{{{Text: "..."}}}, StartAt: d - time.Millisecond}) + s.Items = append(s.Items, &Item{EndAt: d, Lines: []Line{{Items: []LineItem{{Text: "..."}}}}, StartAt: d - time.Millisecond}) } } @@ -376,6 +439,8 @@ func (s Subtitles) Write(dst string) (err error) { 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": @@ -388,36 +453,33 @@ func (s Subtitles) Write(dst string) (err error) { return } -// parseDuration parses a duration in "00:00:00.000" or "00:00:00,000" format -func parseDuration(i, millisecondSep string) (o time.Duration, err error) { +// 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 - if len(parts[1]) > 3 { + 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 - s = strings.TrimSpace(parts[1]) if milliseconds, err = strconv.Atoi(s); err != nil { err = errors.Wrapf(err, "astisub: atoi of %s failed", s) return } - - // In case number of milliseconds digits is not 3 - if len(s) == 2 { - milliseconds *= 10 - } else if len(s) == 1 { - milliseconds *= 100 - } + 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(parts[0]), ":") + parts = strings.Split(strings.TrimSpace(s), ":") var partSeconds, partMinutes, partHours string if len(parts) == 2 { partSeconds = parts[1] @@ -462,8 +524,8 @@ func parseDuration(i, millisecondSep string) (o time.Duration, err error) { return } -// formatDurationSRT formats a duration -func formatDuration(i time.Duration, millisecondSep string) (s string) { +// 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 @@ -489,12 +551,14 @@ func formatDuration(i time.Duration, millisecondSep string) (s string) { s += strconv.Itoa(seconds) + millisecondSep // Parse milliseconds - var milliseconds = int(n / time.Millisecond) - if milliseconds < 10 { - s += "00" - } else if milliseconds < 100 { - s += "0" - } - s += strconv.Itoa(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 } diff --git a/subtitles_internal_test.go b/subtitles_internal_test.go index 4907c3c..8bc2c08 100644 --- a/subtitles_internal_test.go +++ b/subtitles_internal_test.go @@ -7,44 +7,60 @@ import ( "github.com/stretchr/testify/assert" ) +func TestColor(t *testing.T) { + c, err := newColorFromString("305419896", 10) + assert.NoError(t, err) + assert.Equal(t, Color{Alpha: 0x12, Blue: 0x34, Green: 0x56, Red: 0x78}, *c) + assert.Equal(t, "305419896", c.String(10)) + c, err = newColorFromString("12345678", 16) + assert.NoError(t, err) + assert.Equal(t, Color{Alpha: 0x12, Blue: 0x34, Green: 0x56, Red: 0x78}, *c) + assert.Equal(t, "12345678", c.String(16)) +} + func TestParseDuration(t *testing.T) { - d, err := parseDuration("12:34:56,1234", ",") + d, err := parseDuration("12:34:56,1234", ",", 3) assert.EqualError(t, err, "astisub: Invalid number of millisecond digits detected in 12:34:56,1234") - d, err = parseDuration("12,123", ",") + d, err = parseDuration("12,123", ",", 3) assert.EqualError(t, err, "astisub: No hours, minutes or seconds detected in 12,123") - d, err = parseDuration("12:34,123", ",") + d, err = parseDuration("12:34,123", ",", 3) assert.NoError(t, err) assert.Equal(t, 12*time.Minute+34*time.Second+123*time.Millisecond, d) - d, err = parseDuration("12:34:56,123", ",") + d, err = parseDuration("12:34:56,123", ",", 3) assert.NoError(t, err) assert.Equal(t, 12*time.Hour+34*time.Minute+56*time.Second+123*time.Millisecond, d) - d, err = parseDuration("12:34:56,1", ",") + d, err = parseDuration("12:34:56,1", ",", 3) assert.NoError(t, err) assert.Equal(t, 12*time.Hour+34*time.Minute+56*time.Second+100*time.Millisecond, d) - d, err = parseDuration("12:34:56.123", ".") + d, err = parseDuration("12:34:56.123", ".", 3) assert.NoError(t, err) assert.Equal(t, 12*time.Hour+34*time.Minute+56*time.Second+123*time.Millisecond, d) + d, err = parseDuration("1:23:45.67", ".", 2) + assert.NoError(t, err) + assert.Equal(t, time.Hour+23*time.Minute+45*time.Second+67*time.Millisecond, d) } func TestFormatDuration(t *testing.T) { - s := formatDuration(time.Second, ",") + s := formatDuration(time.Second, ",", 3) assert.Equal(t, "00:00:01,000", s) - s = formatDuration(time.Millisecond, ",") + s = formatDuration(time.Second, ",", 2) + assert.Equal(t, "00:00:01,00", s) + s = formatDuration(time.Millisecond, ",", 3) assert.Equal(t, "00:00:00,001", s) - s = formatDuration(10*time.Millisecond, ".") + s = formatDuration(10*time.Millisecond, ".", 3) assert.Equal(t, "00:00:00.010", s) - s = formatDuration(100*time.Millisecond, ",") + s = formatDuration(100*time.Millisecond, ",", 3) assert.Equal(t, "00:00:00,100", s) - s = formatDuration(time.Second+234*time.Millisecond, ",") + s = formatDuration(time.Second+234*time.Millisecond, ",", 3) assert.Equal(t, "00:00:01,234", s) - s = formatDuration(12*time.Second+345*time.Millisecond, ",") + s = formatDuration(12*time.Second+345*time.Millisecond, ",", 3) assert.Equal(t, "00:00:12,345", s) - s = formatDuration(2*time.Minute+3*time.Second+456*time.Millisecond, ",") + s = formatDuration(2*time.Minute+3*time.Second+456*time.Millisecond, ",", 3) assert.Equal(t, "00:02:03,456", s) - s = formatDuration(20*time.Minute+34*time.Second+567*time.Millisecond, ",") + s = formatDuration(20*time.Minute+34*time.Second+567*time.Millisecond, ",", 3) assert.Equal(t, "00:20:34,567", s) - s = formatDuration(3*time.Hour+25*time.Minute+45*time.Second+678*time.Millisecond, ",") + s = formatDuration(3*time.Hour+25*time.Minute+45*time.Second+678*time.Millisecond, ",", 3) assert.Equal(t, "03:25:45,678", s) - s = formatDuration(34*time.Hour+17*time.Minute+36*time.Second+789*time.Millisecond, ",") + s = formatDuration(34*time.Hour+17*time.Minute+36*time.Second+789*time.Millisecond, ",", 3) assert.Equal(t, "34:17:36,789", s) } diff --git a/subtitles_test.go b/subtitles_test.go index f3b4de2..1d45c1a 100644 --- a/subtitles_test.go +++ b/subtitles_test.go @@ -9,7 +9,7 @@ import ( ) func TestLine_Text(t *testing.T) { - var l = astisub.Line{{Text: "1"}, {Text: "2"}, {Text: "3"}} + var l = astisub.Line{Items: []astisub.LineItem{{Text: "1"}, {Text: "2"}, {Text: "3"}}} assert.Equal(t, "1 2 3", l.String()) } @@ -40,7 +40,7 @@ func assertSubtitleItems(t *testing.T, i *astisub.Subtitles) { } func mockSubtitles() *astisub.Subtitles { - return &astisub.Subtitles{Items: []*astisub.Item{{EndAt: 3 * time.Second, StartAt: time.Second, Lines: []astisub.Line{{{Text: "subtitle-1"}}}}, {EndAt: 7 * time.Second, StartAt: 3 * time.Second, Lines: []astisub.Line{{{Text: "subtitle-2"}}}}}} + return &astisub.Subtitles{Items: []*astisub.Item{{EndAt: 3 * time.Second, StartAt: time.Second, Lines: []astisub.Line{{Items: []astisub.LineItem{{Text: "subtitle-1"}}}}}, {EndAt: 7 * time.Second, StartAt: 3 * time.Second, Lines: []astisub.Line{{Items: []astisub.LineItem{{Text: "subtitle-2"}}}}}}} } func TestSubtitles_Add(t *testing.T) { @@ -69,7 +69,7 @@ func TestSubtitles_ForceDuration(t *testing.T) { assert.Len(t, s.Items, 3) assert.Equal(t, 10*time.Second, s.Items[2].EndAt) assert.Equal(t, 10*time.Second-time.Millisecond, s.Items[2].StartAt) - assert.Equal(t, []astisub.Line{{{Text: "..."}}}, s.Items[2].Lines) + assert.Equal(t, []astisub.Line{{Items: []astisub.LineItem{{Text: "..."}}}}, s.Items[2].Lines) s.Items[2].StartAt = 7 * time.Second s.Items[2].EndAt = 12 * time.Second s.ForceDuration(10 * time.Second) @@ -87,22 +87,22 @@ func TestSubtitles_Fragment(t *testing.T) { assert.Len(t, s.Items, 5) assert.Equal(t, time.Second, s.Items[0].StartAt) assert.Equal(t, 2*time.Second, s.Items[0].EndAt) - assert.Equal(t, []astisub.Line{{{Text: "subtitle-1"}}}, s.Items[0].Lines) + assert.Equal(t, []astisub.Line{{Items: []astisub.LineItem{{Text: "subtitle-1"}}}}, s.Items[0].Lines) assert.Equal(t, 2*time.Second, s.Items[1].StartAt) assert.Equal(t, 3*time.Second, s.Items[1].EndAt) - assert.Equal(t, []astisub.Line{{{Text: "subtitle-1"}}}, s.Items[1].Lines) + assert.Equal(t, []astisub.Line{{Items: []astisub.LineItem{{Text: "subtitle-1"}}}}, s.Items[1].Lines) assert.Equal(t, 3*time.Second, s.Items[2].StartAt) assert.Equal(t, 4*time.Second, s.Items[2].EndAt) - assert.Equal(t, []astisub.Line{{{Text: "subtitle-2"}}}, s.Items[2].Lines) + assert.Equal(t, []astisub.Line{{Items: []astisub.LineItem{{Text: "subtitle-2"}}}}, s.Items[2].Lines) assert.Equal(t, 4*time.Second, s.Items[3].StartAt) assert.Equal(t, 6*time.Second, s.Items[3].EndAt) - assert.Equal(t, []astisub.Line{{{Text: "subtitle-2"}}}, s.Items[3].Lines) + assert.Equal(t, []astisub.Line{{Items: []astisub.LineItem{{Text: "subtitle-2"}}}}, s.Items[3].Lines) assert.Equal(t, 6*time.Second, s.Items[4].StartAt) assert.Equal(t, 7*time.Second, s.Items[4].EndAt) - assert.Equal(t, []astisub.Line{{{Text: "subtitle-2"}}}, s.Items[4].Lines) + assert.Equal(t, []astisub.Line{{Items: []astisub.LineItem{{Text: "subtitle-2"}}}}, s.Items[4].Lines) // Unfragment - s.Items = append(s.Items[:4], append([]*astisub.Item{{EndAt: 5 * time.Second, Lines: []astisub.Line{{{Text: "subtitle-3"}}}, StartAt: 4 * time.Second}}, s.Items[4:]...)...) + s.Items = append(s.Items[:4], append([]*astisub.Item{{EndAt: 5 * time.Second, Lines: []astisub.Line{{Items: []astisub.LineItem{{Text: "subtitle-3"}}}}, StartAt: 4 * time.Second}}, s.Items[4:]...)...) s.Unfragment() assert.Len(t, s.Items, 3) assert.Equal(t, "subtitle-1", s.Items[0].String()) diff --git a/testdata/example-in.ssa b/testdata/example-in.ssa new file mode 100644 index 0000000..e141230 --- /dev/null +++ b/testdata/example-in.ssa @@ -0,0 +1,20 @@ +[Script Info] +; Comment 1 +; Comment 2 +Title: SSA test +Original Editing: Copyright test + +[V4 Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, TertiaryColour, BackColour, Bold, Italic, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, AlphaLevel, Encoding +Style: 1,f1,4,65535,65535,65535,-2147483640,-1,0,7,1,4,7,1,4,7,0.1,0 +Style: 2,f2,5,15724527,65535,65535,986895,-1,0,8,2,5,8,2,5,8,0.2,1 +Style: 3,f3,6,&H00B4FCFC,&H00B4FCFC,&H00000008,&H00000008,0,0,9,3,6,9,3,6,9,0.3,2 + +[Events] +Format: Marked, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: Marked=0,0:01:39.00,0:01:41.04,1,Cher,1234,2345,3456,test,{\pos(400,570)}(deep rumbling) +Dialogue: Marked=1,0:02:04.08,0:02:07.12,2,autre,0000,0000,0000,,MAN:\nHow did we end up here? +Dialogue: Marked=1,0:02:12.16,0:02:15.20,3,autre,0000,0000,0000,,This place is horrible. +Dialogue: Marked=1,0:02:20.24,0:02:22.28,1,autre,0000,0000,0000,,Smells like balls. +Dialogue: Marked=1,0:02:28.32,0:02:31.36,2,autre,0000,0000,0000,,We don't belong\nin this shithole. +Dialogue: Marked=1,0:02:31.40,0:02:33.44,3,autre,0000,0000,0000,,(computer playing\nelectronic melody) \ No newline at end of file diff --git a/testdata/example-out.ssa b/testdata/example-out.ssa new file mode 100644 index 0000000..5bb47e5 --- /dev/null +++ b/testdata/example-out.ssa @@ -0,0 +1,20 @@ +[Script Info] +; Comment 1 +; Comment 2 +Original Editing: Copyright test +Title: SSA test + +[V4 Styles] +Format: Name, Alignment, AlphaLevel, BackColour, Bold, BorderStyle, Encoding, Fontname, Fontsize, Italic, MarginL, MarginR, MarginV, Outline, OutlineColour, PrimaryColour, SecondaryColour, Shadow +Style: 1,7,0.100,&H80000008,1,7,0,f1,4.000,0,1,4,7,1,&H0000ffff,&H0000ffff,&H0000ffff,4 +Style: 2,8,0.200,&H000f0f0f,1,8,1,f2,5.000,0,2,5,8,2,&H0000ffff,&H00efefef,&H0000ffff,5 +Style: 3,9,0.300,&H00000008,0,9,2,f3,6.000,0,3,6,9,3,&H00000008,&H00b4fcfc,&H00b4fcfc,6 + +[Events] +Format: Start, End, Effect, MarginL, MarginR, MarginV, Marked, Name, Style, Text +Dialogue: 00:01:39.00,00:01:41.04,test,1234,2345,3456,Marked=0,Cher,1,{\pos(400,570)}(deep rumbling) +Dialogue: 00:02:04.08,00:02:07.12,,0,0,0,Marked=1,autre,2,MAN:\nHow did we end up here? +Dialogue: 00:02:12.16,00:02:15.20,,0,0,0,Marked=1,autre,3,This place is horrible. +Dialogue: 00:02:20.24,00:02:22.28,,0,0,0,Marked=1,autre,1,Smells like balls. +Dialogue: 00:02:28.32,00:02:31.36,,0,0,0,Marked=1,autre,2,We don't belong\nin this shithole. +Dialogue: 00:02:31.40,00:02:33.44,,0,0,0,Marked=1,autre,3,(computer playing\nelectronic melody) diff --git a/ttml.go b/ttml.go index 3cb9a9e..57f4970 100644 --- a/ttml.go +++ b/ttml.go @@ -82,30 +82,30 @@ type TTMLInStyleAttributes struct { // StyleAttributes converts TTMLInStyleAttributes into a StyleAttributes func (s TTMLInStyleAttributes) styleAttributes() *StyleAttributes { return &StyleAttributes{ - BackgroundColor: s.BackgroundColor, - Color: s.Color, - Direction: s.Direction, - Display: s.Display, - DisplayAlign: s.DisplayAlign, - Extent: s.Extent, - FontFamily: s.FontFamily, - FontSize: s.FontSize, - FontStyle: s.FontStyle, - FontWeight: s.FontWeight, - LineHeight: s.LineHeight, - Opacity: s.Opacity, - Origin: s.Origin, - Overflow: s.Overflow, - Padding: s.Padding, - ShowBackground: s.ShowBackground, - TextAlign: s.TextAlign, - TextDecoration: s.TextDecoration, - TextOutline: s.TextOutline, - UnicodeBidi: s.UnicodeBidi, - Visibility: s.Visibility, - WrapOption: s.WrapOption, - WritingMode: s.WritingMode, - ZIndex: s.ZIndex, + TTMLBackgroundColor: s.BackgroundColor, + TTMLColor: s.Color, + TTMLDirection: s.Direction, + TTMLDisplay: s.Display, + TTMLDisplayAlign: s.DisplayAlign, + TTMLExtent: s.Extent, + TTMLFontFamily: s.FontFamily, + TTMLFontSize: s.FontSize, + TTMLFontStyle: s.FontStyle, + TTMLFontWeight: s.FontWeight, + TTMLLineHeight: s.LineHeight, + TTMLOpacity: s.Opacity, + TTMLOrigin: s.Origin, + TTMLOverflow: s.Overflow, + TTMLPadding: s.Padding, + TTMLShowBackground: s.ShowBackground, + TTMLTextAlign: s.TextAlign, + TTMLTextDecoration: s.TextDecoration, + TTMLTextOutline: s.TextOutline, + TTMLUnicodeBidi: s.UnicodeBidi, + TTMLVisibility: s.Visibility, + TTMLWrapOption: s.WrapOption, + TTMLWritingMode: s.WritingMode, + TTMLZIndex: s.ZIndex, } } @@ -205,7 +205,7 @@ func (d *TTMLInDuration) UnmarshalText(i []byte) (err error) { // Update text text = text[:indexes[0]] + ".000" } - d.d, err = parseDuration(text, ".") + d.d, err = parseDuration(text, ".", 3) return } @@ -347,7 +347,7 @@ func ReadFromTTML(i io.Reader) (o *Subtitles, err error) { } // Append items - *l = append(*l, t) + l.Items = append(l.Items, t) } } @@ -412,30 +412,30 @@ func ttmlOutStyleAttributesFromStyleAttributes(s *StyleAttributes) TTMLOutStyleA return TTMLOutStyleAttributes{} } return TTMLOutStyleAttributes{ - BackgroundColor: s.BackgroundColor, - Color: s.Color, - Direction: s.Direction, - Display: s.Display, - DisplayAlign: s.DisplayAlign, - Extent: s.Extent, - FontFamily: s.FontFamily, - FontSize: s.FontSize, - FontStyle: s.FontStyle, - FontWeight: s.FontWeight, - LineHeight: s.LineHeight, - Opacity: s.Opacity, - Origin: s.Origin, - Overflow: s.Overflow, - Padding: s.Padding, - ShowBackground: s.ShowBackground, - TextAlign: s.TextAlign, - TextDecoration: s.TextDecoration, - TextOutline: s.TextOutline, - UnicodeBidi: s.UnicodeBidi, - Visibility: s.Visibility, - WrapOption: s.WrapOption, - WritingMode: s.WritingMode, - ZIndex: s.ZIndex, + BackgroundColor: s.TTMLBackgroundColor, + Color: s.TTMLColor, + Direction: s.TTMLDirection, + Display: s.TTMLDisplay, + DisplayAlign: s.TTMLDisplayAlign, + Extent: s.TTMLExtent, + FontFamily: s.TTMLFontFamily, + FontSize: s.TTMLFontSize, + FontStyle: s.TTMLFontStyle, + FontWeight: s.TTMLFontWeight, + LineHeight: s.TTMLLineHeight, + Opacity: s.TTMLOpacity, + Origin: s.TTMLOrigin, + Overflow: s.TTMLOverflow, + Padding: s.TTMLPadding, + ShowBackground: s.TTMLShowBackground, + TextAlign: s.TTMLTextAlign, + TextDecoration: s.TTMLTextDecoration, + TextOutline: s.TTMLTextOutline, + UnicodeBidi: s.TTMLUnicodeBidi, + Visibility: s.TTMLVisibility, + WrapOption: s.TTMLWrapOption, + WritingMode: s.TTMLWritingMode, + ZIndex: s.TTMLZIndex, } } @@ -482,7 +482,7 @@ type TTMLOutDuration time.Duration // MarshalText implements the TextMarshaler interface func (t TTMLOutDuration) MarshalText() ([]byte, error) { - return []byte(formatDuration(time.Duration(t), ".")), nil + return []byte(formatDuration(time.Duration(t), ".", 3)), nil } // WriteToTTML writes subtitles in .ttml format @@ -566,7 +566,7 @@ func (s Subtitles) WriteToTTML(o io.Writer) (err error) { // Add lines for _, line := range item.Lines { // Loop through line items - for _, lineItem := range line { + for _, lineItem := range line.Items { // Init ttml item var ttmlItem = TTMLOutItem{ Text: lineItem.Text, diff --git a/ttml_test.go b/ttml_test.go index 7270116..92a79f6 100644 --- a/ttml_test.go +++ b/ttml_test.go @@ -18,24 +18,24 @@ func TestTTML(t *testing.T) { assert.Equal(t, &astisub.Metadata{Copyright: "Copyright test", Framerate: 25, Language: astisub.LanguageFrench, Title: "Title test"}, s.Metadata) // Styles assert.Equal(t, 3, len(s.Styles)) - assert.Equal(t, astisub.Style{ID: "style_0", InlineStyle: &astisub.StyleAttributes{Color: "white", Extent: "100% 10%", FontFamily: "sansSerif", FontStyle: "normal", Origin: "0% 90%", TextAlign: "center"}, Style: s.Styles["style_2"]}, *s.Styles["style_0"]) - assert.Equal(t, astisub.Style{ID: "style_1", InlineStyle: &astisub.StyleAttributes{Color: "white", Extent: "100% 13%", FontFamily: "sansSerif", FontStyle: "normal", Origin: "0% 87%", TextAlign: "center"}}, *s.Styles["style_1"]) - assert.Equal(t, astisub.Style{ID: "style_2", InlineStyle: &astisub.StyleAttributes{Color: "white", Extent: "100% 20%", FontFamily: "sansSerif", FontStyle: "normal", Origin: "0% 80%", TextAlign: "center"}}, *s.Styles["style_2"]) + assert.Equal(t, astisub.Style{ID: "style_0", InlineStyle: &astisub.StyleAttributes{TTMLColor: "white", TTMLExtent: "100% 10%", TTMLFontFamily: "sansSerif", TTMLFontStyle: "normal", TTMLOrigin: "0% 90%", TTMLTextAlign: "center"}, Style: s.Styles["style_2"]}, *s.Styles["style_0"]) + assert.Equal(t, astisub.Style{ID: "style_1", InlineStyle: &astisub.StyleAttributes{TTMLColor: "white", TTMLExtent: "100% 13%", TTMLFontFamily: "sansSerif", TTMLFontStyle: "normal", TTMLOrigin: "0% 87%", TTMLTextAlign: "center"}}, *s.Styles["style_1"]) + assert.Equal(t, astisub.Style{ID: "style_2", InlineStyle: &astisub.StyleAttributes{TTMLColor: "white", TTMLExtent: "100% 20%", TTMLFontFamily: "sansSerif", TTMLFontStyle: "normal", TTMLOrigin: "0% 80%", TTMLTextAlign: "center"}}, *s.Styles["style_2"]) // Regions assert.Equal(t, 3, len(s.Regions)) - assert.Equal(t, astisub.Region{ID: "region_0", Style: s.Styles["style_0"], InlineStyle: &astisub.StyleAttributes{Color: "blue"}}, *s.Regions["region_0"]) + assert.Equal(t, astisub.Region{ID: "region_0", Style: s.Styles["style_0"], InlineStyle: &astisub.StyleAttributes{TTMLColor: "blue"}}, *s.Regions["region_0"]) assert.Equal(t, astisub.Region{ID: "region_1", Style: s.Styles["style_1"], InlineStyle: &astisub.StyleAttributes{}}, *s.Regions["region_1"]) assert.Equal(t, astisub.Region{ID: "region_2", Style: s.Styles["style_2"], InlineStyle: &astisub.StyleAttributes{}}, *s.Regions["region_2"]) // Items assert.Equal(t, s.Regions["region_1"], s.Items[0].Region) assert.Equal(t, s.Styles["style_1"], s.Items[0].Style) - assert.Equal(t, &astisub.StyleAttributes{Color: "red"}, s.Items[0].InlineStyle) - assert.Equal(t, []astisub.Line{{{Style: s.Styles["style_1"], InlineStyle: &astisub.StyleAttributes{Color: "black"}, Text: "(deep rumbling)"}}}, s.Items[0].Lines) - assert.Equal(t, []astisub.Line{{{InlineStyle: &astisub.StyleAttributes{}, Text: "MAN:"}}, {{InlineStyle: &astisub.StyleAttributes{}, Text: "How did we"}, {InlineStyle: &astisub.StyleAttributes{Color: "green"}, Style: s.Styles["style_1"], Text: "end up"}, {InlineStyle: &astisub.StyleAttributes{}, Text: "here?"}}}, s.Items[1].Lines) - assert.Equal(t, []astisub.Line{{{InlineStyle: &astisub.StyleAttributes{}, Style: s.Styles["style_1"], Text: "This place is horrible."}}}, s.Items[2].Lines) - assert.Equal(t, []astisub.Line{{{InlineStyle: &astisub.StyleAttributes{}, Style: s.Styles["style_1"], Text: "Smells like balls."}}}, s.Items[3].Lines) - assert.Equal(t, []astisub.Line{{{InlineStyle: &astisub.StyleAttributes{}, Style: s.Styles["style_2"], Text: "We don't belong"}}, {{InlineStyle: &astisub.StyleAttributes{}, Style: s.Styles["style_1"], Text: "in this shithole."}}}, s.Items[4].Lines) - assert.Equal(t, []astisub.Line{{{InlineStyle: &astisub.StyleAttributes{}, Style: s.Styles["style_2"], Text: "(computer playing"}}, {{InlineStyle: &astisub.StyleAttributes{}, Style: s.Styles["style_1"], Text: "electronic melody)"}}}, s.Items[5].Lines) + assert.Equal(t, &astisub.StyleAttributes{TTMLColor: "red"}, s.Items[0].InlineStyle) + assert.Equal(t, []astisub.Line{{Items: []astisub.LineItem{{Style: s.Styles["style_1"], InlineStyle: &astisub.StyleAttributes{TTMLColor: "black"}, Text: "(deep rumbling)"}}}}, s.Items[0].Lines) + assert.Equal(t, []astisub.Line{{Items: []astisub.LineItem{{InlineStyle: &astisub.StyleAttributes{}, Text: "MAN:"}}}, {Items: []astisub.LineItem{{InlineStyle: &astisub.StyleAttributes{}, Text: "How did we"}, {InlineStyle: &astisub.StyleAttributes{TTMLColor: "green"}, Style: s.Styles["style_1"], Text: "end up"}, {InlineStyle: &astisub.StyleAttributes{}, Text: "here?"}}}}, s.Items[1].Lines) + assert.Equal(t, []astisub.Line{{Items: []astisub.LineItem{{InlineStyle: &astisub.StyleAttributes{}, Style: s.Styles["style_1"], Text: "This place is horrible."}}}}, s.Items[2].Lines) + assert.Equal(t, []astisub.Line{{Items: []astisub.LineItem{{InlineStyle: &astisub.StyleAttributes{}, Style: s.Styles["style_1"], Text: "Smells like balls."}}}}, s.Items[3].Lines) + assert.Equal(t, []astisub.Line{{Items: []astisub.LineItem{{InlineStyle: &astisub.StyleAttributes{}, Style: s.Styles["style_2"], Text: "We don't belong"}}}, {Items: []astisub.LineItem{{InlineStyle: &astisub.StyleAttributes{}, Style: s.Styles["style_1"], Text: "in this shithole."}}}}, s.Items[4].Lines) + assert.Equal(t, []astisub.Line{{Items: []astisub.LineItem{{InlineStyle: &astisub.StyleAttributes{}, Style: s.Styles["style_2"], Text: "(computer playing"}}}, {Items: []astisub.LineItem{{InlineStyle: &astisub.StyleAttributes{}, Style: s.Styles["style_1"], Text: "electronic melody)"}}}}, s.Items[5].Lines) // No subtitles to write w := &bytes.Buffer{} diff --git a/webvtt.go b/webvtt.go index ad14578..b365cee 100644 --- a/webvtt.go +++ b/webvtt.go @@ -31,7 +31,7 @@ var ( // parseDurationWebVTT parses a .vtt duration func parseDurationWebVTT(i string) (time.Duration, error) { - return parseDuration(i, ".") + return parseDuration(i, ".", 3) } // ReadFromWebVTT parses a .vtt content @@ -87,18 +87,18 @@ func ReadFromWebVTT(i io.Reader) (o *Subtitles, err error) { case "id": r.ID = split[1] case "lines": - if r.InlineStyle.Lines, err = strconv.Atoi(split[1]); err != nil { - err = errors.Wrapf(err, "astisub: atoi of %s failed", split[1]) + if r.InlineStyle.WebVTTLines, err = strconv.Atoi(split[1]); err != nil { + err = errors.Wrapf(err, "atoi of %s failed", split[1]) return } case "regionanchor": - r.InlineStyle.RegionAnchor = split[1] + r.InlineStyle.WebVTTRegionAnchor = split[1] case "scroll": - r.InlineStyle.Scroll = split[1] + r.InlineStyle.WebVTTScroll = split[1] case "viewportanchor": - r.InlineStyle.ViewportAnchor = split[1] + r.InlineStyle.WebVTTViewportAnchor = split[1] case "width": - r.InlineStyle.Width = split[1] + r.InlineStyle.WebVTTWidth = split[1] } } @@ -147,11 +147,11 @@ func ReadFromWebVTT(i io.Reader) (o *Subtitles, err error) { // Switch on key switch split[0] { case "align": - item.InlineStyle.Align = split[1] + item.InlineStyle.WebVTTAlign = split[1] case "line": - item.InlineStyle.Line = split[1] + item.InlineStyle.WebVTTLine = split[1] case "position": - item.InlineStyle.Position = split[1] + item.InlineStyle.WebVTTPosition = split[1] case "region": if _, ok := o.Regions[split[1]]; !ok { err = fmt.Errorf("astisub: Unknown region %s", split[1]) @@ -159,9 +159,9 @@ func ReadFromWebVTT(i io.Reader) (o *Subtitles, err error) { } item.Region = o.Regions[split[1]] case "size": - item.InlineStyle.Size = split[1] + item.InlineStyle.WebVTTSize = split[1] case "vertical": - item.InlineStyle.Vertical = split[1] + item.InlineStyle.WebVTTVertical = split[1] } } } @@ -180,7 +180,7 @@ func ReadFromWebVTT(i io.Reader) (o *Subtitles, err error) { case webvttBlockNameStyle: // TODO Do something with the style case webvttBlockNameText: - item.Lines = append(item.Lines, Line{{Text: line}}) + item.Lines = append(item.Lines, Line{Items: []LineItem{{Text: line}}}) default: // This is the ID // TODO Do something with the id @@ -192,7 +192,7 @@ func ReadFromWebVTT(i io.Reader) (o *Subtitles, err error) { // formatDurationWebVTT formats a .vtt duration func formatDurationWebVTT(i time.Duration) string { - return formatDuration(i, ".") + return formatDuration(i, ".", 3) } // WriteToWebVTT writes subtitles in .vtt format @@ -215,25 +215,25 @@ func (s Subtitles) WriteToWebVTT(o io.Writer) (err error) { sort.Strings(k) for _, id := range k { c = append(c, []byte("Region: id="+s.Regions[id].ID)...) - if s.Regions[id].InlineStyle.Lines != 0 { + if s.Regions[id].InlineStyle.WebVTTLines != 0 { c = append(c, bytesSpace...) - c = append(c, []byte("lines="+strconv.Itoa(s.Regions[id].InlineStyle.Lines))...) + c = append(c, []byte("lines="+strconv.Itoa(s.Regions[id].InlineStyle.WebVTTLines))...) } - if s.Regions[id].InlineStyle.RegionAnchor != "" { + if s.Regions[id].InlineStyle.WebVTTRegionAnchor != "" { c = append(c, bytesSpace...) - c = append(c, []byte("regionanchor="+s.Regions[id].InlineStyle.RegionAnchor)...) + c = append(c, []byte("regionanchor="+s.Regions[id].InlineStyle.WebVTTRegionAnchor)...) } - if s.Regions[id].InlineStyle.Scroll != "" { + if s.Regions[id].InlineStyle.WebVTTScroll != "" { c = append(c, bytesSpace...) - c = append(c, []byte("scroll="+s.Regions[id].InlineStyle.Scroll)...) + c = append(c, []byte("scroll="+s.Regions[id].InlineStyle.WebVTTScroll)...) } - if s.Regions[id].InlineStyle.ViewportAnchor != "" { + if s.Regions[id].InlineStyle.WebVTTViewportAnchor != "" { c = append(c, bytesSpace...) - c = append(c, []byte("viewportanchor="+s.Regions[id].InlineStyle.ViewportAnchor)...) + c = append(c, []byte("viewportanchor="+s.Regions[id].InlineStyle.WebVTTViewportAnchor)...) } - if s.Regions[id].InlineStyle.Width != "" { + if s.Regions[id].InlineStyle.WebVTTWidth != "" { c = append(c, bytesSpace...) - c = append(c, []byte("width="+s.Regions[id].InlineStyle.Width)...) + c = append(c, []byte("width="+s.Regions[id].InlineStyle.WebVTTWidth)...) } c = append(c, bytesLineSeparator...) } @@ -262,29 +262,29 @@ func (s Subtitles) WriteToWebVTT(o io.Writer) (err error) { // Add styles if item.InlineStyle != nil { - if item.InlineStyle.Align != "" { + if item.InlineStyle.WebVTTAlign != "" { c = append(c, bytesSpace...) - c = append(c, []byte("align:"+item.InlineStyle.Align)...) + c = append(c, []byte("align:"+item.InlineStyle.WebVTTAlign)...) } - if item.InlineStyle.Line != "" { + if item.InlineStyle.WebVTTLine != "" { c = append(c, bytesSpace...) - c = append(c, []byte("line:"+item.InlineStyle.Line)...) + c = append(c, []byte("line:"+item.InlineStyle.WebVTTLine)...) } - if item.InlineStyle.Position != "" { + if item.InlineStyle.WebVTTPosition != "" { c = append(c, bytesSpace...) - c = append(c, []byte("position:"+item.InlineStyle.Position)...) + c = append(c, []byte("position:"+item.InlineStyle.WebVTTPosition)...) } if item.Region != nil { c = append(c, bytesSpace...) c = append(c, []byte("region:"+item.Region.ID)...) } - if item.InlineStyle.Size != "" { + if item.InlineStyle.WebVTTSize != "" { c = append(c, bytesSpace...) - c = append(c, []byte("size:"+item.InlineStyle.Size)...) + c = append(c, []byte("size:"+item.InlineStyle.WebVTTSize)...) } - if item.InlineStyle.Vertical != "" { + if item.InlineStyle.WebVTTVertical != "" { c = append(c, bytesSpace...) - c = append(c, []byte("vertical:"+item.InlineStyle.Vertical)...) + c = append(c, []byte("vertical:"+item.InlineStyle.WebVTTVertical)...) } } diff --git a/webvtt_test.go b/webvtt_test.go index 112b6fe..1c815c2 100644 --- a/webvtt_test.go +++ b/webvtt_test.go @@ -19,12 +19,12 @@ func TestWebVTT(t *testing.T) { assert.Equal(t, []string{"This a comment inside the VTT", "and this is the second line"}, s.Items[1].Comments) // Regions assert.Equal(t, 2, len(s.Regions)) - assert.Equal(t, astisub.Region{ID: "fred", InlineStyle: &astisub.StyleAttributes{Lines: 3, RegionAnchor: "0%,100%", Scroll: "up", ViewportAnchor: "10%,90%", Width: "40%"}}, *s.Regions["fred"]) - assert.Equal(t, astisub.Region{ID: "bill", InlineStyle: &astisub.StyleAttributes{Lines: 3, RegionAnchor: "100%,100%", Scroll: "up", ViewportAnchor: "90%,90%", Width: "40%"}}, *s.Regions["bill"]) + assert.Equal(t, astisub.Region{ID: "fred", InlineStyle: &astisub.StyleAttributes{WebVTTLines: 3, WebVTTRegionAnchor: "0%,100%", WebVTTScroll: "up", WebVTTViewportAnchor: "10%,90%", WebVTTWidth: "40%"}}, *s.Regions["fred"]) + assert.Equal(t, astisub.Region{ID: "bill", InlineStyle: &astisub.StyleAttributes{WebVTTLines: 3, WebVTTRegionAnchor: "100%,100%", WebVTTScroll: "up", WebVTTViewportAnchor: "90%,90%", WebVTTWidth: "40%"}}, *s.Regions["bill"]) assert.Equal(t, s.Regions["bill"], s.Items[0].Region) assert.Equal(t, s.Regions["fred"], s.Items[1].Region) // Styles - assert.Equal(t, astisub.StyleAttributes{Align: "left", Position: "10%,start", Size: "35%"}, *s.Items[1].InlineStyle) + assert.Equal(t, astisub.StyleAttributes{WebVTTAlign: "left", WebVTTPosition: "10%,start", WebVTTSize: "35%"}, *s.Items[1].InlineStyle) // No subtitles to write w := &bytes.Buffer{}