├── .github └── workflows │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── astisub └── main.go ├── go.mod ├── go.sum ├── language.go ├── srt.go ├── srt_test.go ├── ssa.go ├── ssa_test.go ├── stl.go ├── stl_internal_test.go ├── stl_test.go ├── subtitles.go ├── subtitles_internal_test.go ├── subtitles_test.go ├── teletext.go ├── teletext_test.go ├── testdata ├── broken-1-in.vtt ├── example-in-breaklines.ttml ├── example-in-carriage-return.srt ├── example-in-carriage-return.ssa ├── example-in-carriage-return.vtt ├── example-in-html-entities.srt ├── example-in-html-entities.vtt ├── example-in-non-utf8.srt ├── example-in-non-utf8.vtt ├── example-in-nonzero-offset.stl ├── example-in-styled.srt ├── example-in.srt ├── example-in.ssa ├── example-in.stl ├── example-in.ttml ├── example-in.vtt ├── example-opn-in.stl ├── example-opn-out.stl ├── example-out-breaklines.ttml ├── example-out-html-entities.srt ├── example-out-html-entities.vtt ├── example-out-no-indent.ttml ├── example-out-styled.srt ├── example-out-styled.vtt ├── example-out-v4plus.ssa ├── example-out.srt ├── example-out.ssa ├── example-out.stl ├── example-out.ttml ├── example-out.vtt └── missing-sequence-in.srt ├── ttml.go ├── ttml_internal_test.go ├── ttml_test.go ├── webvtt.go ├── webvtt_internal_test.go └── webvtt_test.go /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | 9 | jobs: 10 | 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | 16 | - name: Set up Go 17 | uses: actions/setup-go@v4 18 | with: 19 | go-version: '1.20' 20 | 21 | - name: Install dependencies 22 | run: go mod download 23 | 24 | - name: Run tests 25 | run: go test -race -covermode atomic -coverprofile=covprofile ./... 26 | 27 | - if: github.event_name != 'pull_request' 28 | name: Send coverage 29 | env: 30 | COVERALLS_TOKEN: ${{ secrets.COVERALLS_TOKEN }} 31 | run: | 32 | go install github.com/mattn/goveralls@latest 33 | goveralls -coverprofile=covprofile -service=github 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | Thumbs.db 3 | .idea/ 4 | cover* 5 | test 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Quentin Renard 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![GoReportCard](http://goreportcard.com/badge/github.com/asticode/go-astisub)](http://goreportcard.com/report/github.com/asticode/go-astisub) 2 | [![GoDoc](https://godoc.org/github.com/asticode/go-astisub?status.svg)](https://godoc.org/github.com/asticode/go-astisub) 3 | [![Test](https://github.com/asticode/go-astisub/actions/workflows/test.yml/badge.svg)](https://github.com/asticode/go-astisub/actions/workflows/test.yml) 4 | [![Coveralls](https://coveralls.io/repos/github/asticode/go-astisub/badge.svg?branch=master)](https://coveralls.io/github/asticode/go-astisub) 5 | 6 | This is a Golang library to manipulate subtitles. 7 | 8 | It allows you to manipulate `srt`, `stl`, `ttml`, `ssa/ass`, `webvtt` and `teletext` files for now. 9 | 10 | Available operations are `parsing`, `writing`, `applying linear correction`, `syncing`, `fragmenting`, `unfragmenting`, `merging` and `optimizing`. 11 | 12 | # Installation 13 | 14 | To install the library: 15 | 16 | go get github.com/asticode/go-astisub 17 | 18 | To install the CLI: 19 | 20 | go install github.com/asticode/go-astisub/astisub 21 | 22 | # Using the library in your code 23 | 24 | WARNING: the code below doesn't handle errors for readibility purposes. However you SHOULD! 25 | 26 | ```go 27 | // Open subtitles 28 | s1, _ := astisub.OpenFile("/path/to/example.ttml") 29 | s2, _ := astisub.ReadFromSRT(bytes.NewReader([]byte("1\n00:01:00.000 --> 00:02:00.000\nCredits"))) 30 | 31 | // Add a duration to every subtitles (syncing) 32 | s1.Add(-2*time.Second) 33 | 34 | // Fragment the subtitles 35 | s1.Fragment(2*time.Second) 36 | 37 | // Merge subtitles 38 | s1.Merge(s2) 39 | 40 | // Optimize subtitles 41 | s1.Optimize() 42 | 43 | // Unfragment the subtitles 44 | s1.Unfragment() 45 | 46 | // Apply linear correction 47 | s1.ApplyLinearCorrection(1*time.Second, 2*time.Second, 5*time.Second, 7*time.Second) 48 | 49 | // Write subtitles 50 | s1.Write("/path/to/example.srt") 51 | var buf = &bytes.Buffer{} 52 | s2.WriteToTTML(buf) 53 | ``` 54 | 55 | # Using the CLI 56 | 57 | If **astisub** has been installed properly you can: 58 | 59 | - convert any type of subtitle to any other type of subtitle: 60 | 61 | astisub convert -i example.srt -o example.ttml 62 | 63 | - apply linear correction to any type of subtitle: 64 | 65 | astisub apply-linear-correction -i example.srt -a1 1s -d1 2s -a2 5s -d2 7s -o example.out.srt 66 | 67 | - fragment any type of subtitle: 68 | 69 | astisub fragment -i example.srt -f 2s -o example.out.srt 70 | 71 | - merge any type of subtitle into any other type of subtitle: 72 | 73 | astisub merge -i example.srt -i example.ttml -o example.out.srt 74 | 75 | - optimize any type of subtitle: 76 | 77 | astisub optimize -i example.srt -o example.out.srt 78 | 79 | - unfragment any type of subtitle: 80 | 81 | astisub unfragment -i example.srt -o example.out.srt 82 | 83 | - sync any type of subtitle: 84 | 85 | astisub sync -i example.srt -s "-2s" -o example.out.srt 86 | 87 | # Features and roadmap 88 | 89 | - [x] parsing 90 | - [x] writing 91 | - [x] syncing 92 | - [x] fragmenting/unfragmenting 93 | - [x] merging 94 | - [x] ordering 95 | - [x] optimizing 96 | - [x] linear correction 97 | - [x] .srt 98 | - [x] .ttml 99 | - [x] .vtt 100 | - [x] .stl 101 | - [x] .ssa/.ass 102 | - [x] .teletext 103 | - [ ] .smi 104 | -------------------------------------------------------------------------------- /astisub/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "log" 6 | 7 | "github.com/asticode/go-astikit" 8 | "github.com/asticode/go-astisub" 9 | ) 10 | 11 | // Flags 12 | var ( 13 | actual1 = flag.Duration("a1", 0, "the first actual duration") 14 | actual2 = flag.Duration("a2", 0, "the second actual duration") 15 | desired1 = flag.Duration("d1", 0, "the first desired duration") 16 | desired2 = flag.Duration("d2", 0, "the second desired duration") 17 | fragmentDuration = flag.Duration("f", 0, "the fragment duration") 18 | inputPath = astikit.NewFlagStrings() 19 | teletextPage = flag.Int("p", 0, "the teletext page") 20 | outputPath = flag.String("o", "", "the output path") 21 | syncDuration = flag.Duration("s", 0, "the sync duration") 22 | ) 23 | 24 | func main() { 25 | // Init 26 | cmd := astikit.FlagCmd() 27 | flag.Var(&inputPath, "i", "the input paths") 28 | flag.Parse() 29 | 30 | // Validate input path 31 | if len(*inputPath.Slice) == 0 { 32 | log.Fatal("Use -i to provide at least one input path") 33 | } 34 | 35 | // Validate output path 36 | if len(*outputPath) <= 0 { 37 | log.Fatal("Use -o to provide an output path") 38 | } 39 | 40 | // Open first input path 41 | var sub *astisub.Subtitles 42 | var err error 43 | if sub, err = astisub.Open(astisub.Options{Filename: (*inputPath.Slice)[0], Teletext: astisub.TeletextOptions{Page: *teletextPage}}); err != nil { 44 | log.Fatalf("%s while opening %s", err, (*inputPath.Slice)[0]) 45 | } 46 | 47 | // Switch on subcommand 48 | switch cmd { 49 | case "apply-linear-correction": 50 | // Validate actual and desired durations 51 | if *actual1 <= 0 { 52 | log.Fatal("Use -a1 to provide the first actual duration") 53 | } 54 | if *desired1 <= 0 { 55 | log.Fatal("Use -d1 to provide the first desired duration") 56 | } 57 | if *actual2 <= 0 { 58 | log.Fatal("Use -a2 to provide the second actual duration") 59 | } 60 | if *desired2 <= 0 { 61 | log.Fatal("Use -d2 to provide the second desired duration") 62 | } 63 | 64 | // Apply linear correction 65 | sub.ApplyLinearCorrection(*actual1, *desired1, *actual2, *desired2) 66 | 67 | // Write 68 | if err = sub.Write(*outputPath); err != nil { 69 | log.Fatalf("%s while writing to %s", err, *outputPath) 70 | } 71 | case "convert": 72 | // Write 73 | if err = sub.Write(*outputPath); err != nil { 74 | log.Fatalf("%s while writing to %s", err, *outputPath) 75 | } 76 | case "fragment": 77 | // Validate fragment duration 78 | if *fragmentDuration <= 0 { 79 | log.Fatal("Use -f to provide a fragment duration") 80 | } 81 | 82 | // Fragment 83 | sub.Fragment(*fragmentDuration) 84 | 85 | // Write 86 | if err = sub.Write(*outputPath); err != nil { 87 | log.Fatalf("%s while writing to %s", err, *outputPath) 88 | } 89 | case "merge": 90 | // Validate second input path 91 | if len(*inputPath.Slice) == 1 { 92 | log.Fatal("Use -i to provide at least two input paths") 93 | } 94 | 95 | // Open second input path 96 | var sub2 *astisub.Subtitles 97 | if sub2, err = astisub.Open(astisub.Options{Filename: (*inputPath.Slice)[1], Teletext: astisub.TeletextOptions{Page: *teletextPage}}); err != nil { 98 | log.Fatalf("%s while opening %s", err, (*inputPath.Slice)[1]) 99 | } 100 | 101 | // Merge 102 | sub.Merge(sub2) 103 | 104 | // Write 105 | if err = sub.Write(*outputPath); err != nil { 106 | log.Fatalf("%s while writing to %s", err, *outputPath) 107 | } 108 | case "optimize": 109 | // Optimize 110 | sub.Optimize() 111 | 112 | // Write 113 | if err = sub.Write(*outputPath); err != nil { 114 | log.Fatalf("%s while writing to %s", err, *outputPath) 115 | } 116 | case "sync": 117 | // Validate sync duration 118 | if *syncDuration == 0 { 119 | log.Fatal("Use -s to provide a sync duration") 120 | } 121 | 122 | // Fragment 123 | sub.Add(*syncDuration) 124 | 125 | // Write 126 | if err = sub.Write(*outputPath); err != nil { 127 | log.Fatalf("%s while writing to %s", err, *outputPath) 128 | } 129 | case "unfragment": 130 | // Unfragment 131 | sub.Unfragment() 132 | 133 | // Write 134 | if err = sub.Write(*outputPath); err != nil { 135 | log.Fatalf("%s while writing to %s", err, *outputPath) 136 | } 137 | default: 138 | log.Fatalf("Invalid subcommand %s", cmd) 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/asticode/go-astisub 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/asticode/go-astikit v0.20.0 7 | github.com/asticode/go-astits v1.8.0 8 | github.com/stretchr/testify v1.4.0 9 | golang.org/x/net v0.0.0-20200904194848-62affa334b73 10 | golang.org/x/text v0.3.2 11 | ) 12 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/asticode/go-astikit v0.20.0 h1:+7N+J4E4lWx2QOkRdOf6DafWJMv6O4RRfgClwQokrH8= 2 | github.com/asticode/go-astikit v0.20.0/go.mod h1:h4ly7idim1tNhaVkdVBeXQZEE3L0xblP7fCWbgwipF0= 3 | github.com/asticode/go-astits v1.8.0 h1:rf6aiiGn/QhlFjNON1n5plqF3Fs025XLUwiQ0NB6oZg= 4 | github.com/asticode/go-astits v1.8.0/go.mod h1:DkOWmBNQpnr9mv24KfZjq4JawCFX1FCqjLVGvO0DygQ= 5 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 6 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/pkg/profile v1.4.0/go.mod h1:NWz/XGvpEW1FyYQ7fCx4dqYBLlfTcE+A9FLAkNKqjFE= 8 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 9 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 10 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 11 | github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= 12 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 13 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 14 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 15 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 16 | golang.org/x/net v0.0.0-20200904194848-62affa334b73 h1:MXfv8rhZWmFeqX3GNZRsd6vOLoaCHjYEX3qkRo3YBUA= 17 | golang.org/x/net v0.0.0-20200904194848-62affa334b73/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= 18 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 19 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 20 | golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 21 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 22 | golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= 23 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 24 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 25 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 26 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 27 | gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= 28 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 29 | -------------------------------------------------------------------------------- /language.go: -------------------------------------------------------------------------------- 1 | package astisub 2 | 3 | // Languages 4 | const ( 5 | LanguageChinese = "chinese" 6 | LanguageEnglish = "english" 7 | LanguageFrench = "french" 8 | LanguageJapanese = "japanese" 9 | LanguageNorwegian = "norwegian" 10 | ) 11 | -------------------------------------------------------------------------------- /srt.go: -------------------------------------------------------------------------------- 1 | package astisub 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "strconv" 7 | "strings" 8 | "time" 9 | "unicode/utf8" 10 | 11 | "golang.org/x/net/html" 12 | ) 13 | 14 | // Constants 15 | const ( 16 | srtTimeBoundariesSeparator = "-->" 17 | ) 18 | 19 | // Vars 20 | var ( 21 | bytesSRTTimeBoundariesSeparator = []byte(" " + srtTimeBoundariesSeparator + " ") 22 | ) 23 | 24 | // parseDurationSRT parses an .srt duration 25 | func parseDurationSRT(i string) (d time.Duration, err error) { 26 | for _, s := range []string{",", "."} { 27 | if d, err = parseDuration(i, s, 3); err == nil { 28 | return 29 | } 30 | } 31 | return 32 | } 33 | 34 | // ReadFromSRT parses an .srt content 35 | func ReadFromSRT(i io.Reader) (o *Subtitles, err error) { 36 | // Init 37 | o = NewSubtitles() 38 | var scanner = newScanner(i) 39 | 40 | // Scan 41 | var line string 42 | var lineNum int 43 | var s = &Item{} 44 | var sa = &StyleAttributes{} 45 | for scanner.Scan() { 46 | // Fetch line 47 | line = strings.TrimSpace(scanner.Text()) 48 | lineNum++ 49 | if !utf8.ValidString(line) { 50 | err = fmt.Errorf("astisub: line %d is not valid utf-8", lineNum) 51 | return 52 | } 53 | 54 | // Remove BOM header 55 | if lineNum == 1 { 56 | line = strings.TrimPrefix(line, string(BytesBOM)) 57 | } 58 | 59 | // Line contains time boundaries 60 | if strings.Contains(line, srtTimeBoundariesSeparator) { 61 | // Reset style attributes 62 | sa = &StyleAttributes{} 63 | 64 | // Remove last item of previous subtitle since it should be the index. 65 | // If the last line is empty then the item is missing an index. 66 | var index string 67 | if len(s.Lines) != 0 { 68 | index = s.Lines[len(s.Lines)-1].String() 69 | if index != "" { 70 | s.Lines = s.Lines[:len(s.Lines)-1] 71 | } 72 | } 73 | 74 | // Remove trailing empty lines 75 | if len(s.Lines) > 0 { 76 | for i := len(s.Lines) - 1; i >= 0; i-- { 77 | if len(s.Lines[i].Items) > 0 { 78 | for j := len(s.Lines[i].Items) - 1; j >= 0; j-- { 79 | if len(s.Lines[i].Items[j].Text) == 0 { 80 | s.Lines[i].Items = s.Lines[i].Items[:j] 81 | } else { 82 | break 83 | } 84 | } 85 | if len(s.Lines[i].Items) == 0 { 86 | s.Lines = s.Lines[:i] 87 | } 88 | 89 | } 90 | } 91 | } 92 | 93 | // Init subtitle 94 | s = &Item{} 95 | 96 | // Fetch Index 97 | if index != "" { 98 | s.Index, _ = strconv.Atoi(index) 99 | } 100 | 101 | // Extract time boundaries 102 | s1 := strings.Split(line, srtTimeBoundariesSeparator) 103 | if l := len(s1); l < 2 { 104 | err = fmt.Errorf("astisub: line %d: time boundaries has only %d element(s)", lineNum, l) 105 | return 106 | } 107 | // We do this to eliminate extra stuff like positions which are not documented anywhere 108 | s2 := strings.Fields(s1[1]) 109 | 110 | // Parse time boundaries 111 | if s.StartAt, err = parseDurationSRT(s1[0]); err != nil { 112 | err = fmt.Errorf("astisub: line %d: parsing srt duration %s failed: %w", lineNum, s1[0], err) 113 | return 114 | } 115 | if s.EndAt, err = parseDurationSRT(s2[0]); err != nil { 116 | err = fmt.Errorf("astisub: line %d: parsing srt duration %s failed: %w", lineNum, s2[0], err) 117 | return 118 | } 119 | 120 | // Append subtitle 121 | o.Items = append(o.Items, s) 122 | } else { 123 | // Add text 124 | if l := parseTextSrt(line, sa); len(l.Items) > 0 { 125 | s.Lines = append(s.Lines, l) 126 | } 127 | } 128 | } 129 | return 130 | } 131 | 132 | // parseTextSrt parses the input line to fill the Line 133 | func parseTextSrt(i string, sa *StyleAttributes) (o Line) { 134 | // special handling needed for empty line 135 | if strings.TrimSpace(i) == "" { 136 | o.Items = []LineItem{{Text: ""}} 137 | return 138 | } 139 | 140 | // Create tokenizer 141 | tr := html.NewTokenizer(strings.NewReader(i)) 142 | 143 | // Loop 144 | for { 145 | // Get next tag 146 | t := tr.Next() 147 | 148 | // Process error 149 | if err := tr.Err(); err != nil { 150 | break 151 | } 152 | 153 | // Get unmodified text 154 | raw := string(tr.Raw()) 155 | // Get current token 156 | token := tr.Token() 157 | 158 | switch t { 159 | case html.EndTagToken: 160 | // Parse italic/bold/underline 161 | switch token.Data { 162 | case "b": 163 | sa.SRTBold = false 164 | case "i": 165 | sa.SRTItalics = false 166 | case "u": 167 | sa.SRTUnderline = false 168 | case "font": 169 | sa.SRTColor = nil 170 | } 171 | case html.StartTagToken: 172 | // Parse italic/bold/underline 173 | switch token.Data { 174 | case "b": 175 | sa.SRTBold = true 176 | case "i": 177 | sa.SRTItalics = true 178 | case "u": 179 | sa.SRTUnderline = true 180 | case "font": 181 | if c := htmlTokenAttribute(&token, "color"); c != nil { 182 | sa.SRTColor = c 183 | } 184 | } 185 | case html.TextToken: 186 | if s := strings.TrimSpace(raw); s != "" { 187 | // Get style attribute 188 | var styleAttributes *StyleAttributes 189 | if sa.SRTBold || sa.SRTColor != nil || sa.SRTItalics || sa.SRTUnderline { 190 | styleAttributes = &StyleAttributes{ 191 | SRTBold: sa.SRTBold, 192 | SRTColor: sa.SRTColor, 193 | SRTItalics: sa.SRTItalics, 194 | SRTUnderline: sa.SRTUnderline, 195 | } 196 | styleAttributes.propagateSRTAttributes() 197 | } 198 | 199 | // Append item 200 | o.Items = append(o.Items, LineItem{ 201 | InlineStyle: styleAttributes, 202 | Text: unescapeHTML(raw), 203 | }) 204 | } 205 | } 206 | } 207 | return 208 | } 209 | 210 | // formatDurationSRT formats an .srt duration 211 | func formatDurationSRT(i time.Duration) string { 212 | return formatDuration(i, ",", 3) 213 | } 214 | 215 | // WriteToSRT writes subtitles in .srt format 216 | func (s Subtitles) WriteToSRT(o io.Writer) (err error) { 217 | // Do not write anything if no subtitles 218 | if len(s.Items) == 0 { 219 | err = ErrNoSubtitlesToWrite 220 | return 221 | } 222 | 223 | // Add BOM header 224 | var c []byte 225 | c = append(c, BytesBOM...) 226 | 227 | // Loop through subtitles 228 | for k, v := range s.Items { 229 | // Add time boundaries 230 | c = append(c, []byte(strconv.Itoa(k+1))...) 231 | c = append(c, bytesLineSeparator...) 232 | c = append(c, []byte(formatDurationSRT(v.StartAt))...) 233 | c = append(c, bytesSRTTimeBoundariesSeparator...) 234 | c = append(c, []byte(formatDurationSRT(v.EndAt))...) 235 | c = append(c, bytesLineSeparator...) 236 | 237 | // Loop through lines 238 | for _, l := range v.Lines { 239 | c = append(c, []byte(l.srtBytes())...) 240 | } 241 | 242 | // Add new line 243 | c = append(c, bytesLineSeparator...) 244 | } 245 | 246 | // Remove last new line 247 | c = c[:len(c)-1] 248 | 249 | // Write 250 | if _, err = o.Write(c); err != nil { 251 | err = fmt.Errorf("astisub: writing failed: %w", err) 252 | return 253 | } 254 | return 255 | } 256 | 257 | func (l Line) srtBytes() (c []byte) { 258 | for _, li := range l.Items { 259 | c = append(c, li.srtBytes()...) 260 | } 261 | c = append(c, bytesLineSeparator...) 262 | return 263 | } 264 | 265 | func (li LineItem) srtBytes() (c []byte) { 266 | // Get color 267 | var color string 268 | if li.InlineStyle != nil && li.InlineStyle.SRTColor != nil { 269 | color = *li.InlineStyle.SRTColor 270 | } 271 | 272 | // Get bold/italics/underline 273 | b := li.InlineStyle != nil && li.InlineStyle.SRTBold 274 | i := li.InlineStyle != nil && li.InlineStyle.SRTItalics 275 | u := li.InlineStyle != nil && li.InlineStyle.SRTUnderline 276 | 277 | // Get position 278 | var pos byte 279 | if li.InlineStyle != nil { 280 | pos = li.InlineStyle.SRTPosition 281 | } 282 | 283 | // Append 284 | if color != "" { 285 | c = append(c, []byte("")...) 286 | } 287 | if b { 288 | c = append(c, []byte("")...) 289 | } 290 | if i { 291 | c = append(c, []byte("")...) 292 | } 293 | if u { 294 | c = append(c, []byte("")...) 295 | } 296 | if pos != 0 { 297 | c = append(c, []byte(fmt.Sprintf(`{\an%d}`, pos))...) 298 | } 299 | c = append(c, []byte(escapeHTML(li.Text))...) 300 | if u { 301 | c = append(c, []byte("")...) 302 | } 303 | if i { 304 | c = append(c, []byte("")...) 305 | } 306 | if b { 307 | c = append(c, []byte("")...) 308 | } 309 | if color != "" { 310 | c = append(c, []byte("")...) 311 | } 312 | return 313 | } 314 | -------------------------------------------------------------------------------- /srt_test.go: -------------------------------------------------------------------------------- 1 | package astisub_test 2 | 3 | import ( 4 | "bytes" 5 | "io/ioutil" 6 | "os" 7 | "strings" 8 | "testing" 9 | "time" 10 | 11 | "github.com/asticode/go-astisub" 12 | "github.com/stretchr/testify/assert" 13 | "github.com/stretchr/testify/require" 14 | ) 15 | 16 | func TestSRT(t *testing.T) { 17 | // Open 18 | s, err := astisub.OpenFile("./testdata/example-in.srt") 19 | assert.NoError(t, err) 20 | assertSubtitleItems(t, s) 21 | 22 | // No subtitles to write 23 | w := &bytes.Buffer{} 24 | err = astisub.Subtitles{}.WriteToSRT(w) 25 | assert.EqualError(t, err, astisub.ErrNoSubtitlesToWrite.Error()) 26 | 27 | // Write 28 | c, err := ioutil.ReadFile("./testdata/example-out.srt") 29 | assert.NoError(t, err) 30 | err = s.WriteToSRT(w) 31 | assert.NoError(t, err) 32 | assert.Equal(t, string(c), w.String()) 33 | } 34 | 35 | func TestSRTMissingSequence(t *testing.T) { 36 | // Open 37 | s, err := astisub.OpenFile("./testdata/missing-sequence-in.srt") 38 | assert.NoError(t, err) 39 | assertSubtitleItems(t, s) 40 | 41 | // No subtitles to write 42 | w := &bytes.Buffer{} 43 | err = astisub.Subtitles{}.WriteToSRT(w) 44 | assert.EqualError(t, err, astisub.ErrNoSubtitlesToWrite.Error()) 45 | 46 | // Write 47 | c, err := ioutil.ReadFile("./testdata/example-out.srt") 48 | assert.NoError(t, err) 49 | err = s.WriteToSRT(w) 50 | assert.NoError(t, err) 51 | assert.Equal(t, string(c), w.String()) 52 | } 53 | 54 | func TestNonUTF8SRT(t *testing.T) { 55 | _, err := astisub.OpenFile("./testdata/example-in-non-utf8.srt") 56 | assert.Error(t, err) 57 | } 58 | 59 | func TestSRTStyled(t *testing.T) { 60 | // Open 61 | s, err := astisub.OpenFile("./testdata/example-in-styled.srt") 62 | assert.NoError(t, err) 63 | 64 | // assert the items are properly parsed 65 | assert.Len(t, s.Items, 10) 66 | assert.Equal(t, 17*time.Second+985*time.Millisecond, s.Items[0].StartAt) 67 | assert.Equal(t, 20*time.Second+521*time.Millisecond, s.Items[0].EndAt) 68 | assert.Equal(t, "[instrumental music]", s.Items[0].Lines[0].String()) 69 | assert.Equal(t, 47*time.Second+115*time.Millisecond, s.Items[1].StartAt) 70 | assert.Equal(t, 48*time.Second+282*time.Millisecond, s.Items[1].EndAt) 71 | assert.Equal(t, "[ticks]", s.Items[1].Lines[0].String()) 72 | assert.Equal(t, 58*time.Second+192*time.Millisecond, s.Items[2].StartAt) 73 | assert.Equal(t, 59*time.Second+727*time.Millisecond, s.Items[2].EndAt) 74 | assert.Equal(t, "[instrumental music]", s.Items[2].Lines[0].String()) 75 | assert.Equal(t, 1*time.Minute+1*time.Second+662*time.Millisecond, s.Items[3].StartAt) 76 | assert.Equal(t, 1*time.Minute+3*time.Second+63*time.Millisecond, s.Items[3].EndAt) 77 | assert.Equal(t, "[dog barking]", s.Items[3].Lines[0].String()) 78 | assert.Equal(t, 1*time.Minute+26*time.Second+787*time.Millisecond, s.Items[4].StartAt) 79 | assert.Equal(t, 1*time.Minute+29*time.Second+523*time.Millisecond, s.Items[4].EndAt) 80 | assert.Equal(t, "[beeping]", s.Items[4].Lines[0].String()) 81 | assert.Equal(t, 1*time.Minute+29*time.Second+590*time.Millisecond, s.Items[5].StartAt) 82 | assert.Equal(t, 1*time.Minute+31*time.Second+992*time.Millisecond, s.Items[5].EndAt) 83 | assert.Equal(t, "[automated]", s.Items[5].Lines[0].String()) 84 | assert.Equal(t, "'The time is 7:35.'", s.Items[5].Lines[1].String()) 85 | assert.Equal(t, "Test with multi line italics", s.Items[6].Lines[0].String()) 86 | assert.Equal(t, "Terminated on the next line", s.Items[6].Lines[1].String()) 87 | assert.Equal(t, "Unterminated styles", s.Items[7].Lines[0].String()) 88 | assert.Equal(t, "Do no fall to the next item", s.Items[8].Lines[0].String()) 89 | assert.Equal(t, "x", s.Items[9].Lines[0].Items[0].Text) 90 | assert.Equal(t, "^3 * ", s.Items[9].Lines[0].Items[1].Text) 91 | assert.Equal(t, "x", s.Items[9].Lines[0].Items[2].Text) 92 | assert.Equal(t, " = 100", s.Items[9].Lines[0].Items[3].Text) 93 | 94 | // assert the styles of the items 95 | assert.Equal(t, "#00ff00", *s.Items[0].Lines[0].Items[0].InlineStyle.SRTColor) 96 | assert.True(t, s.Items[0].Lines[0].Items[0].InlineStyle.SRTBold) 97 | assert.False(t, s.Items[0].Lines[0].Items[0].InlineStyle.SRTItalics) 98 | assert.False(t, s.Items[0].Lines[0].Items[0].InlineStyle.SRTUnderline) 99 | assert.Equal(t, "#ff00ff", *s.Items[1].Lines[0].Items[0].InlineStyle.SRTColor) 100 | assert.False(t, s.Items[1].Lines[0].Items[0].InlineStyle.SRTBold) 101 | assert.False(t, s.Items[1].Lines[0].Items[0].InlineStyle.SRTItalics) 102 | assert.False(t, s.Items[1].Lines[0].Items[0].InlineStyle.SRTUnderline) 103 | assert.Equal(t, "#00ff00", *s.Items[2].Lines[0].Items[0].InlineStyle.SRTColor) 104 | assert.False(t, s.Items[2].Lines[0].Items[0].InlineStyle.SRTBold) 105 | assert.False(t, s.Items[2].Lines[0].Items[0].InlineStyle.SRTItalics) 106 | assert.False(t, s.Items[2].Lines[0].Items[0].InlineStyle.SRTUnderline) 107 | assert.Nil(t, s.Items[3].Lines[0].Items[0].InlineStyle.SRTColor) 108 | assert.True(t, s.Items[3].Lines[0].Items[0].InlineStyle.SRTBold) 109 | assert.False(t, s.Items[3].Lines[0].Items[0].InlineStyle.SRTItalics) 110 | assert.True(t, s.Items[3].Lines[0].Items[0].InlineStyle.SRTUnderline) 111 | assert.Nil(t, s.Items[4].Lines[0].Items[0].InlineStyle) 112 | assert.Nil(t, s.Items[5].Lines[0].Items[0].InlineStyle) 113 | assert.Nil(t, s.Items[5].Lines[1].Items[0].InlineStyle.SRTColor) 114 | assert.False(t, s.Items[5].Lines[1].Items[0].InlineStyle.SRTBold) 115 | assert.True(t, s.Items[5].Lines[1].Items[0].InlineStyle.SRTItalics) 116 | assert.False(t, s.Items[5].Lines[1].Items[0].InlineStyle.SRTUnderline) 117 | assert.True(t, s.Items[6].Lines[0].Items[0].InlineStyle.SRTItalics) 118 | assert.False(t, s.Items[6].Lines[0].Items[0].InlineStyle.SRTUnderline) 119 | assert.False(t, s.Items[6].Lines[0].Items[0].InlineStyle.SRTBold) 120 | assert.Nil(t, s.Items[6].Lines[0].Items[0].InlineStyle.SRTColor) 121 | assert.True(t, s.Items[6].Lines[1].Items[0].InlineStyle.SRTItalics) 122 | assert.False(t, s.Items[6].Lines[1].Items[0].InlineStyle.SRTUnderline) 123 | assert.False(t, s.Items[6].Lines[1].Items[0].InlineStyle.SRTBold) 124 | assert.Nil(t, s.Items[6].Lines[1].Items[0].InlineStyle.SRTColor) 125 | assert.True(t, s.Items[7].Lines[0].Items[0].InlineStyle.SRTItalics) 126 | assert.False(t, s.Items[7].Lines[0].Items[0].InlineStyle.SRTUnderline) 127 | assert.False(t, s.Items[7].Lines[0].Items[0].InlineStyle.SRTBold) 128 | assert.Nil(t, s.Items[7].Lines[0].Items[0].InlineStyle.SRTColor) 129 | assert.Nil(t, s.Items[8].Lines[0].Items[0].InlineStyle) 130 | assert.True(t, s.Items[9].Lines[0].Items[0].InlineStyle.SRTItalics) 131 | assert.Nil(t, s.Items[9].Lines[0].Items[1].InlineStyle) 132 | assert.True(t, s.Items[9].Lines[0].Items[2].InlineStyle.SRTItalics) 133 | assert.Nil(t, s.Items[9].Lines[0].Items[3].InlineStyle) 134 | 135 | // Write to srt 136 | w := &bytes.Buffer{} 137 | c, err := os.ReadFile("./testdata/example-out-styled.srt") 138 | assert.NoError(t, err) 139 | err = s.WriteToSRT(w) 140 | assert.NoError(t, err) 141 | assert.Equal(t, string(c), w.String()) 142 | 143 | // Write to WebVTT 144 | w = &bytes.Buffer{} 145 | c, err = os.ReadFile("./testdata/example-out-styled.vtt") 146 | assert.NoError(t, err) 147 | err = s.WriteToWebVTT(w) 148 | assert.NoError(t, err) 149 | assert.Equal(t, string(c), w.String()) 150 | } 151 | 152 | func TestSRTParseDuration(t *testing.T) { 153 | testData := ` 154 | 1 155 | 00:00:01.876-->00:0:03.390 156 | Duration without enclosing space` 157 | 158 | s, err := astisub.ReadFromSRT(strings.NewReader(testData)) 159 | require.NoError(t, err) 160 | 161 | require.Len(t, s.Items, 1) 162 | assert.Equal(t, 1*time.Second+876*time.Millisecond, s.Items[0].StartAt) 163 | assert.Equal(t, 3*time.Second+390*time.Millisecond, s.Items[0].EndAt) 164 | assert.Equal(t, "Duration without enclosing space", s.Items[0].Lines[0].String()) 165 | } 166 | -------------------------------------------------------------------------------- /ssa_test.go: -------------------------------------------------------------------------------- 1 | package astisub_test 2 | 3 | import ( 4 | "bytes" 5 | "io/ioutil" 6 | "testing" 7 | 8 | "github.com/asticode/go-astikit" 9 | "github.com/asticode/go-astisub" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func assertSSAStyle(t *testing.T, e, a astisub.Style) { 14 | assert.Equal(t, e.ID, a.ID) 15 | assertSSAStyleAttributes(t, *e.InlineStyle, *a.InlineStyle) 16 | } 17 | 18 | func assertSSAStyleAttributes(t *testing.T, e, a astisub.StyleAttributes) { 19 | if e.SSAAlignment != nil { 20 | assert.Equal(t, *e.SSAAlignment, *a.SSAAlignment) 21 | } 22 | if e.SSAAlphaLevel != nil { 23 | assert.Equal(t, *e.SSAAlphaLevel, *a.SSAAlphaLevel) 24 | } 25 | if e.SSABackColour != nil { 26 | assert.Equal(t, *e.SSABackColour, *a.SSABackColour) 27 | } 28 | if e.SSABold != nil { 29 | assert.Equal(t, *e.SSABold, *a.SSABold) 30 | } 31 | if e.SSABorderStyle != nil { 32 | assert.Equal(t, *e.SSABorderStyle, *a.SSABorderStyle) 33 | } 34 | if e.SSAFontSize != nil { 35 | assert.Equal(t, e.SSAFontName, a.SSAFontName) 36 | } 37 | if e.SSAFontSize != nil { 38 | assert.Equal(t, *e.SSAFontSize, *a.SSAFontSize) 39 | } 40 | if e.SSALayer != nil { 41 | assert.Equal(t, *e.SSALayer, *a.SSALayer) 42 | } 43 | if e.SSAMarked != nil { 44 | assert.Equal(t, *e.SSAMarked, *a.SSAMarked) 45 | } 46 | if e.SSAMarginLeft != nil { 47 | assert.Equal(t, *e.SSAMarginLeft, *a.SSAMarginLeft) 48 | } 49 | if e.SSAMarginRight != nil { 50 | assert.Equal(t, *e.SSAMarginRight, *a.SSAMarginRight) 51 | } 52 | if e.SSAMarginVertical != nil { 53 | assert.Equal(t, *e.SSAMarginVertical, *a.SSAMarginVertical) 54 | } 55 | if e.SSAOutline != nil { 56 | assert.Equal(t, *e.SSAOutline, *a.SSAOutline) 57 | } 58 | if e.SSAOutlineColour != nil { 59 | assert.Equal(t, *e.SSAOutlineColour, *a.SSAOutlineColour) 60 | } 61 | if e.SSAPrimaryColour != nil { 62 | assert.Equal(t, *e.SSAPrimaryColour, *a.SSAPrimaryColour) 63 | } 64 | if e.SSASecondaryColour != nil { 65 | assert.Equal(t, *e.SSASecondaryColour, *a.SSASecondaryColour) 66 | } 67 | if e.SSAShadow != nil { 68 | assert.Equal(t, *e.SSAShadow, *a.SSAShadow) 69 | } 70 | } 71 | 72 | func TestSSA(t *testing.T) { 73 | // Open 74 | s, err := astisub.OpenFile("./testdata/example-in.ssa") 75 | assert.NoError(t, err) 76 | assertSubtitleItems(t, s) 77 | // Metadata 78 | assert.Equal(t, &astisub.Metadata{Comments: []string{"Comment 1", "Comment 2"}, SSACollisions: "Normal", SSAOriginalScript: "asticode", SSAPlayDepth: astikit.IntPtr(0), SSAPlayResY: astikit.IntPtr(600), SSAScriptType: "v4.00", SSAScriptUpdatedBy: "version 2.8.01", SSATimer: astikit.Float64Ptr(100), Title: "SSA test"}, s.Metadata) 79 | // Styles 80 | assert.Equal(t, 3, len(s.Styles)) 81 | assertSSAStyle(t, astisub.Style{ID: "1", InlineStyle: &astisub.StyleAttributes{SSAAlignment: astikit.IntPtr(7), SSAAlphaLevel: astikit.Float64Ptr(0.1), SSABackColour: &astisub.Color{Alpha: 128, Red: 8}, SSABold: astikit.BoolPtr(true), SSABorderStyle: astikit.IntPtr(7), SSAFontName: "f1", SSAFontSize: astikit.Float64Ptr(4), SSAOutline: astikit.Float64Ptr(1), SSAOutlineColour: &astisub.Color{Green: 255, Red: 255}, SSAMarginLeft: astikit.IntPtr(1), SSAMarginRight: astikit.IntPtr(4), SSAMarginVertical: astikit.IntPtr(7), SSAPrimaryColour: &astisub.Color{Green: 255, Red: 255}, SSASecondaryColour: &astisub.Color{Green: 255, Red: 255}, SSAShadow: astikit.Float64Ptr(4)}}, *s.Styles["1"]) 82 | assertSSAStyle(t, astisub.Style{ID: "2", InlineStyle: &astisub.StyleAttributes{SSAAlignment: astikit.IntPtr(8), SSAAlphaLevel: astikit.Float64Ptr(0.2), SSABackColour: &astisub.Color{Blue: 15, Green: 15, Red: 15}, SSABold: astikit.BoolPtr(true), SSABorderStyle: astikit.IntPtr(8), SSAEncoding: astikit.IntPtr(1), SSAFontName: "f2", SSAFontSize: astikit.Float64Ptr(5), SSAOutline: astikit.Float64Ptr(2), SSAOutlineColour: &astisub.Color{Green: 255, Red: 255}, SSAMarginLeft: astikit.IntPtr(2), SSAMarginRight: astikit.IntPtr(5), SSAMarginVertical: astikit.IntPtr(8), SSAPrimaryColour: &astisub.Color{Blue: 239, Green: 239, Red: 239}, SSASecondaryColour: &astisub.Color{Green: 255, Red: 255}, SSAShadow: astikit.Float64Ptr(5)}}, *s.Styles["2"]) 83 | assertSSAStyle(t, astisub.Style{ID: "3", InlineStyle: &astisub.StyleAttributes{SSAAlignment: astikit.IntPtr(9), SSAAlphaLevel: astikit.Float64Ptr(0.3), SSABackColour: &astisub.Color{Red: 8}, SSABorderStyle: astikit.IntPtr(9), SSAEncoding: astikit.IntPtr(2), SSAFontName: "f3", SSAFontSize: astikit.Float64Ptr(6), SSAOutline: astikit.Float64Ptr(3), SSAOutlineColour: &astisub.Color{Red: 8}, SSAMarginLeft: astikit.IntPtr(3), SSAMarginRight: astikit.IntPtr(6), SSAMarginVertical: astikit.IntPtr(9), SSAPrimaryColour: &astisub.Color{Blue: 180, Green: 252, Red: 252}, SSASecondaryColour: &astisub.Color{Blue: 180, Green: 252, Red: 252}, SSAShadow: astikit.Float64Ptr(6)}}, *s.Styles["3"]) 84 | // Items 85 | assertSSAStyleAttributes(t, astisub.StyleAttributes{SSAEffect: "test", SSAMarked: astikit.BoolPtr(false), SSAMarginLeft: astikit.IntPtr(1234), SSAMarginRight: astikit.IntPtr(2345), SSAMarginVertical: astikit.IntPtr(3456)}, *s.Items[0].InlineStyle) 86 | assert.Equal(t, s.Styles["1"], s.Items[0].Style) 87 | assert.Equal(t, []astisub.Line{{Items: []astisub.LineItem{{InlineStyle: &astisub.StyleAttributes{SSAEffect: "{\\pos(400,570)}"}, Text: "(deep rumbling)"}}, VoiceName: "Cher"}}, s.Items[0].Lines) 88 | assert.Equal(t, s.Styles["2"], s.Items[1].Style) 89 | assert.Equal(t, s.Styles["3"], s.Items[2].Style) 90 | assert.Equal(t, s.Styles["1"], s.Items[3].Style) 91 | assert.Equal(t, s.Styles["2"], s.Items[4].Style) 92 | assert.Equal(t, s.Styles["3"], s.Items[5].Style) 93 | 94 | // No subtitles to write 95 | w := &bytes.Buffer{} 96 | err = astisub.Subtitles{}.WriteToSSA(w) 97 | assert.EqualError(t, err, astisub.ErrNoSubtitlesToWrite.Error()) 98 | 99 | // Write 100 | c, err := ioutil.ReadFile("./testdata/example-out.ssa") 101 | assert.NoError(t, err) 102 | err = s.WriteToSSA(w) 103 | assert.NoError(t, err) 104 | assert.Equal(t, string(c), w.String()) 105 | } 106 | 107 | func TestSSAv4plus(t *testing.T) { 108 | // Open 109 | s, err := astisub.OpenFile("./testdata/example-in.ssa") 110 | assert.NoError(t, err) 111 | assertSubtitleItems(t, s) 112 | // Metadata 113 | s.Metadata.SSAScriptType = "v4.00+" 114 | 115 | // Write 116 | w := &bytes.Buffer{} 117 | err = s.WriteToSSA(w) 118 | assert.NoError(t, err) 119 | 120 | c, err := ioutil.ReadFile("./testdata/example-out-v4plus.ssa") 121 | assert.NoError(t, err) 122 | assert.Equal(t, string(c), w.String()) 123 | } 124 | 125 | func TestInBetweenSSAEffect(t *testing.T) { 126 | s, err := astisub.ReadFromSSA(bytes.NewReader([]byte(`[Events] 127 | Format: Marked, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text 128 | Dialogue: Marked=0,0:01:39.00,0:01:41.04,,Cher,1234,2345,3456,test,First item{\pos(400,570)}Second item`))) 129 | assert.NoError(t, err) 130 | assert.Len(t, s.Items[0].Lines[0].Items, 2) 131 | assert.Equal(t, astisub.LineItem{Text: "First item"}, s.Items[0].Lines[0].Items[0]) 132 | assert.Equal(t, astisub.LineItem{ 133 | InlineStyle: &astisub.StyleAttributes{SSAEffect: "{\\pos(400,570)}"}, 134 | Text: "Second item", 135 | }, s.Items[0].Lines[0].Items[1]) 136 | } 137 | -------------------------------------------------------------------------------- /stl_internal_test.go: -------------------------------------------------------------------------------- 1 | package astisub 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/asticode/go-astikit" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestSTLDuration(t *testing.T) { 12 | // Default 13 | d, err := parseDurationSTL("12345678", 100) 14 | assert.NoError(t, err) 15 | assert.Equal(t, 12*time.Hour+34*time.Minute+56*time.Second+780*time.Millisecond, d) 16 | s := formatDurationSTL(d, 100) 17 | assert.Equal(t, "12345678", s) 18 | 19 | // Bytes 20 | b := formatDurationSTLBytes(d, 100) 21 | assert.Equal(t, []byte{0xc, 0x22, 0x38, 0x4e}, b) 22 | d2 := parseDurationSTLBytes([]byte{0xc, 0x22, 0x38, 0x4e}, 100) 23 | assert.Equal(t, d, d2) 24 | } 25 | 26 | func TestSTLCharacterHandler(t *testing.T) { 27 | h, err := newSTLCharacterHandler(stlCharacterCodeTableNumberLatin) 28 | assert.NoError(t, err) 29 | o := h.decode(0x1f) 30 | assert.Equal(t, []byte(nil), o) 31 | o = h.decode(0x65) 32 | assert.Equal(t, []byte("e"), o) 33 | o = h.decode(0xc1) 34 | assert.Equal(t, []byte(nil), o) 35 | o = h.decode(0x65) 36 | assert.Equal(t, []byte("è"), o) 37 | } 38 | 39 | func TestSTLCharacterHandlerUmlaut(t *testing.T) { 40 | h, err := newSTLCharacterHandler(stlCharacterCodeTableNumberLatin) 41 | assert.NoError(t, err) 42 | 43 | o := h.decode(0xc8) 44 | assert.Equal(t, []byte(nil), o) 45 | o = h.decode(0x61) 46 | assert.Equal(t, []byte("ä"), o) 47 | 48 | o = h.decode(0xc8) 49 | assert.Equal(t, []byte(nil), o) 50 | o = h.decode(0x41) 51 | assert.Equal(t, []byte("Ä"), o) 52 | 53 | o = h.decode(0xc8) 54 | assert.Equal(t, []byte(nil), o) 55 | o = h.decode(0x6f) 56 | assert.Equal(t, []byte("ö"), o) 57 | 58 | o = h.decode(0xc8) 59 | assert.Equal(t, []byte(nil), o) 60 | o = h.decode(0x4f) 61 | assert.Equal(t, []byte("Ö"), o) 62 | 63 | o = h.decode(0xc8) 64 | assert.Equal(t, []byte(nil), o) 65 | o = h.decode(0x75) 66 | assert.Equal(t, []byte("ü"), o) 67 | 68 | o = h.decode(0xc8) 69 | assert.Equal(t, []byte(nil), o) 70 | o = h.decode(0x55) 71 | assert.Equal(t, []byte("Ü"), o) 72 | 73 | o = h.decode(0xc8) 74 | assert.Equal(t, []byte(nil), o) 75 | o = h.decode(0x65) 76 | assert.Equal(t, []byte("ë"), o) 77 | 78 | o = h.decode(0xc8) 79 | assert.Equal(t, []byte(nil), o) 80 | o = h.decode(0x45) 81 | assert.Equal(t, []byte("Ë"), o) 82 | 83 | o = h.decode(0xc8) 84 | assert.Equal(t, []byte(nil), o) 85 | o = h.decode(0x69) 86 | assert.Equal(t, []byte("ï"), o) 87 | 88 | o = h.decode(0xc8) 89 | assert.Equal(t, []byte(nil), o) 90 | o = h.decode(0x49) 91 | assert.Equal(t, []byte("Ï"), o) 92 | } 93 | 94 | func TestSTLStyler(t *testing.T) { 95 | // Parse spacing attributes 96 | s := newSTLStyler() 97 | s.parseSpacingAttribute(0x80) 98 | assert.Equal(t, stlStyler{italics: astikit.BoolPtr(true)}, *s) 99 | s.parseSpacingAttribute(0x81) 100 | assert.Equal(t, stlStyler{italics: astikit.BoolPtr(false)}, *s) 101 | s = newSTLStyler() 102 | s.parseSpacingAttribute(0x82) 103 | assert.Equal(t, stlStyler{underline: astikit.BoolPtr(true)}, *s) 104 | s.parseSpacingAttribute(0x83) 105 | assert.Equal(t, stlStyler{underline: astikit.BoolPtr(false)}, *s) 106 | s = newSTLStyler() 107 | s.parseSpacingAttribute(0x84) 108 | assert.Equal(t, stlStyler{boxing: astikit.BoolPtr(true)}, *s) 109 | s.parseSpacingAttribute(0x85) 110 | assert.Equal(t, stlStyler{boxing: astikit.BoolPtr(false)}, *s) 111 | 112 | // Has been set 113 | s = newSTLStyler() 114 | assert.False(t, s.hasBeenSet()) 115 | s.boxing = astikit.BoolPtr(true) 116 | assert.True(t, s.hasBeenSet()) 117 | s = newSTLStyler() 118 | s.italics = astikit.BoolPtr(true) 119 | assert.True(t, s.hasBeenSet()) 120 | s = newSTLStyler() 121 | s.underline = astikit.BoolPtr(true) 122 | assert.True(t, s.hasBeenSet()) 123 | 124 | // Has changed 125 | s = newSTLStyler() 126 | sa := &StyleAttributes{} 127 | assert.False(t, s.hasChanged(sa)) 128 | s.boxing = astikit.BoolPtr(true) 129 | assert.True(t, s.hasChanged(sa)) 130 | sa.STLBoxing = s.boxing 131 | assert.False(t, s.hasChanged(sa)) 132 | s.italics = astikit.BoolPtr(true) 133 | assert.True(t, s.hasChanged(sa)) 134 | sa.STLItalics = s.italics 135 | assert.False(t, s.hasChanged(sa)) 136 | s.underline = astikit.BoolPtr(true) 137 | assert.True(t, s.hasChanged(sa)) 138 | sa.STLUnderline = s.underline 139 | assert.False(t, s.hasChanged(sa)) 140 | 141 | // Update 142 | s = newSTLStyler() 143 | sa = &StyleAttributes{} 144 | s.update(sa) 145 | assert.Equal(t, StyleAttributes{}, *sa) 146 | s.boxing = astikit.BoolPtr(true) 147 | s.update(sa) 148 | assert.Equal(t, StyleAttributes{STLBoxing: s.boxing}, *sa) 149 | s.italics = astikit.BoolPtr(true) 150 | s.update(sa) 151 | assert.Equal(t, StyleAttributes{STLBoxing: s.boxing, STLItalics: s.italics}, *sa) 152 | s.underline = astikit.BoolPtr(true) 153 | s.update(sa) 154 | assert.Equal(t, StyleAttributes{STLBoxing: s.boxing, STLItalics: s.italics, STLUnderline: s.underline}, *sa) 155 | } 156 | -------------------------------------------------------------------------------- /stl_test.go: -------------------------------------------------------------------------------- 1 | package astisub_test 2 | 3 | import ( 4 | "bytes" 5 | "io/ioutil" 6 | "os" 7 | "testing" 8 | "time" 9 | 10 | "github.com/asticode/go-astikit" 11 | "github.com/asticode/go-astisub" 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | func TestSTL(t *testing.T) { 16 | // Init 17 | creationDate, _ := time.Parse("060102", "170702") 18 | revisionDate, _ := time.Parse("060102", "010101") 19 | 20 | // Open 21 | s, err := astisub.OpenFile("./testdata/example-in.stl") 22 | assert.NoError(t, err) 23 | assertSubtitleItems(t, s) 24 | // Metadata 25 | assert.Equal(t, &astisub.Metadata{ 26 | Framerate: 25, 27 | Language: astisub.LanguageFrench, 28 | STLCreationDate: &creationDate, 29 | STLMaximumNumberOfDisplayableCharactersInAnyTextRow: astikit.IntPtr(40), 30 | STLMaximumNumberOfDisplayableRows: astikit.IntPtr(23), 31 | STLPublisher: "Copyright test", 32 | STLDisplayStandardCode: "1", 33 | STLRevisionDate: &revisionDate, 34 | STLSubtitleListReferenceCode: "12345678", 35 | STLCountryOfOrigin: "FRA", 36 | Title: "Title test"}, 37 | s.Metadata) 38 | 39 | // No subtitles to write 40 | w := &bytes.Buffer{} 41 | err = astisub.Subtitles{}.WriteToSTL(w) 42 | assert.EqualError(t, err, astisub.ErrNoSubtitlesToWrite.Error()) 43 | 44 | // Write 45 | c, err := ioutil.ReadFile("./testdata/example-out.stl") 46 | assert.NoError(t, err) 47 | err = s.WriteToSTL(w) 48 | assert.NoError(t, err) 49 | assert.Equal(t, string(c), w.String()) 50 | } 51 | 52 | func TestOPNSTL(t *testing.T) { 53 | // Init 54 | creationDate, _ := time.Parse("060102", "200110") 55 | revisionDate, _ := time.Parse("060102", "200110") 56 | 57 | // Open 58 | s, err := astisub.OpenFile("./testdata/example-opn-in.stl") 59 | assert.NoError(t, err) 60 | // Metadata 61 | assert.Equal(t, &astisub.Metadata{ 62 | Framerate: 25, 63 | Language: astisub.LanguageEnglish, 64 | STLCountryOfOrigin: "NOR", 65 | STLCreationDate: &creationDate, 66 | STLDisplayStandardCode: "0", 67 | STLMaximumNumberOfDisplayableCharactersInAnyTextRow: astikit.IntPtr(38), 68 | STLMaximumNumberOfDisplayableRows: astikit.IntPtr(11), 69 | STLPublisher: "", 70 | STLRevisionDate: &revisionDate, 71 | STLRevisionNumber: 1, 72 | Title: ""}, 73 | s.Metadata) 74 | 75 | // No subtitles to write 76 | w := &bytes.Buffer{} 77 | err = astisub.Subtitles{}.WriteToSTL(w) 78 | assert.EqualError(t, err, astisub.ErrNoSubtitlesToWrite.Error()) 79 | 80 | // Write 81 | c, err := ioutil.ReadFile("./testdata/example-opn-out.stl") 82 | assert.NoError(t, err) 83 | err = s.WriteToSTL(w) 84 | assert.NoError(t, err) 85 | assert.Equal(t, string(c), w.String()) 86 | } 87 | 88 | func TestIgnoreTimecodeStartOfProgramme(t *testing.T) { 89 | opts := astisub.STLOptions{IgnoreTimecodeStartOfProgramme: true} 90 | r, err := os.Open("./testdata/example-in-nonzero-offset.stl") 91 | assert.NoError(t, err) 92 | defer r.Close() 93 | 94 | s, err := astisub.ReadFromSTL(r, opts) 95 | assert.NoError(t, err) 96 | firstStart := 99 * time.Second 97 | assert.Equal(t, firstStart, s.Items[0].StartAt, "first start at 0") 98 | } 99 | -------------------------------------------------------------------------------- /subtitles.go: -------------------------------------------------------------------------------- 1 | package astisub 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "math" 10 | "os" 11 | "path/filepath" 12 | "sort" 13 | "strconv" 14 | "strings" 15 | "time" 16 | 17 | "github.com/asticode/go-astikit" 18 | "golang.org/x/net/html" 19 | ) 20 | 21 | // Bytes 22 | var ( 23 | BytesBOM = []byte{239, 187, 191} 24 | bytesLineSeparator = []byte("\n") 25 | bytesSpace = []byte(" ") 26 | ) 27 | 28 | // Colors 29 | var ( 30 | ColorBlack = &Color{} 31 | ColorBlue = &Color{Blue: 255} 32 | ColorCyan = &Color{Blue: 255, Green: 255} 33 | ColorGray = &Color{Blue: 128, Green: 128, Red: 128} 34 | ColorGreen = &Color{Green: 128} 35 | ColorLime = &Color{Green: 255} 36 | ColorMagenta = &Color{Blue: 255, Red: 255} 37 | ColorMaroon = &Color{Red: 128} 38 | ColorNavy = &Color{Blue: 128} 39 | ColorOlive = &Color{Green: 128, Red: 128} 40 | ColorPurple = &Color{Blue: 128, Red: 128} 41 | ColorRed = &Color{Red: 255} 42 | ColorSilver = &Color{Blue: 192, Green: 192, Red: 192} 43 | ColorTeal = &Color{Blue: 128, Green: 128} 44 | ColorYellow = &Color{Green: 255, Red: 255} 45 | ColorWhite = &Color{Blue: 255, Green: 255, Red: 255} 46 | ) 47 | 48 | // Errors 49 | var ( 50 | ErrInvalidExtension = errors.New("astisub: invalid extension") 51 | ErrNoSubtitlesToWrite = errors.New("astisub: no subtitles to write") 52 | ) 53 | 54 | // HTML Escape 55 | var ( 56 | htmlEscaper = strings.NewReplacer("&", "&", "<", "<", "\u00A0", " ") 57 | htmlUnescaper = strings.NewReplacer("&", "&", "<", "<", " ", "\u00A0") 58 | ) 59 | 60 | // Now allows testing functions using it 61 | var Now = func() time.Time { 62 | return time.Now() 63 | } 64 | 65 | // Options represents open or write options 66 | type Options struct { 67 | Filename string 68 | Teletext TeletextOptions 69 | STL STLOptions 70 | } 71 | 72 | // Open opens a subtitle reader based on options 73 | func Open(o Options) (s *Subtitles, err error) { 74 | // Open the file 75 | var f *os.File 76 | if f, err = os.Open(o.Filename); err != nil { 77 | err = fmt.Errorf("astisub: opening %s failed: %w", o.Filename, err) 78 | return 79 | } 80 | defer f.Close() 81 | 82 | // Parse the content 83 | switch filepath.Ext(strings.ToLower(o.Filename)) { 84 | case ".srt": 85 | s, err = ReadFromSRT(f) 86 | case ".ssa", ".ass": 87 | s, err = ReadFromSSA(f) 88 | case ".stl": 89 | s, err = ReadFromSTL(f, o.STL) 90 | case ".ts": 91 | s, err = ReadFromTeletext(f, o.Teletext) 92 | case ".ttml": 93 | s, err = ReadFromTTML(f) 94 | case ".vtt": 95 | s, err = ReadFromWebVTT(f) 96 | default: 97 | err = ErrInvalidExtension 98 | } 99 | return 100 | } 101 | 102 | // OpenFile opens a file regardless of other options 103 | func OpenFile(filename string) (*Subtitles, error) { 104 | return Open(Options{Filename: filename}) 105 | } 106 | 107 | // Subtitles represents an ordered list of items with formatting 108 | type Subtitles struct { 109 | Items []*Item 110 | Metadata *Metadata 111 | Regions map[string]*Region 112 | Styles map[string]*Style 113 | } 114 | 115 | // NewSubtitles creates new subtitles 116 | func NewSubtitles() *Subtitles { 117 | return &Subtitles{ 118 | Regions: make(map[string]*Region), 119 | Styles: make(map[string]*Style), 120 | } 121 | } 122 | 123 | // Item represents a text to show between 2 time boundaries with formatting 124 | type Item struct { 125 | Comments []string 126 | Index int 127 | EndAt time.Duration 128 | InlineStyle *StyleAttributes 129 | Lines []Line 130 | Region *Region 131 | StartAt time.Duration 132 | Style *Style 133 | } 134 | 135 | // String implements the Stringer interface 136 | func (i Item) String() string { 137 | var os []string 138 | for _, l := range i.Lines { 139 | os = append(os, l.String()) 140 | } 141 | return strings.Join(os, " - ") 142 | } 143 | 144 | // Color represents a color 145 | type Color struct { 146 | Alpha, Blue, Green, Red uint8 147 | } 148 | 149 | // newColorFromSSAString builds a new color based on an SSA string 150 | func newColorFromSSAString(s string, base int) (c *Color, err error) { 151 | var i int64 152 | if i, err = strconv.ParseInt(s, base, 64); err != nil { 153 | err = fmt.Errorf("parsing int %s with base %d failed: %w", s, base, err) 154 | return 155 | } 156 | c = &Color{ 157 | Alpha: uint8(i>>24) & 0xff, 158 | Blue: uint8(i>>16) & 0xff, 159 | Green: uint8(i>>8) & 0xff, 160 | Red: uint8(i) & 0xff, 161 | } 162 | return 163 | } 164 | 165 | // SSAString expresses the color as an SSA string 166 | func (c *Color) SSAString() string { 167 | return fmt.Sprintf("%.8x", uint32(c.Alpha)<<24|uint32(c.Blue)<<16|uint32(c.Green)<<8|uint32(c.Red)) 168 | } 169 | 170 | // TTMLString expresses the color as a TTML string 171 | func (c *Color) TTMLString() string { 172 | return fmt.Sprintf("%.6x", uint32(c.Red)<<16|uint32(c.Green)<<8|uint32(c.Blue)) 173 | } 174 | 175 | type Justification int 176 | 177 | var ( 178 | JustificationUnchanged = Justification(1) 179 | JustificationLeft = Justification(2) 180 | JustificationCentered = Justification(3) 181 | JustificationRight = Justification(4) 182 | ) 183 | 184 | // StyleAttributes represents style attributes 185 | type StyleAttributes struct { 186 | SRTBold bool 187 | SRTColor *string 188 | SRTItalics bool 189 | SRTPosition byte // 1-9 numpad layout 190 | SRTUnderline bool 191 | SSAAlignment *int 192 | SSAAlphaLevel *float64 193 | SSAAngle *float64 // degrees 194 | SSABackColour *Color 195 | SSABold *bool 196 | SSABorderStyle *int 197 | SSAEffect string 198 | SSAEncoding *int 199 | SSAFontName string 200 | SSAFontSize *float64 201 | SSAItalic *bool 202 | SSALayer *int 203 | SSAMarginLeft *int // pixels 204 | SSAMarginRight *int // pixels 205 | SSAMarginVertical *int // pixels 206 | SSAMarked *bool 207 | SSAOutline *float64 // pixels 208 | SSAOutlineColour *Color 209 | SSAPrimaryColour *Color 210 | SSAScaleX *float64 // % 211 | SSAScaleY *float64 // % 212 | SSASecondaryColour *Color 213 | SSAShadow *float64 // pixels 214 | SSASpacing *float64 // pixels 215 | SSAStrikeout *bool 216 | SSAUnderline *bool 217 | STLBoxing *bool 218 | STLItalics *bool 219 | STLJustification *Justification 220 | STLPosition *STLPosition 221 | STLUnderline *bool 222 | TeletextColor *Color 223 | TeletextDoubleHeight *bool 224 | TeletextDoubleSize *bool 225 | TeletextDoubleWidth *bool 226 | TeletextSpacesAfter *int 227 | TeletextSpacesBefore *int 228 | // TODO Use pointers with real types below 229 | TTMLBackgroundColor *string // https://htmlcolorcodes.com/fr/ 230 | TTMLColor *string 231 | TTMLDirection *string 232 | TTMLDisplay *string 233 | TTMLDisplayAlign *string 234 | TTMLExtent *string 235 | TTMLFontFamily *string 236 | TTMLFontSize *string 237 | TTMLFontStyle *string 238 | TTMLFontWeight *string 239 | TTMLLineHeight *string 240 | TTMLOpacity *string 241 | TTMLOrigin *string 242 | TTMLOverflow *string 243 | TTMLPadding *string 244 | TTMLShowBackground *string 245 | TTMLTextAlign *string 246 | TTMLTextDecoration *string 247 | TTMLTextOutline *string 248 | TTMLUnicodeBidi *string 249 | TTMLVisibility *string 250 | TTMLWrapOption *string 251 | TTMLWritingMode *string 252 | TTMLZIndex *int 253 | WebVTTAlign string 254 | WebVTTBold bool 255 | WebVTTItalics bool 256 | WebVTTLine string 257 | WebVTTLines int 258 | WebVTTPosition string 259 | WebVTTRegionAnchor string 260 | WebVTTScroll string 261 | WebVTTSize string 262 | WebVTTStyles []string 263 | WebVTTTags []WebVTTTag 264 | WebVTTUnderline bool 265 | WebVTTVertical string 266 | WebVTTViewportAnchor string 267 | WebVTTWidth string 268 | } 269 | 270 | type WebVTTTag struct { 271 | Name string 272 | Annotation string 273 | Classes []string 274 | } 275 | 276 | func (t WebVTTTag) startTag() string { 277 | if t.Name == "" { 278 | return "" 279 | } 280 | 281 | s := t.Name 282 | if len(t.Classes) > 0 { 283 | s += "." + strings.Join(t.Classes, ".") 284 | } 285 | 286 | if t.Annotation != "" { 287 | s += " " + t.Annotation 288 | } 289 | 290 | return "<" + s + ">" 291 | } 292 | 293 | func (t WebVTTTag) endTag() string { 294 | if t.Name == "" { 295 | return "" 296 | } 297 | return "" 298 | } 299 | 300 | func (sa *StyleAttributes) propagateSRTAttributes() { 301 | // copy relevant attrs to WebVTT ones 302 | if sa.SRTColor != nil { 303 | // TODO: handle non-default colors that need custom styles 304 | sa.TTMLColor = sa.SRTColor 305 | } 306 | 307 | switch sa.SRTPosition { 308 | case 7: // top-left 309 | sa.WebVTTAlign = "left" 310 | sa.WebVTTPosition = "10%" 311 | case 8: // top-center 312 | sa.WebVTTPosition = "10%" 313 | case 9: // top-right 314 | sa.WebVTTAlign = "right" 315 | sa.WebVTTPosition = "10%" 316 | case 4: // middle-left 317 | sa.WebVTTAlign = "left" 318 | sa.WebVTTPosition = "50%" 319 | case 5: // middle-center 320 | sa.WebVTTPosition = "50%" 321 | case 6: // middle-right 322 | sa.WebVTTAlign = "right" 323 | sa.WebVTTPosition = "50%" 324 | case 1: // bottom-left 325 | sa.WebVTTAlign = "left" 326 | sa.WebVTTPosition = "90%" 327 | case 2: // bottom-center 328 | sa.WebVTTPosition = "90%" 329 | case 3: // bottom-right 330 | sa.WebVTTAlign = "right" 331 | sa.WebVTTPosition = "90%" 332 | } 333 | 334 | sa.WebVTTBold = sa.SRTBold 335 | sa.WebVTTItalics = sa.SRTItalics 336 | sa.WebVTTUnderline = sa.SRTUnderline 337 | 338 | sa.WebVTTTags = make([]WebVTTTag, 0) 339 | if sa.WebVTTBold { 340 | sa.WebVTTTags = append(sa.WebVTTTags, WebVTTTag{Name: "b"}) 341 | } 342 | if sa.WebVTTItalics { 343 | sa.WebVTTTags = append(sa.WebVTTTags, WebVTTTag{Name: "i"}) 344 | } 345 | if sa.WebVTTUnderline { 346 | sa.WebVTTTags = append(sa.WebVTTTags, WebVTTTag{Name: "u"}) 347 | } 348 | } 349 | 350 | func (sa *StyleAttributes) propagateSSAAttributes() {} 351 | 352 | func (sa *StyleAttributes) propagateSTLAttributes() { 353 | if sa.STLJustification != nil { 354 | switch *sa.STLJustification { 355 | case JustificationCentered: 356 | // default to middle anyway? 357 | case JustificationRight: 358 | sa.WebVTTAlign = "right" 359 | case JustificationLeft: 360 | sa.WebVTTAlign = "left" 361 | } 362 | } 363 | // converts STL vertical position (row number) to WebVTT line percentage 364 | if sa.STLPosition != nil && sa.STLPosition.MaxRows > 0 { 365 | // in-vision vertical position ranges from 0 to maxrows (maxrows <= 99) 366 | sa.WebVTTLine = fmt.Sprintf("%d%%", sa.STLPosition.VerticalPosition*100/sa.STLPosition.MaxRows) 367 | // teletext vertical position ranges from 1 to 23; as webvtt line percentage starts 368 | // from the top at 0%, substract 1 to the stl position to get a better conversion. 369 | // Especially apparent on Shaka player, where a single line at vp 22 would be half 370 | // out of bounds at 95% (22*100/23), and fine at 91% (21*100/23) 371 | if sa.STLPosition.MaxRows == 23 && sa.STLPosition.VerticalPosition > 0 { 372 | sa.WebVTTLine = fmt.Sprintf("%d%%", (sa.STLPosition.VerticalPosition-1)*100/sa.STLPosition.MaxRows) 373 | } 374 | } 375 | } 376 | 377 | func (sa *StyleAttributes) propagateTeletextAttributes() { 378 | if sa.TeletextColor != nil { 379 | sa.TTMLColor = astikit.StrPtr("#" + sa.TeletextColor.TTMLString()) 380 | } 381 | } 382 | 383 | // reference for migration: https://w3c.github.io/ttml-webvtt-mapping/ 384 | func (sa *StyleAttributes) propagateTTMLAttributes() { 385 | if sa.TTMLTextAlign != nil { 386 | sa.WebVTTAlign = *sa.TTMLTextAlign 387 | } 388 | if sa.TTMLExtent != nil { 389 | //region settings 390 | lineHeight := 5 //assuming height of line as 5.33vh 391 | dimensions := strings.Split(*sa.TTMLExtent, " ") 392 | if len(dimensions) > 1 { 393 | sa.WebVTTWidth = dimensions[0] 394 | if height, err := strconv.Atoi(strings.ReplaceAll(dimensions[1], "%", "")); err == nil { 395 | sa.WebVTTLines = height / lineHeight 396 | } 397 | //cue settings 398 | //default TTML WritingMode is lrtb i.e. left to right, top to bottom 399 | sa.WebVTTSize = dimensions[1] 400 | if sa.TTMLWritingMode != nil && strings.HasPrefix(*sa.TTMLWritingMode, "tb") { 401 | sa.WebVTTSize = dimensions[0] 402 | } 403 | } 404 | } 405 | if sa.TTMLOrigin != nil { 406 | //region settings 407 | sa.WebVTTRegionAnchor = "0%,0%" 408 | sa.WebVTTViewportAnchor = strings.ReplaceAll(strings.TrimSpace(*sa.TTMLOrigin), " ", ",") 409 | sa.WebVTTScroll = "up" 410 | //cue settings 411 | coordinates := strings.Split(*sa.TTMLOrigin, " ") 412 | if len(coordinates) > 1 { 413 | sa.WebVTTLine = coordinates[0] 414 | sa.WebVTTPosition = coordinates[1] 415 | if sa.TTMLWritingMode != nil && strings.HasPrefix(*sa.TTMLWritingMode, "tb") { 416 | sa.WebVTTLine = coordinates[1] 417 | sa.WebVTTPosition = coordinates[0] 418 | } 419 | } 420 | } 421 | } 422 | 423 | func (sa *StyleAttributes) propagateWebVTTAttributes() { 424 | // copy relevant attrs to SRT ones 425 | if sa.TTMLColor != nil { 426 | sa.SRTColor = sa.TTMLColor 427 | } 428 | sa.SRTBold = sa.WebVTTBold 429 | sa.SRTItalics = sa.WebVTTItalics 430 | sa.SRTUnderline = sa.WebVTTUnderline 431 | } 432 | 433 | // Metadata represents metadata 434 | // TODO Merge attributes 435 | type Metadata struct { 436 | Comments []string 437 | Framerate int 438 | Language string 439 | SSACollisions string 440 | SSAOriginalEditing string 441 | SSAOriginalScript string 442 | SSAOriginalTiming string 443 | SSAOriginalTranslation string 444 | SSAPlayDepth *int 445 | SSAPlayResX, SSAPlayResY *int 446 | SSAScriptType string 447 | SSAScriptUpdatedBy string 448 | SSASynchPoint string 449 | SSATimer *float64 450 | SSAUpdateDetails string 451 | SSAWrapStyle string 452 | STLCountryOfOrigin string 453 | STLCreationDate *time.Time 454 | STLDisplayStandardCode string 455 | STLEditorContactDetails string 456 | STLEditorName string 457 | STLMaximumNumberOfDisplayableCharactersInAnyTextRow *int 458 | STLMaximumNumberOfDisplayableRows *int 459 | STLOriginalEpisodeTitle string 460 | STLPublisher string 461 | STLRevisionDate *time.Time 462 | STLRevisionNumber int 463 | STLSubtitleListReferenceCode string 464 | STLTimecodeStartOfProgramme time.Duration 465 | STLTranslatedEpisodeTitle string 466 | STLTranslatedProgramTitle string 467 | STLTranslatorContactDetails string 468 | STLTranslatorName string 469 | Title string 470 | TTMLCopyright string 471 | WebVTTTimestampMap *WebVTTTimestampMap 472 | } 473 | 474 | // Region represents a subtitle's region 475 | type Region struct { 476 | ID string 477 | InlineStyle *StyleAttributes 478 | Style *Style 479 | } 480 | 481 | // Style represents a subtitle's style 482 | type Style struct { 483 | ID string 484 | InlineStyle *StyleAttributes 485 | Style *Style 486 | } 487 | 488 | // Line represents a set of formatted line items 489 | type Line struct { 490 | Items []LineItem 491 | VoiceName string 492 | } 493 | 494 | // String implement the Stringer interface 495 | func (l Line) String() string { 496 | var texts []string 497 | for _, i := range l.Items { 498 | texts = append(texts, i.Text) 499 | } 500 | // Don't add spaces here since items must contain their own space 501 | return strings.Join(texts, "") 502 | } 503 | 504 | // LineItem represents a formatted line item 505 | type LineItem struct { 506 | InlineStyle *StyleAttributes 507 | StartAt time.Duration 508 | Style *Style 509 | Text string 510 | } 511 | 512 | // Add adds a duration to each time boundaries. As in the time package, duration can be negative. 513 | func (s *Subtitles) Add(d time.Duration) { 514 | for idx := 0; idx < len(s.Items); idx++ { 515 | s.Items[idx].EndAt += d 516 | s.Items[idx].StartAt += d 517 | if s.Items[idx].EndAt <= 0 && s.Items[idx].StartAt <= 0 { 518 | s.Items = append(s.Items[:idx], s.Items[idx+1:]...) 519 | idx-- 520 | } else if s.Items[idx].StartAt <= 0 { 521 | s.Items[idx].StartAt = time.Duration(0) 522 | } 523 | } 524 | } 525 | 526 | // Duration returns the subtitles duration 527 | func (s Subtitles) Duration() time.Duration { 528 | if len(s.Items) == 0 { 529 | return time.Duration(0) 530 | } 531 | return s.Items[len(s.Items)-1].EndAt 532 | } 533 | 534 | // ForceDuration updates the subtitles duration. 535 | // If requested duration is bigger, then we create a dummy item. 536 | // If requested duration is smaller, then we remove useless items and we cut the last item or add a dummy item. 537 | func (s *Subtitles) ForceDuration(d time.Duration, addDummyItem bool) { 538 | // Requested duration is the same as the subtitles'one 539 | if s.Duration() == d { 540 | return 541 | } 542 | 543 | // Requested duration is bigger than subtitles'one 544 | if s.Duration() > d { 545 | // Find last item before input duration and update end at 546 | var lastIndex = -1 547 | for index, i := range s.Items { 548 | // Start at is bigger than input duration, we've found the last item 549 | if i.StartAt >= d { 550 | lastIndex = index 551 | break 552 | } else if i.EndAt > d { 553 | s.Items[index].EndAt = d 554 | } 555 | } 556 | 557 | // Last index has been found 558 | if lastIndex != -1 { 559 | s.Items = s.Items[:lastIndex] 560 | } 561 | } 562 | 563 | // Add dummy item with the minimum duration possible 564 | if addDummyItem && s.Duration() < d { 565 | s.Items = append(s.Items, &Item{EndAt: d, Lines: []Line{{Items: []LineItem{{Text: "..."}}}}, StartAt: d - time.Millisecond}) 566 | } 567 | } 568 | 569 | // Fragment fragments subtitles with a specific fragment duration 570 | func (s *Subtitles) Fragment(f time.Duration) { 571 | // Nothing to fragment 572 | if len(s.Items) == 0 { 573 | return 574 | } 575 | 576 | // Here we want to simulate fragments of duration f until there are no subtitles left in that period of time 577 | var fragmentStartAt, fragmentEndAt = time.Duration(0), f 578 | for fragmentStartAt < s.Items[len(s.Items)-1].EndAt { 579 | // We loop through subtitles and process the ones that either contain the fragment start at, 580 | // or contain the fragment end at 581 | // 582 | // It's useless processing subtitles contained between fragment start at and end at 583 | // |____________________| <- subtitle 584 | // | | 585 | // fragment start at fragment end at 586 | for i, sub := range s.Items { 587 | // Init 588 | var newSub = &Item{} 589 | *newSub = *sub 590 | 591 | // A switch is more readable here 592 | switch { 593 | // Subtitle contains fragment start at 594 | // |____________________| <- subtitle 595 | // | | 596 | // fragment start at fragment end at 597 | case sub.StartAt < fragmentStartAt && sub.EndAt > fragmentStartAt: 598 | sub.StartAt = fragmentStartAt 599 | newSub.EndAt = fragmentStartAt 600 | // Subtitle contains fragment end at 601 | // |____________________| <- subtitle 602 | // | | 603 | // fragment start at fragment end at 604 | case sub.StartAt < fragmentEndAt && sub.EndAt > fragmentEndAt: 605 | sub.StartAt = fragmentEndAt 606 | newSub.EndAt = fragmentEndAt 607 | default: 608 | continue 609 | } 610 | 611 | // Insert new sub 612 | s.Items = append(s.Items[:i], append([]*Item{newSub}, s.Items[i:]...)...) 613 | } 614 | 615 | // Update fragments boundaries 616 | fragmentStartAt += f 617 | fragmentEndAt += f 618 | } 619 | 620 | // Order 621 | s.Order() 622 | } 623 | 624 | // IsEmpty returns whether the subtitles are empty 625 | func (s Subtitles) IsEmpty() bool { 626 | return len(s.Items) == 0 627 | } 628 | 629 | // Merge merges subtitles i into subtitles 630 | func (s *Subtitles) Merge(i *Subtitles) { 631 | // Append items 632 | s.Items = append(s.Items, i.Items...) 633 | s.Order() 634 | 635 | // Add regions 636 | for _, region := range i.Regions { 637 | if _, ok := s.Regions[region.ID]; !ok { 638 | s.Regions[region.ID] = region 639 | } 640 | } 641 | 642 | // Add styles 643 | for _, style := range i.Styles { 644 | if _, ok := s.Styles[style.ID]; !ok { 645 | s.Styles[style.ID] = style 646 | } 647 | } 648 | } 649 | 650 | // Optimize optimizes subtitles 651 | func (s *Subtitles) Optimize() { 652 | // Nothing to optimize 653 | if len(s.Items) == 0 { 654 | return 655 | } 656 | 657 | // Remove unused regions and style 658 | s.removeUnusedRegionsAndStyles() 659 | } 660 | 661 | // removeUnusedRegionsAndStyles removes unused regions and styles 662 | func (s *Subtitles) removeUnusedRegionsAndStyles() { 663 | // Loop through items 664 | var usedRegions, usedStyles = make(map[string]bool), make(map[string]bool) 665 | for _, item := range s.Items { 666 | // Add region 667 | if item.Region != nil { 668 | usedRegions[item.Region.ID] = true 669 | } 670 | 671 | // Add style 672 | if item.Style != nil { 673 | usedStyles[item.Style.ID] = true 674 | } 675 | 676 | // Loop through lines 677 | for _, line := range item.Lines { 678 | // Loop through line items 679 | for _, lineItem := range line.Items { 680 | // Add style 681 | if lineItem.Style != nil { 682 | usedStyles[lineItem.Style.ID] = true 683 | } 684 | } 685 | } 686 | } 687 | 688 | // Loop through regions 689 | for id, region := range s.Regions { 690 | if _, ok := usedRegions[region.ID]; ok { 691 | if region.Style != nil { 692 | usedStyles[region.Style.ID] = true 693 | } 694 | } else { 695 | delete(s.Regions, id) 696 | } 697 | } 698 | 699 | // Loop through style 700 | for id, style := range s.Styles { 701 | if _, ok := usedStyles[style.ID]; !ok { 702 | delete(s.Styles, id) 703 | } 704 | } 705 | } 706 | 707 | // Order orders items 708 | func (s *Subtitles) Order() { 709 | // Nothing to do if less than 1 element 710 | if len(s.Items) <= 1 { 711 | return 712 | } 713 | 714 | // Order 715 | sort.SliceStable(s.Items, func(i, j int) bool { 716 | return s.Items[i].StartAt < s.Items[j].StartAt 717 | }) 718 | } 719 | 720 | // RemoveStyling removes the styling from the subtitles 721 | func (s *Subtitles) RemoveStyling() { 722 | s.Regions = map[string]*Region{} 723 | s.Styles = map[string]*Style{} 724 | for _, i := range s.Items { 725 | i.Region = nil 726 | i.Style = nil 727 | i.InlineStyle = nil 728 | for idxLine, l := range i.Lines { 729 | for idxLineItem := range l.Items { 730 | i.Lines[idxLine].Items[idxLineItem].InlineStyle = nil 731 | i.Lines[idxLine].Items[idxLineItem].Style = nil 732 | } 733 | } 734 | } 735 | } 736 | 737 | // Unfragment unfragments subtitles 738 | func (s *Subtitles) Unfragment() { 739 | // Nothing to do if less than 1 element 740 | if len(s.Items) <= 1 { 741 | return 742 | } 743 | 744 | // Order 745 | s.Order() 746 | 747 | // Loop through items 748 | for i := 0; i < len(s.Items)-1; i++ { 749 | for j := i + 1; j < len(s.Items); j++ { 750 | // Items are the same 751 | if s.Items[i].String() == s.Items[j].String() && s.Items[i].EndAt >= s.Items[j].StartAt { 752 | // Only override end time if longer 753 | if s.Items[i].EndAt < s.Items[j].EndAt { 754 | s.Items[i].EndAt = s.Items[j].EndAt 755 | } 756 | s.Items = append(s.Items[:j], s.Items[j+1:]...) 757 | j-- 758 | } else if s.Items[i].EndAt < s.Items[j].StartAt { 759 | break 760 | } 761 | } 762 | } 763 | } 764 | 765 | // ApplyLinearCorrection applies linear correction 766 | func (s *Subtitles) ApplyLinearCorrection(actual1, desired1, actual2, desired2 time.Duration) { 767 | // Get parameters 768 | a := float64(desired2-desired1) / float64(actual2-actual1) 769 | b := time.Duration(float64(desired1) - a*float64(actual1)) 770 | 771 | // Loop through items 772 | for idx := range s.Items { 773 | s.Items[idx].EndAt = time.Duration(a*float64(s.Items[idx].EndAt)) + b 774 | s.Items[idx].StartAt = time.Duration(a*float64(s.Items[idx].StartAt)) + b 775 | } 776 | } 777 | 778 | // Write writes subtitles to a file 779 | func (s Subtitles) Write(dst string) (err error) { 780 | // Create the file 781 | var f *os.File 782 | if f, err = os.Create(dst); err != nil { 783 | err = fmt.Errorf("astisub: creating %s failed: %w", dst, err) 784 | return 785 | } 786 | defer f.Close() 787 | 788 | // Write the content 789 | switch filepath.Ext(strings.ToLower(dst)) { 790 | case ".srt": 791 | err = s.WriteToSRT(f) 792 | case ".ssa", ".ass": 793 | err = s.WriteToSSA(f) 794 | case ".stl": 795 | err = s.WriteToSTL(f) 796 | case ".ttml": 797 | err = s.WriteToTTML(f) 798 | case ".vtt": 799 | err = s.WriteToWebVTT(f) 800 | default: 801 | err = ErrInvalidExtension 802 | } 803 | return 804 | } 805 | 806 | // parseDuration parses a duration in "00:00:00.000", "00:00:00,000" or "0:00:00:00" format 807 | func parseDuration(i, millisecondSep string, numberOfMillisecondDigits int) (o time.Duration, err error) { 808 | // Split milliseconds 809 | var parts = strings.Split(i, millisecondSep) 810 | var milliseconds int 811 | var s string 812 | if len(parts) >= 2 { 813 | // Invalid number of millisecond digits 814 | s = strings.TrimSpace(parts[len(parts)-1]) 815 | if len(s) > 3 { 816 | err = fmt.Errorf("astisub: Invalid number of millisecond digits detected in %s", i) 817 | return 818 | } 819 | 820 | // Parse milliseconds 821 | if milliseconds, err = strconv.Atoi(s); err != nil { 822 | err = fmt.Errorf("astisub: atoi of %s failed: %w", s, err) 823 | return 824 | } 825 | milliseconds *= int(math.Pow10(numberOfMillisecondDigits - len(s))) 826 | s = strings.Join(parts[:len(parts)-1], millisecondSep) 827 | } else { 828 | s = i 829 | } 830 | 831 | // Split hours, minutes and seconds 832 | parts = strings.Split(strings.TrimSpace(s), ":") 833 | var partSeconds, partMinutes, partHours string 834 | if len(parts) == 2 { 835 | partSeconds = parts[1] 836 | partMinutes = parts[0] 837 | } else if len(parts) == 3 { 838 | partSeconds = parts[2] 839 | partMinutes = parts[1] 840 | partHours = parts[0] 841 | } else { 842 | err = fmt.Errorf("astisub: No hours, minutes or seconds detected in %s", i) 843 | return 844 | } 845 | 846 | // Parse seconds 847 | var seconds int 848 | s = strings.TrimSpace(partSeconds) 849 | if seconds, err = strconv.Atoi(s); err != nil { 850 | err = fmt.Errorf("astisub: atoi of %s failed: %w", s, err) 851 | return 852 | } 853 | 854 | // Parse minutes 855 | var minutes int 856 | s = strings.TrimSpace(partMinutes) 857 | if minutes, err = strconv.Atoi(s); err != nil { 858 | err = fmt.Errorf("astisub: atoi of %s failed: %w", s, err) 859 | return 860 | } 861 | 862 | // Parse hours 863 | var hours int 864 | if len(partHours) > 0 { 865 | s = strings.TrimSpace(partHours) 866 | if hours, err = strconv.Atoi(s); err != nil { 867 | err = fmt.Errorf("astisub: atoi of %s failed: %w", s, err) 868 | return 869 | } 870 | } 871 | 872 | // Generate output 873 | o = time.Duration(milliseconds)*time.Millisecond + time.Duration(seconds)*time.Second + time.Duration(minutes)*time.Minute + time.Duration(hours)*time.Hour 874 | return 875 | } 876 | 877 | // formatDuration formats a duration 878 | func formatDuration(i time.Duration, millisecondSep string, numberOfMillisecondDigits int) (s string) { 879 | // Parse hours 880 | var hours = int(i / time.Hour) 881 | var n = i % time.Hour 882 | if hours < 10 { 883 | s += "0" 884 | } 885 | s += strconv.Itoa(hours) + ":" 886 | 887 | // Parse minutes 888 | var minutes = int(n / time.Minute) 889 | n = i % time.Minute 890 | if minutes < 10 { 891 | s += "0" 892 | } 893 | s += strconv.Itoa(minutes) + ":" 894 | 895 | // Parse seconds 896 | var seconds = int(n / time.Second) 897 | n = i % time.Second 898 | if seconds < 10 { 899 | s += "0" 900 | } 901 | s += strconv.Itoa(seconds) + millisecondSep 902 | 903 | // Parse milliseconds 904 | var milliseconds = math.Floor(float64(n) / float64(time.Millisecond) / float64(math.Pow(10, 3-float64(numberOfMillisecondDigits)))) 905 | s += astikit.StrPad(strconv.FormatFloat(milliseconds, 'f', 0, 64), '0', numberOfMillisecondDigits, astikit.PadLeft) 906 | return 907 | } 908 | 909 | // appendStringToBytesWithNewLine adds a string to bytes then adds a new line 910 | func appendStringToBytesWithNewLine(i []byte, s string) (o []byte) { 911 | o = append(i, []byte(s)...) 912 | o = append(o, bytesLineSeparator...) 913 | return 914 | } 915 | 916 | func htmlTokenAttribute(t *html.Token, key string) *string { 917 | 918 | for _, attr := range t.Attr { 919 | if attr.Key == key { 920 | return &attr.Val 921 | } 922 | } 923 | 924 | return nil 925 | } 926 | 927 | func escapeHTML(i string) string { 928 | return htmlEscaper.Replace(i) 929 | } 930 | 931 | func unescapeHTML(i string) string { 932 | return htmlUnescaper.Replace(i) 933 | } 934 | 935 | func newScanner(i io.Reader) *bufio.Scanner { 936 | var scanner = bufio.NewScanner(i) 937 | scanner.Split(func(data []byte, atEOF bool) (advance int, token []byte, err error) { 938 | if atEOF && len(data) == 0 { 939 | return 0, nil, nil 940 | } 941 | if i := bytes.IndexAny(data, "\r\n"); i >= 0 { 942 | if data[i] == '\n' { 943 | // We have a line terminated by single newline. 944 | return i + 1, data[0:i], nil 945 | } 946 | advance = i + 1 947 | if len(data) > i+1 && data[i+1] == '\n' { 948 | advance += 1 949 | } 950 | return advance, data[0:i], nil 951 | } 952 | // If we're at EOF, we have a final, non-terminated line. Return it. 953 | if atEOF { 954 | return len(data), data, nil 955 | } 956 | // Request more data. 957 | return 0, nil, nil 958 | }) 959 | return scanner 960 | } 961 | -------------------------------------------------------------------------------- /subtitles_internal_test.go: -------------------------------------------------------------------------------- 1 | package astisub 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestColor(t *testing.T) { 11 | c, err := newColorFromSSAString("305419896", 10) 12 | assert.NoError(t, err) 13 | assert.Equal(t, Color{Alpha: 0x12, Blue: 0x34, Green: 0x56, Red: 0x78}, *c) 14 | c, err = newColorFromSSAString("12345678", 16) 15 | assert.NoError(t, err) 16 | assert.Equal(t, Color{Alpha: 0x12, Blue: 0x34, Green: 0x56, Red: 0x78}, *c) 17 | assert.Equal(t, "785634", c.TTMLString()) 18 | assert.Equal(t, "12345678", c.SSAString()) 19 | } 20 | 21 | func TestParseDuration(t *testing.T) { 22 | _, err := parseDuration("12:34:56,1234", ",", 3) 23 | assert.EqualError(t, err, "astisub: Invalid number of millisecond digits detected in 12:34:56,1234") 24 | _, err = parseDuration("12,123", ",", 3) 25 | assert.EqualError(t, err, "astisub: No hours, minutes or seconds detected in 12,123") 26 | d, err := parseDuration("12:34,123", ",", 3) 27 | assert.NoError(t, err) 28 | assert.Equal(t, 12*time.Minute+34*time.Second+123*time.Millisecond, d) 29 | d, err = parseDuration("12:34:56,123", ",", 3) 30 | assert.NoError(t, err) 31 | assert.Equal(t, 12*time.Hour+34*time.Minute+56*time.Second+123*time.Millisecond, d) 32 | d, err = parseDuration("12:34:56,1", ",", 3) 33 | assert.NoError(t, err) 34 | assert.Equal(t, 12*time.Hour+34*time.Minute+56*time.Second+100*time.Millisecond, d) 35 | d, err = parseDuration("12:34:56.123", ".", 3) 36 | assert.NoError(t, err) 37 | assert.Equal(t, 12*time.Hour+34*time.Minute+56*time.Second+123*time.Millisecond, d) 38 | d, err = parseDuration("1:23:45.67", ".", 2) 39 | assert.NoError(t, err) 40 | assert.Equal(t, time.Hour+23*time.Minute+45*time.Second+67*time.Millisecond, d) 41 | } 42 | 43 | func TestFormatDuration(t *testing.T) { 44 | s := formatDuration(time.Second, ",", 3) 45 | assert.Equal(t, "00:00:01,000", s) 46 | s = formatDuration(time.Second, ",", 2) 47 | assert.Equal(t, "00:00:01,00", s) 48 | s = formatDuration(time.Millisecond, ",", 3) 49 | assert.Equal(t, "00:00:00,001", s) 50 | s = formatDuration(10*time.Millisecond, ".", 3) 51 | assert.Equal(t, "00:00:00.010", s) 52 | s = formatDuration(100*time.Millisecond, ",", 3) 53 | assert.Equal(t, "00:00:00,100", s) 54 | s = formatDuration(time.Second+234*time.Millisecond, ",", 3) 55 | assert.Equal(t, "00:00:01,234", s) 56 | s = formatDuration(12*time.Second+345*time.Millisecond, ",", 3) 57 | assert.Equal(t, "00:00:12,345", s) 58 | s = formatDuration(2*time.Minute+3*time.Second+456*time.Millisecond, ",", 3) 59 | assert.Equal(t, "00:02:03,456", s) 60 | s = formatDuration(20*time.Minute+34*time.Second+567*time.Millisecond, ",", 3) 61 | assert.Equal(t, "00:20:34,567", s) 62 | s = formatDuration(3*time.Hour+25*time.Minute+45*time.Second+678*time.Millisecond, ",", 3) 63 | assert.Equal(t, "03:25:45,678", s) 64 | s = formatDuration(34*time.Hour+17*time.Minute+36*time.Second+789*time.Millisecond, ",", 3) 65 | assert.Equal(t, "34:17:36,789", s) 66 | s = formatDuration(12*time.Hour+34*time.Minute+56*time.Second+999*time.Millisecond, ",", 2) 67 | assert.Equal(t, "12:34:56,99", s) 68 | } 69 | -------------------------------------------------------------------------------- /subtitles_test.go: -------------------------------------------------------------------------------- 1 | package astisub_test 2 | 3 | import ( 4 | "bytes" 5 | "os" 6 | "testing" 7 | "time" 8 | 9 | "github.com/asticode/go-astisub" 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func TestLine_Text(t *testing.T) { 15 | var l = astisub.Line{Items: []astisub.LineItem{{Text: "1"}, {Text: "2"}, {Text: "3"}}} 16 | assert.Equal(t, "123", l.String()) 17 | } 18 | 19 | func assertSubtitleItems(t *testing.T, i *astisub.Subtitles) { 20 | // No format 21 | assert.Len(t, i.Items, 6) 22 | assert.Equal(t, time.Minute+39*time.Second, i.Items[0].StartAt) 23 | assert.Equal(t, time.Minute+41*time.Second+40*time.Millisecond, i.Items[0].EndAt) 24 | assert.Equal(t, "(deep rumbling)", i.Items[0].Lines[0].String()) 25 | assert.Equal(t, 2*time.Minute+4*time.Second+80*time.Millisecond, i.Items[1].StartAt) 26 | assert.Equal(t, 2*time.Minute+7*time.Second+120*time.Millisecond, i.Items[1].EndAt) 27 | assert.Equal(t, "MAN:", i.Items[1].Lines[0].String()) 28 | assert.Equal(t, "How did we end up here?", i.Items[1].Lines[1].String()) 29 | assert.Equal(t, 2*time.Minute+12*time.Second+160*time.Millisecond, i.Items[2].StartAt) 30 | assert.Equal(t, 2*time.Minute+15*time.Second+200*time.Millisecond, i.Items[2].EndAt) 31 | assert.Equal(t, "This place is horrible.", i.Items[2].Lines[0].String()) 32 | assert.Equal(t, 2*time.Minute+20*time.Second+240*time.Millisecond, i.Items[3].StartAt) 33 | assert.Equal(t, 2*time.Minute+22*time.Second+280*time.Millisecond, i.Items[3].EndAt) 34 | assert.Equal(t, "Smells like balls.", i.Items[3].Lines[0].String()) 35 | assert.Equal(t, 2*time.Minute+28*time.Second+320*time.Millisecond, i.Items[4].StartAt) 36 | assert.Equal(t, 2*time.Minute+31*time.Second+360*time.Millisecond, i.Items[4].EndAt) 37 | assert.Equal(t, "We don't belong", i.Items[4].Lines[0].String()) 38 | assert.Equal(t, "in this shithole.", i.Items[4].Lines[1].String()) 39 | assert.Equal(t, 2*time.Minute+31*time.Second+400*time.Millisecond, i.Items[5].StartAt) 40 | assert.Equal(t, 2*time.Minute+33*time.Second+440*time.Millisecond, i.Items[5].EndAt) 41 | assert.Equal(t, "(computer playing", i.Items[5].Lines[0].String()) 42 | assert.Equal(t, "electronic melody)", i.Items[5].Lines[1].String()) 43 | } 44 | 45 | func mockSubtitles() *astisub.Subtitles { 46 | 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"}}}}}}} 47 | } 48 | 49 | func TestSubtitles_Add(t *testing.T) { 50 | var s = mockSubtitles() 51 | s.Add(time.Second) 52 | assert.Len(t, s.Items, 2) 53 | assert.Equal(t, 2*time.Second, s.Items[0].StartAt) 54 | assert.Equal(t, 4*time.Second, s.Items[0].EndAt) 55 | assert.Equal(t, 2*time.Second, s.Items[0].StartAt) 56 | assert.Equal(t, 4*time.Second, s.Items[0].EndAt) 57 | s.Add(-3 * time.Second) 58 | assert.Len(t, s.Items, 2) 59 | assert.Equal(t, time.Duration(0), s.Items[0].StartAt) 60 | assert.Equal(t, time.Second, s.Items[0].EndAt) 61 | s.Add(-2 * time.Second) 62 | assert.Len(t, s.Items, 1) 63 | assert.Equal(t, "subtitle-2", s.Items[0].Lines[0].Items[0].Text) 64 | } 65 | 66 | func TestSubtitles_Duration(t *testing.T) { 67 | assert.Equal(t, time.Duration(0), astisub.Subtitles{}.Duration()) 68 | assert.Equal(t, 7*time.Second, mockSubtitles().Duration()) 69 | } 70 | 71 | func TestSubtitles_IsEmpty(t *testing.T) { 72 | assert.True(t, astisub.Subtitles{}.IsEmpty()) 73 | assert.False(t, mockSubtitles().IsEmpty()) 74 | } 75 | 76 | func TestSubtitles_ForceDuration(t *testing.T) { 77 | var s = mockSubtitles() 78 | s.ForceDuration(10*time.Second, false) 79 | assert.Len(t, s.Items, 2) 80 | s = mockSubtitles() 81 | s.ForceDuration(10*time.Second, true) 82 | assert.Len(t, s.Items, 3) 83 | assert.Equal(t, 10*time.Second, s.Items[2].EndAt) 84 | assert.Equal(t, 10*time.Second-time.Millisecond, s.Items[2].StartAt) 85 | assert.Equal(t, []astisub.Line{{Items: []astisub.LineItem{{Text: "..."}}}}, s.Items[2].Lines) 86 | s.Items[2].StartAt = 7 * time.Second 87 | s.Items[2].EndAt = 12 * time.Second 88 | s.ForceDuration(10*time.Second, true) 89 | assert.Len(t, s.Items, 3) 90 | assert.Equal(t, 10*time.Second, s.Items[2].EndAt) 91 | assert.Equal(t, 7*time.Second, s.Items[2].StartAt) 92 | } 93 | 94 | func TestSubtitles_Fragment(t *testing.T) { 95 | // Init 96 | var s = mockSubtitles() 97 | 98 | // Fragment 99 | s.Fragment(2 * time.Second) 100 | assert.Len(t, s.Items, 5) 101 | assert.Equal(t, time.Second, s.Items[0].StartAt) 102 | assert.Equal(t, 2*time.Second, s.Items[0].EndAt) 103 | assert.Equal(t, []astisub.Line{{Items: []astisub.LineItem{{Text: "subtitle-1"}}}}, s.Items[0].Lines) 104 | assert.Equal(t, 2*time.Second, s.Items[1].StartAt) 105 | assert.Equal(t, 3*time.Second, s.Items[1].EndAt) 106 | assert.Equal(t, []astisub.Line{{Items: []astisub.LineItem{{Text: "subtitle-1"}}}}, s.Items[1].Lines) 107 | assert.Equal(t, 3*time.Second, s.Items[2].StartAt) 108 | assert.Equal(t, 4*time.Second, s.Items[2].EndAt) 109 | assert.Equal(t, []astisub.Line{{Items: []astisub.LineItem{{Text: "subtitle-2"}}}}, s.Items[2].Lines) 110 | assert.Equal(t, 4*time.Second, s.Items[3].StartAt) 111 | assert.Equal(t, 6*time.Second, s.Items[3].EndAt) 112 | assert.Equal(t, []astisub.Line{{Items: []astisub.LineItem{{Text: "subtitle-2"}}}}, s.Items[3].Lines) 113 | assert.Equal(t, 6*time.Second, s.Items[4].StartAt) 114 | assert.Equal(t, 7*time.Second, s.Items[4].EndAt) 115 | assert.Equal(t, []astisub.Line{{Items: []astisub.LineItem{{Text: "subtitle-2"}}}}, s.Items[4].Lines) 116 | 117 | // Unfragment 118 | 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:]...)...) 119 | s.Unfragment() 120 | assert.Len(t, s.Items, 3) 121 | assert.Equal(t, "subtitle-1", s.Items[0].String()) 122 | assert.Equal(t, time.Second, s.Items[0].StartAt) 123 | assert.Equal(t, 3*time.Second, s.Items[0].EndAt) 124 | assert.Equal(t, "subtitle-2", s.Items[1].String()) 125 | assert.Equal(t, 3*time.Second, s.Items[1].StartAt) 126 | assert.Equal(t, 7*time.Second, s.Items[1].EndAt) 127 | assert.Equal(t, "subtitle-3", s.Items[2].String()) 128 | assert.Equal(t, 4*time.Second, s.Items[2].StartAt) 129 | assert.Equal(t, 5*time.Second, s.Items[2].EndAt) 130 | } 131 | 132 | func TestSubtitles_Unfragment(t *testing.T) { 133 | itemText := func(s string) []astisub.Line { 134 | return []astisub.Line{{Items: []astisub.LineItem{{Text: s}}}} 135 | } 136 | items := []*astisub.Item{{ 137 | Lines: itemText("subtitle-1"), 138 | StartAt: 1 * time.Second, 139 | EndAt: 2 * time.Second, 140 | }, { 141 | Lines: itemText("subtitle-2"), 142 | StartAt: 2 * time.Second, 143 | EndAt: 5 * time.Second, 144 | }, { 145 | Lines: itemText("subtitle-3"), 146 | StartAt: 3 * time.Second, 147 | EndAt: 4 * time.Second, 148 | }, { 149 | // gap and nested within first subtitle-2; should not override end time 150 | Lines: itemText("subtitle-2"), 151 | StartAt: 3 * time.Second, 152 | EndAt: 4 * time.Second, 153 | }, { 154 | Lines: itemText("subtitle-3"), 155 | // gap and start time equals previous end time 156 | StartAt: 4 * time.Second, 157 | EndAt: 5 * time.Second, 158 | }, { 159 | // should not be combined 160 | Lines: itemText("subtitle-3"), 161 | StartAt: 6 * time.Second, 162 | EndAt: 7 * time.Second, 163 | }, { 164 | // test correcting for out-of-orderness 165 | Lines: itemText("subtitle-1"), 166 | StartAt: 0 * time.Second, 167 | EndAt: 3 * time.Second, 168 | }} 169 | 170 | s := &astisub.Subtitles{Items: items} 171 | 172 | s.Unfragment() 173 | 174 | expected := []astisub.Item{{ 175 | Lines: itemText("subtitle-1"), 176 | StartAt: 0 * time.Second, 177 | EndAt: 3 * time.Second, 178 | }, { 179 | Lines: itemText("subtitle-2"), 180 | StartAt: 2 * time.Second, 181 | EndAt: 5 * time.Second, 182 | }, { 183 | Lines: itemText("subtitle-3"), 184 | StartAt: 3 * time.Second, 185 | EndAt: 5 * time.Second, 186 | }, { 187 | Lines: itemText("subtitle-3"), 188 | StartAt: 6 * time.Second, 189 | EndAt: 7 * time.Second, 190 | }} 191 | 192 | assert.Equal(t, len(expected), len(s.Items)) 193 | for i := range expected { 194 | assert.Equal(t, expected[i], *s.Items[i]) 195 | } 196 | } 197 | 198 | func TestSubtitles_Merge(t *testing.T) { 199 | var s1 = &astisub.Subtitles{Items: []*astisub.Item{{EndAt: 3 * time.Second, StartAt: time.Second}, {EndAt: 8 * time.Second, StartAt: 5 * time.Second}, {EndAt: 12 * time.Second, StartAt: 10 * time.Second}}, Regions: map[string]*astisub.Region{"region_0": {ID: "region_0"}, "region_1": {ID: "region_1"}}, Styles: map[string]*astisub.Style{"style_0": {ID: "style_0"}, "style_1": {ID: "style_1"}}} 200 | var s2 = &astisub.Subtitles{Items: []*astisub.Item{{EndAt: 4 * time.Second, StartAt: 2 * time.Second}, {EndAt: 7 * time.Second, StartAt: 6 * time.Second}, {EndAt: 11 * time.Second, StartAt: 9 * time.Second}, {EndAt: 14 * time.Second, StartAt: 13 * time.Second}}, Regions: map[string]*astisub.Region{"region_1": {ID: "region_1"}, "region_2": {ID: "region_2"}}, Styles: map[string]*astisub.Style{"style_1": {ID: "style_1"}, "style_2": {ID: "style_2"}}} 201 | s1.Merge(s2) 202 | assert.Len(t, s1.Items, 7) 203 | assert.Equal(t, &astisub.Item{EndAt: 3 * time.Second, StartAt: time.Second}, s1.Items[0]) 204 | assert.Equal(t, &astisub.Item{EndAt: 4 * time.Second, StartAt: 2 * time.Second}, s1.Items[1]) 205 | assert.Equal(t, &astisub.Item{EndAt: 8 * time.Second, StartAt: 5 * time.Second}, s1.Items[2]) 206 | assert.Equal(t, &astisub.Item{EndAt: 7 * time.Second, StartAt: 6 * time.Second}, s1.Items[3]) 207 | assert.Equal(t, &astisub.Item{EndAt: 11 * time.Second, StartAt: 9 * time.Second}, s1.Items[4]) 208 | assert.Equal(t, &astisub.Item{EndAt: 12 * time.Second, StartAt: 10 * time.Second}, s1.Items[5]) 209 | assert.Equal(t, &astisub.Item{EndAt: 14 * time.Second, StartAt: 13 * time.Second}, s1.Items[6]) 210 | assert.Equal(t, len(s1.Regions), 3) 211 | assert.Equal(t, len(s1.Styles), 3) 212 | } 213 | 214 | func TestSubtitles_Optimize(t *testing.T) { 215 | var s = &astisub.Subtitles{ 216 | Items: []*astisub.Item{ 217 | {Region: &astisub.Region{ID: "1"}}, 218 | {Style: &astisub.Style{ID: "1"}}, 219 | {Lines: []astisub.Line{{Items: []astisub.LineItem{{Style: &astisub.Style{ID: "2"}}}}}}, 220 | }, 221 | Regions: map[string]*astisub.Region{ 222 | "1": {ID: "1", Style: &astisub.Style{ID: "3"}}, 223 | "2": {ID: "2", Style: &astisub.Style{ID: "4"}}, 224 | }, 225 | Styles: map[string]*astisub.Style{ 226 | "1": {ID: "1"}, 227 | "2": {ID: "2"}, 228 | "3": {ID: "3"}, 229 | "4": {ID: "4"}, 230 | "5": {ID: "5"}, 231 | }, 232 | } 233 | s.Optimize() 234 | assert.Len(t, s.Regions, 1) 235 | assert.Len(t, s.Styles, 3) 236 | } 237 | 238 | func TestSubtitles_Order(t *testing.T) { 239 | var s = &astisub.Subtitles{Items: []*astisub.Item{{StartAt: 4 * time.Second, EndAt: 5 * time.Second}, {StartAt: 2 * time.Second, EndAt: 3 * time.Second}, {StartAt: 3 * time.Second, EndAt: 4 * time.Second}, {StartAt: time.Second, EndAt: 2 * time.Second}}} 240 | s.Order() 241 | assert.Equal(t, time.Second, s.Items[0].StartAt) 242 | assert.Equal(t, 2*time.Second, s.Items[0].EndAt) 243 | assert.Equal(t, 2*time.Second, s.Items[1].StartAt) 244 | assert.Equal(t, 3*time.Second, s.Items[1].EndAt) 245 | assert.Equal(t, 3*time.Second, s.Items[2].StartAt) 246 | assert.Equal(t, 4*time.Second, s.Items[2].EndAt) 247 | assert.Equal(t, 4*time.Second, s.Items[3].StartAt) 248 | assert.Equal(t, 5*time.Second, s.Items[3].EndAt) 249 | } 250 | 251 | func TestSubtitles_RemoveStyling(t *testing.T) { 252 | s := &astisub.Subtitles{ 253 | Items: []*astisub.Item{ 254 | { 255 | Lines: []astisub.Line{{ 256 | Items: []astisub.LineItem{{ 257 | InlineStyle: &astisub.StyleAttributes{}, 258 | Style: &astisub.Style{}, 259 | }}, 260 | }}, 261 | InlineStyle: &astisub.StyleAttributes{}, 262 | Region: &astisub.Region{}, 263 | Style: &astisub.Style{}, 264 | }, 265 | }, 266 | Regions: map[string]*astisub.Region{"region": {}}, 267 | Styles: map[string]*astisub.Style{"style": {}}, 268 | } 269 | s.RemoveStyling() 270 | assert.Equal(t, &astisub.Subtitles{ 271 | Items: []*astisub.Item{ 272 | { 273 | Lines: []astisub.Line{{ 274 | Items: []astisub.LineItem{{}}, 275 | }}, 276 | }, 277 | }, 278 | Regions: map[string]*astisub.Region{}, 279 | Styles: map[string]*astisub.Style{}, 280 | }, s) 281 | } 282 | 283 | func TestSubtitles_ApplyLinearCorrection(t *testing.T) { 284 | s := &astisub.Subtitles{Items: []*astisub.Item{ 285 | { 286 | EndAt: 2 * time.Second, 287 | StartAt: 1 * time.Second, 288 | }, 289 | { 290 | EndAt: 5 * time.Second, 291 | StartAt: 3 * time.Second, 292 | }, 293 | { 294 | EndAt: 10 * time.Second, 295 | StartAt: 7 * time.Second, 296 | }, 297 | }} 298 | s.ApplyLinearCorrection(3*time.Second, 5*time.Second, 5*time.Second, 8*time.Second) 299 | require.Equal(t, 2*time.Second, s.Items[0].StartAt) 300 | require.Equal(t, 3500*time.Millisecond, s.Items[0].EndAt) 301 | require.Equal(t, 5*time.Second, s.Items[1].StartAt) 302 | require.Equal(t, 8*time.Second, s.Items[1].EndAt) 303 | require.Equal(t, 11*time.Second, s.Items[2].StartAt) 304 | require.Equal(t, 15500*time.Millisecond, s.Items[2].EndAt) 305 | } 306 | 307 | func TestHTMLEntity(t *testing.T) { 308 | exts := []string{"srt", "vtt"} 309 | for _, ext := range exts { 310 | // Read input with entities 311 | s, err := astisub.OpenFile("./testdata/example-in-html-entities." + ext) 312 | assert.NoError(t, err) 313 | 314 | assert.Len(t, s.Items, 3) 315 | assert.Equal(t, 331*time.Millisecond, s.Items[0].StartAt) 316 | assert.Equal(t, 3*time.Second+750*time.Millisecond, s.Items[0].EndAt) 317 | assert.Equal(t, "The man in black fled across the desert, \u00A0", s.Items[0].Lines[0].String()) 318 | assert.Equal(t, "& the gunslinger followed.", s.Items[0].Lines[1].String()) 319 | assert.Equal(t, 4*time.Second+101*time.Millisecond, s.Items[1].StartAt) 320 | assert.Equal(t, 5*time.Second+430*time.Millisecond, s.Items[1].EndAt) 321 | assert.Equal(t, "Go,\u00A0then,", s.Items[1].Lines[0].String()) 322 | assert.Equal(t, 6*time.Second+331*time.Millisecond, s.Items[2].StartAt) 323 | assert.Equal(t, 9*time.Second+675*time.Millisecond, s.Items[2].EndAt) 324 | assert.Equal(t, "there are other < worlds than these.", s.Items[2].Lines[0].String()) 325 | 326 | //Write to srt 327 | w := &bytes.Buffer{} 328 | c, err := os.ReadFile("./testdata/example-out-html-entities.srt") 329 | assert.NoError(t, err) 330 | err = s.WriteToSRT(w) 331 | assert.NoError(t, err) 332 | assert.Equal(t, string(c), w.String()) 333 | 334 | //Write WebVTT 335 | w = &bytes.Buffer{} 336 | c, err = os.ReadFile("./testdata/example-out-html-entities.vtt") 337 | assert.NoError(t, err) 338 | err = s.WriteToWebVTT(w) 339 | assert.NoError(t, err) 340 | assert.Equal(t, string(c), w.String()) 341 | } 342 | } 343 | 344 | func TestNewScanner(t *testing.T) { 345 | exts := []string{"vtt", "srt", "ssa"} 346 | for _, ext := range exts { 347 | s, err := astisub.OpenFile("./testdata/example-in-carriage-return." + ext) 348 | assert.NoError(t, err) 349 | assert.Len(t, s.Items, 3) 350 | assert.Equal(t, time.Duration(0), s.Items[0].StartAt) 351 | assert.Equal(t, 3*time.Second+766*time.Millisecond, s.Items[0].EndAt) 352 | assert.Equal(t, "Did one of the last stories strike you as", s.Items[0].Lines[0].String()) 353 | assert.Equal(t, "more interesting than the other?", s.Items[0].Lines[1].String()) 354 | 355 | assert.Equal(t, 3*time.Second+767*time.Millisecond, s.Items[1].StartAt) 356 | assert.Equal(t, 10*time.Second+732*time.Millisecond, s.Items[1].EndAt) 357 | assert.Equal(t, "That's true. You don’t often find 632", s.Items[1].Lines[0].String()) 358 | assert.Equal(t, "pieces of gum stuck on a sidewalk", s.Items[1].Lines[1].String()) 359 | 360 | assert.Equal(t, 10*time.Second+733*time.Millisecond, s.Items[2].StartAt) 361 | assert.Equal(t, 14*time.Second+66*time.Millisecond, s.Items[2].EndAt) 362 | assert.Equal(t, "at a busy bus stop or anywhere", s.Items[2].Lines[0].String()) 363 | assert.Equal(t, "else for that matter.", s.Items[2].Lines[1].String()) 364 | } 365 | } 366 | -------------------------------------------------------------------------------- /teletext_test.go: -------------------------------------------------------------------------------- 1 | package astisub 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/asticode/go-astikit" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestTeletextPESDataType(t *testing.T) { 12 | m := make(map[int]string) 13 | for i := 0; i < 255; i++ { 14 | t := teletextPESDataType(uint8(i)) 15 | if t != teletextPESDataTypeUnknown { 16 | m[i] = t 17 | } 18 | } 19 | assert.Equal(t, map[int]string{19: teletextPESDataTypeEBU, 20: teletextPESDataTypeEBU, 21: teletextPESDataTypeEBU, 26: teletextPESDataTypeEBU, 28: teletextPESDataTypeEBU, 17: teletextPESDataTypeEBU, 27: teletextPESDataTypeEBU, 31: teletextPESDataTypeEBU, 16: teletextPESDataTypeEBU, 18: teletextPESDataTypeEBU, 23: teletextPESDataTypeEBU, 29: teletextPESDataTypeEBU, 22: teletextPESDataTypeEBU, 24: teletextPESDataTypeEBU, 25: teletextPESDataTypeEBU, 30: teletextPESDataTypeEBU}, m) 20 | } 21 | 22 | func TestTeletextPageParse(t *testing.T) { 23 | p := newTeletextPage(0, time.Unix(10, 0)) 24 | p.end = time.Unix(15, 0) 25 | p.rows = []int{2, 1} 26 | p.data = map[uint8][]byte{ 27 | 1: append([]byte{0xb}, []byte("test1")...), 28 | 2: append([]byte{0xb}, []byte("test2")...), 29 | } 30 | s := Subtitles{} 31 | d := newTeletextCharacterDecoder() 32 | d.updateCharset(astikit.UInt8Ptr(0), false) 33 | p.parse(&s, d, time.Unix(5, 0)) 34 | assert.Equal(t, []*Item{{ 35 | EndAt: 10 * time.Second, 36 | Lines: []Line{ 37 | {Items: []LineItem{{InlineStyle: &StyleAttributes{TeletextSpacesAfter: astikit.IntPtr(0), TeletextSpacesBefore: astikit.IntPtr(0)}, Text: "test1"}}}, 38 | {Items: []LineItem{{InlineStyle: &StyleAttributes{TeletextSpacesAfter: astikit.IntPtr(0), TeletextSpacesBefore: astikit.IntPtr(0)}, Text: "test2"}}}, 39 | }, 40 | StartAt: 5 * time.Second, 41 | }}, s.Items) 42 | } 43 | 44 | func TestParseTeletextRow(t *testing.T) { 45 | b := []byte("start") 46 | b = append(b, 0x0, 0xb) 47 | b = append(b, []byte("black")...) 48 | b = append(b, 0x1) 49 | b = append(b, []byte("red")...) 50 | b = append(b, 0x2) 51 | b = append(b, []byte("green")...) 52 | b = append(b, 0x3) 53 | b = append(b, []byte("yellow")...) 54 | b = append(b, 0x4) 55 | b = append(b, []byte("blue")...) 56 | b = append(b, 0x5) 57 | b = append(b, []byte("magenta")...) 58 | b = append(b, 0x6) 59 | b = append(b, []byte("cyan")...) 60 | b = append(b, 0x7) 61 | b = append(b, []byte("white")...) 62 | b = append(b, 0xd) 63 | b = append(b, []byte("double height")...) 64 | b = append(b, 0xe) 65 | b = append(b, []byte("double width")...) 66 | b = append(b, 0xf) 67 | b = append(b, []byte("double size")...) 68 | b = append(b, 0xc) 69 | b = append(b, []byte("reset")...) 70 | b = append(b, 0xa) 71 | b = append(b, []byte("end")...) 72 | i := Item{} 73 | d := newTeletextCharacterDecoder() 74 | d.updateCharset(astikit.UInt8Ptr(0), false) 75 | parseTeletextRow(&i, d, nil, b) 76 | assert.Equal(t, 1, len(i.Lines)) 77 | assert.Equal(t, []LineItem{ 78 | {Text: "black", InlineStyle: &StyleAttributes{ 79 | TeletextColor: ColorBlack, 80 | TeletextSpacesAfter: astikit.IntPtr(0), 81 | TeletextSpacesBefore: astikit.IntPtr(0), 82 | TTMLColor: astikit.StrPtr("#000000"), 83 | }}, 84 | {Text: "red", InlineStyle: &StyleAttributes{ 85 | TeletextColor: ColorRed, 86 | TeletextSpacesAfter: astikit.IntPtr(0), 87 | TeletextSpacesBefore: astikit.IntPtr(0), 88 | TTMLColor: astikit.StrPtr("#ff0000"), 89 | }}, 90 | {Text: "green", InlineStyle: &StyleAttributes{ 91 | TeletextColor: ColorGreen, 92 | TeletextSpacesAfter: astikit.IntPtr(0), 93 | TeletextSpacesBefore: astikit.IntPtr(0), 94 | TTMLColor: astikit.StrPtr("#008000"), 95 | }}, 96 | {Text: "yellow", InlineStyle: &StyleAttributes{ 97 | TeletextColor: ColorYellow, 98 | TeletextSpacesAfter: astikit.IntPtr(0), 99 | TeletextSpacesBefore: astikit.IntPtr(0), 100 | TTMLColor: astikit.StrPtr("#ffff00"), 101 | }}, 102 | {Text: "blue", InlineStyle: &StyleAttributes{ 103 | TeletextColor: ColorBlue, 104 | TeletextSpacesAfter: astikit.IntPtr(0), 105 | TeletextSpacesBefore: astikit.IntPtr(0), 106 | TTMLColor: astikit.StrPtr("#0000ff"), 107 | }}, 108 | {Text: "magenta", InlineStyle: &StyleAttributes{ 109 | TeletextColor: ColorMagenta, 110 | TeletextSpacesAfter: astikit.IntPtr(0), 111 | TeletextSpacesBefore: astikit.IntPtr(0), 112 | TTMLColor: astikit.StrPtr("#ff00ff"), 113 | }}, 114 | {Text: "cyan", InlineStyle: &StyleAttributes{ 115 | TeletextColor: ColorCyan, 116 | TeletextSpacesAfter: astikit.IntPtr(0), 117 | TeletextSpacesBefore: astikit.IntPtr(0), 118 | TTMLColor: astikit.StrPtr("#00ffff"), 119 | }}, 120 | {Text: "white", InlineStyle: &StyleAttributes{ 121 | TeletextColor: ColorWhite, 122 | TeletextSpacesAfter: astikit.IntPtr(0), 123 | TeletextSpacesBefore: astikit.IntPtr(0), 124 | TTMLColor: astikit.StrPtr("#ffffff"), 125 | }}, 126 | {Text: "double height", InlineStyle: &StyleAttributes{ 127 | TeletextColor: ColorWhite, 128 | TeletextDoubleHeight: astikit.BoolPtr(true), 129 | TeletextSpacesAfter: astikit.IntPtr(0), 130 | TeletextSpacesBefore: astikit.IntPtr(0), 131 | TTMLColor: astikit.StrPtr("#ffffff"), 132 | }}, 133 | {Text: "double width", InlineStyle: &StyleAttributes{ 134 | TeletextColor: ColorWhite, 135 | TeletextDoubleHeight: astikit.BoolPtr(true), 136 | TeletextDoubleWidth: astikit.BoolPtr(true), 137 | TeletextSpacesAfter: astikit.IntPtr(0), 138 | TeletextSpacesBefore: astikit.IntPtr(0), 139 | TTMLColor: astikit.StrPtr("#ffffff"), 140 | }}, 141 | {Text: "double size", InlineStyle: &StyleAttributes{ 142 | TeletextColor: ColorWhite, 143 | TeletextDoubleHeight: astikit.BoolPtr(true), 144 | TeletextDoubleWidth: astikit.BoolPtr(true), 145 | TeletextDoubleSize: astikit.BoolPtr(true), 146 | TeletextSpacesAfter: astikit.IntPtr(0), 147 | TeletextSpacesBefore: astikit.IntPtr(0), 148 | TTMLColor: astikit.StrPtr("#ffffff"), 149 | }}, 150 | {Text: "reset", InlineStyle: &StyleAttributes{ 151 | TeletextColor: ColorWhite, 152 | TeletextDoubleHeight: astikit.BoolPtr(false), 153 | TeletextDoubleWidth: astikit.BoolPtr(false), 154 | TeletextDoubleSize: astikit.BoolPtr(false), 155 | TeletextSpacesAfter: astikit.IntPtr(0), 156 | TeletextSpacesBefore: astikit.IntPtr(0), 157 | TTMLColor: astikit.StrPtr("#ffffff"), 158 | }}, 159 | }, i.Lines[0].Items) 160 | } 161 | 162 | func TestAppendTeletextLineItem(t *testing.T) { 163 | // Init 164 | l := Line{} 165 | 166 | // Empty 167 | appendTeletextLineItem(&l, LineItem{}, nil) 168 | assert.Equal(t, 0, len(l.Items)) 169 | 170 | // Not empty 171 | appendTeletextLineItem(&l, LineItem{Text: " test "}, nil) 172 | assert.Equal(t, "test", l.Items[0].Text) 173 | assert.Equal(t, StyleAttributes{ 174 | TeletextSpacesAfter: astikit.IntPtr(2), 175 | TeletextSpacesBefore: astikit.IntPtr(1), 176 | }, *l.Items[0].InlineStyle) 177 | } 178 | -------------------------------------------------------------------------------- /testdata/broken-1-in.vtt: -------------------------------------------------------------------------------- 1 | 2 | BROKENVTT 3 | -------------------------------------------------------------------------------- /testdata/example-in-breaklines.ttml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |
6 |

7 | First line
Second line
8 |

9 |

10 | Third line

Fourth line
11 |

12 |

13 | Fifth line
Sixth middle line 14 |

15 |

16 | Seventh line

Eighth middle line 17 |

18 |
19 | 20 |
-------------------------------------------------------------------------------- /testdata/example-in-carriage-return.srt: -------------------------------------------------------------------------------- 1 | 1 00:00:00.000 --> 00:00:03.766 Did one of the last stories strike you as more interesting than the other? 2 00:00:03.767 --> 00:00:10.732 That's true. You don’t often find 632 pieces of gum stuck on a sidewalk 3 00:00:10.733 --> 00:00:14.066 at a busy bus stop or anywhere else for that matter. 2 | -------------------------------------------------------------------------------- /testdata/example-in-carriage-return.ssa: -------------------------------------------------------------------------------- 1 | [Script Info] ; Comment 1 ; Comment 2 Collisions: Normal Original Script: asticode PlayDepth: 0 PlayResY: 600 ScriptType: v4.00 Script Updated By: version 2.8.01 Timer: 100 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.000,&H0000ffff,&H0000ffff,&H0000ffff,4.000 Style: 2,8,0.200,&H000f0f0f,1,8,1,f2,5.000,0,2,5,8,2.000,&H0000ffff,&H00efefef,&H0000ffff,5.000 Style: 3,9,0.300,&H00000008,0,9,2,f3,6.000,0,3,6,9,3.000,&H00000008,&H00b4fcfc,&H00b4fcfc,6.000 [Events] Format: Marked, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text Dialogue: Marked=0,00:00:00.00,00:00:03.766,1,Cher,1234,2345,3456,test,{\pos(400,570)}Did one of the last stories strike you as\nmore interesting than the other? Dialogue: Marked=1,00:00:03.767,00:00:10.732,2,autre,0,0,0,,That's true. You don’t often find 632\npieces of gum stuck on a sidewalk Dialogue: Marked=1,00:00:10.733,00:00:14.066,3,autre,0,0,0,,at a busy bus stop or anywhere\nelse for that matter. 2 | -------------------------------------------------------------------------------- /testdata/example-in-carriage-return.vtt: -------------------------------------------------------------------------------- 1 | WEBVTT 1 00:00:00.000 --> 00:00:03.766 Did one of the last stories strike you as more interesting than the other? 2 00:00:03.767 --> 00:00:10.732 That's true. You don’t often find 632 pieces of gum stuck on a sidewalk 3 00:00:10.733 --> 00:00:14.066 at a busy bus stop or anywhere else for that matter. 2 | -------------------------------------------------------------------------------- /testdata/example-in-html-entities.srt: -------------------------------------------------------------------------------- 1 | 1 2 | 00:00:00,331 --> 00:00:03,750 3 | The man in black fled across the desert,   4 | & the gunslinger followed. 5 | 6 | 2 7 | 00:00:04,101 --> 00:00:05,430 8 | Go, then, 9 | 10 | 3 11 | 00:00:06,331 --> 00:00:09,675 12 | there are other < worlds than these. 13 | -------------------------------------------------------------------------------- /testdata/example-in-html-entities.vtt: -------------------------------------------------------------------------------- 1 | WEBVTT 2 | 3 | 1 4 | 00:00:00.331 --> 00:00:03.750 5 | The man in black fled across the desert,   6 | & the gunslinger followed. 7 | 8 | 2 9 | 00:00:04.101 --> 00:00:05.430 10 | Go, then, 11 | 12 | 3 13 | 00:00:06.331 --> 00:00:09.675 14 | there are other < worlds than these. 15 | -------------------------------------------------------------------------------- /testdata/example-in-non-utf8.srt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asticode/go-astisub/716eb3660b8c5d42d716f6dadaa0626a6e1645f3/testdata/example-in-non-utf8.srt -------------------------------------------------------------------------------- /testdata/example-in-non-utf8.vtt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asticode/go-astisub/716eb3660b8c5d42d716f6dadaa0626a6e1645f3/testdata/example-in-non-utf8.vtt -------------------------------------------------------------------------------- /testdata/example-in-nonzero-offset.stl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asticode/go-astisub/716eb3660b8c5d42d716f6dadaa0626a6e1645f3/testdata/example-in-nonzero-offset.stl -------------------------------------------------------------------------------- /testdata/example-in-styled.srt: -------------------------------------------------------------------------------- 1 | 1 2 | 00:00:17,985 --> 00:00:20,521 3 | [instrumental music] 4 | 5 | 2 6 | 00:00:47,115 --> 00:00:48,282 7 | [ticks] 8 | 9 | 3 10 | 00:00:58,192 --> 00:00:59,727 11 | [instrumental music] 12 | 13 | 4 14 | 00:01:01,662 --> 00:01:03,063 15 | [dog barking] 16 | 17 | 5 18 | 00:01:26,787 --> 00:01:29,523 19 | [beeping] 20 | 21 | 6 22 | 00:01:29,590 --> 00:01:31,992 23 | [automated] 24 | 'The time is 7:35.' 25 | 26 | 7 27 | 00:08:00,000 --> 00:09:00,000 28 | Test with multi line italics 29 | Terminated on the next line 30 | 31 | 8 32 | 00:09:00,000 --> 00:10:00,000 33 | Unterminated styles 34 | 35 | 9 36 | 00:10:00,000 --> 00:11:00,000 37 | Do no fall to the next item 38 | 39 | 10 40 | 00:12:00,000 --> 00:13:00,000 41 | x^3 * x = 100 -------------------------------------------------------------------------------- /testdata/example-in.srt: -------------------------------------------------------------------------------- 1 | 1 2 | 00:01:39 --> 00:01:41,04 3 | (deep rumbling) 4 | 5 | 2 6 | 00:02:04,08 --> 00:02:07,12 X1:40 X2:600 Y1:20 Y2:50 7 | MAN: 8 | How did we end up here? 9 | 10 | 3 11 | 00:02:12.16 --> 00:02:15.20 12 | This place is horrible. 13 | 14 | 4 15 | 00:02:20.24 --> 00:02:22.28 16 | Smells like balls. 17 | 18 | 5 19 | 00:02:28,32 --> 00:02:31,36 20 | We don't belong 21 | in this shithole. 22 | 23 | 6 24 | 00:02:31,40 --> 00:02:33,44 25 | (computer playing 26 | electronic melody) 27 | -------------------------------------------------------------------------------- /testdata/example-in.ssa: -------------------------------------------------------------------------------- 1 | [Script Info] 2 | ; Comment 1 3 | ; Comment 2 4 | Title: SSA test 5 | Original Script: asticode 6 | Not understood line 7 | Script Updated By: version 2.8.01 8 | ScriptType: v4.00 9 | Collisions: Normal 10 | PlayResY: 600 11 | PlayDepth: 0 12 | Timer: 100,0000 13 | 14 | [Unknown] 15 | Unknown 16 | 17 | [V4 Styles] 18 | Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, TertiaryColour, BackColour, Bold, Italic, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, AlphaLevel, Encoding 19 | Style: 1,f1,4,65535,65535,65535,-2147483640,-1,0,7,1,4,7,1,4,7,0.1,0 20 | Style: 2,f2,5,15724527,65535,65535,986895,-1,0,8,2,5,8,2,5,8,0.2,1 21 | Not understood line 22 | Style: 3,f3,6,&H00B4FCFC,&H00B4FCFC,&H00000008,&H00000008,0,0,9,3,6,9,3,6,9,0.3,2 23 | 24 | [Events] 25 | Format: Marked, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text 26 | Dialogue: Marked=0,0:01:39.00,0:01:41.04,1,Cher,1234,2345,3456,test,{\pos(400,570)}(deep rumbling) 27 | Dialogue: Marked=1,0:02:04.08,0:02:07.12,2,autre,0000,0000,0000,,MAN:\nHow did we end up here? 28 | Dialogue: Marked=1,0:02:12.16,0:02:15.20,*3,autre,0000,0000,0000,,This place is horrible. 29 | Not understood line 30 | Dialogue: Marked=1,0:02:20.24,0:02:22.28,1,autre,0000,0000,0000,,Smells like balls. 31 | Dialogue: Marked=1,0:02:28.32,0:02:31.36,2,autre,0000,0000,0000,,We don't belong\Nin this shithole. 32 | Dialogue: Marked=1,0:02:31.40,0:02:33.44,3,autre,0000,0000,0000,,(computer playing\nelectronic melody) -------------------------------------------------------------------------------- /testdata/example-in.stl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asticode/go-astisub/716eb3660b8c5d42d716f6dadaa0626a6e1645f3/testdata/example-in.stl -------------------------------------------------------------------------------- /testdata/example-in.ttml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Title test 5 | Copyright test 6 | 7 | 8 |

(deep rumbling)

MAN:

How did we end up here?

This place is horrible.

Smells like balls.

We don't belong

in this shithole.

(computer playing

electronic melody)

2 | -------------------------------------------------------------------------------- /testdata/example-out-styled.srt: -------------------------------------------------------------------------------- 1 | 1 2 | 00:00:17,985 --> 00:00:20,521 3 | [instrumental music] 4 | 5 | 2 6 | 00:00:47,115 --> 00:00:48,282 7 | [ticks] 8 | 9 | 3 10 | 00:00:58,192 --> 00:00:59,727 11 | [instrumental music] 12 | 13 | 4 14 | 00:01:01,662 --> 00:01:03,063 15 | [dog barking] 16 | 17 | 5 18 | 00:01:26,787 --> 00:01:29,523 19 | [beeping] 20 | 21 | 6 22 | 00:01:29,590 --> 00:01:31,992 23 | [automated] 24 | 'The time is 7:35.' 25 | 26 | 7 27 | 00:08:00,000 --> 00:09:00,000 28 | Test with multi line italics 29 | Terminated on the next line 30 | 31 | 8 32 | 00:09:00,000 --> 00:10:00,000 33 | Unterminated styles 34 | 35 | 9 36 | 00:10:00,000 --> 00:11:00,000 37 | Do no fall to the next item 38 | 39 | 10 40 | 00:12:00,000 --> 00:13:00,000 41 | x^3 * x = 100 42 | -------------------------------------------------------------------------------- /testdata/example-out-styled.vtt: -------------------------------------------------------------------------------- 1 | WEBVTT 2 | 3 | 1 4 | 00:00:17.985 --> 00:00:20.521 5 | [instrumental music] 6 | 7 | 2 8 | 00:00:47.115 --> 00:00:48.282 9 | [ticks] 10 | 11 | 3 12 | 00:00:58.192 --> 00:00:59.727 13 | [instrumental music] 14 | 15 | 4 16 | 00:01:01.662 --> 00:01:03.063 17 | [dog barking] 18 | 19 | 5 20 | 00:01:26.787 --> 00:01:29.523 21 | [beeping] 22 | 23 | 6 24 | 00:01:29.590 --> 00:01:31.992 25 | [automated] 26 | 'The time is 7:35.' 27 | 28 | 7 29 | 00:08:00.000 --> 00:09:00.000 30 | Test with multi line italics 31 | Terminated on the next line 32 | 33 | 8 34 | 00:09:00.000 --> 00:10:00.000 35 | Unterminated styles 36 | 37 | 9 38 | 00:10:00.000 --> 00:11:00.000 39 | Do no fall to the next item 40 | 41 | 10 42 | 00:12:00.000 --> 00:13:00.000 43 | x^3 * x = 100 44 | -------------------------------------------------------------------------------- /testdata/example-out-v4plus.ssa: -------------------------------------------------------------------------------- 1 | [Script Info] 2 | ; Comment 1 3 | ; Comment 2 4 | Collisions: Normal 5 | Original Script: asticode 6 | PlayDepth: 0 7 | PlayResY: 600 8 | ScriptType: v4.00+ 9 | Script Updated By: version 2.8.01 10 | Timer: 100 11 | Title: SSA test 12 | 13 | [V4+ Styles] 14 | Format: Name, Alignment, AlphaLevel, BackColour, Bold, BorderStyle, Encoding, Fontname, Fontsize, Italic, MarginL, MarginR, MarginV, Outline, OutlineColour, PrimaryColour, SecondaryColour, Shadow 15 | Style: 1,7,0.100,&H80000008,1,7,0,f1,4.000,0,1,4,7,1.000,&H0000ffff,&H0000ffff,&H0000ffff,4.000 16 | Style: 2,8,0.200,&H000f0f0f,1,8,1,f2,5.000,0,2,5,8,2.000,&H0000ffff,&H00efefef,&H0000ffff,5.000 17 | Style: 3,9,0.300,&H00000008,0,9,2,f3,6.000,0,3,6,9,3.000,&H00000008,&H00b4fcfc,&H00b4fcfc,6.000 18 | 19 | [Events] 20 | Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text 21 | Dialogue: 0,00:01:39.00,00:01:41.04,1,Cher,1234,2345,3456,test,{\pos(400,570)}(deep rumbling) 22 | Dialogue: 0,00:02:04.08,00:02:07.12,2,autre,0,0,0,,MAN:\nHow did we end up here? 23 | Dialogue: 0,00:02:12.16,00:02:15.20,3,autre,0,0,0,,This place is horrible. 24 | Dialogue: 0,00:02:20.24,00:02:22.28,1,autre,0,0,0,,Smells like balls. 25 | Dialogue: 0,00:02:28.32,00:02:31.36,2,autre,0,0,0,,We don't belong\nin this shithole. 26 | Dialogue: 0,00:02:31.40,00:02:33.44,3,autre,0,0,0,,(computer playing\nelectronic melody) 27 | -------------------------------------------------------------------------------- /testdata/example-out.srt: -------------------------------------------------------------------------------- 1 | 1 2 | 00:01:39,000 --> 00:01:41,040 3 | (deep rumbling) 4 | 5 | 2 6 | 00:02:04,080 --> 00:02:07,120 7 | MAN: 8 | How did we end up here? 9 | 10 | 3 11 | 00:02:12,160 --> 00:02:15,200 12 | This place is horrible. 13 | 14 | 4 15 | 00:02:20,240 --> 00:02:22,280 16 | Smells like balls. 17 | 18 | 5 19 | 00:02:28,320 --> 00:02:31,360 20 | We don't belong 21 | in this shithole. 22 | 23 | 6 24 | 00:02:31,400 --> 00:02:33,440 25 | (computer playing 26 | electronic melody) 27 | -------------------------------------------------------------------------------- /testdata/example-out.ssa: -------------------------------------------------------------------------------- 1 | [Script Info] 2 | ; Comment 1 3 | ; Comment 2 4 | Collisions: Normal 5 | Original Script: asticode 6 | PlayDepth: 0 7 | PlayResY: 600 8 | ScriptType: v4.00 9 | Script Updated By: version 2.8.01 10 | Timer: 100 11 | Title: SSA test 12 | 13 | [V4 Styles] 14 | Format: Name, Alignment, AlphaLevel, BackColour, Bold, BorderStyle, Encoding, Fontname, Fontsize, Italic, MarginL, MarginR, MarginV, Outline, OutlineColour, PrimaryColour, SecondaryColour, Shadow 15 | Style: 1,7,0.100,&H80000008,1,7,0,f1,4.000,0,1,4,7,1.000,&H0000ffff,&H0000ffff,&H0000ffff,4.000 16 | Style: 2,8,0.200,&H000f0f0f,1,8,1,f2,5.000,0,2,5,8,2.000,&H0000ffff,&H00efefef,&H0000ffff,5.000 17 | Style: 3,9,0.300,&H00000008,0,9,2,f3,6.000,0,3,6,9,3.000,&H00000008,&H00b4fcfc,&H00b4fcfc,6.000 18 | 19 | [Events] 20 | Format: Marked, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text 21 | Dialogue: Marked=0,00:01:39.00,00:01:41.04,1,Cher,1234,2345,3456,test,{\pos(400,570)}(deep rumbling) 22 | Dialogue: Marked=1,00:02:04.08,00:02:07.12,2,autre,0,0,0,,MAN:\nHow did we end up here? 23 | Dialogue: Marked=1,00:02:12.16,00:02:15.20,3,autre,0,0,0,,This place is horrible. 24 | Dialogue: Marked=1,00:02:20.24,00:02:22.28,1,autre,0,0,0,,Smells like balls. 25 | Dialogue: Marked=1,00:02:28.32,00:02:31.36,2,autre,0,0,0,,We don't belong\nin this shithole. 26 | Dialogue: Marked=1,00:02:31.40,00:02:33.44,3,autre,0,0,0,,(computer playing\nelectronic melody) 27 | -------------------------------------------------------------------------------- /testdata/example-out.stl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asticode/go-astisub/716eb3660b8c5d42d716f6dadaa0626a6e1645f3/testdata/example-out.stl -------------------------------------------------------------------------------- /testdata/example-out.ttml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Copyright test 5 | Title test 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 |

21 | (deep rumbling) 22 |

23 |

24 | MAN: 25 |

26 | How did we 27 | end up 28 | here? 29 |

30 |

31 | This place is horrible. 32 |

33 |

34 | Smells like balls. 35 |

36 |

37 | We don't belong 38 |

39 | in this shithole. 40 |

41 |

42 | (computer playing 43 |

44 | electronic melody) 45 |

46 |
47 | 48 |
-------------------------------------------------------------------------------- /testdata/example-out.vtt: -------------------------------------------------------------------------------- 1 | WEBVTT 2 | 3 | STYLE 4 | ::cue(b) { 5 | color: peachpuff; 6 | } 7 | ::cue(c) { 8 | color: white; 9 | } 10 | ::cue(a) { 11 | color: red; 12 | } 13 | ::cue(d) { 14 | color: red; 15 | background-image: linear-gradient(to bottom, dimgray, lightgray); 16 | } 17 | 18 | Region: id=bill lines=3 regionanchor=100%,100% scroll=up viewportanchor=90%,90% width=40% 19 | Region: id=fred lines=3 regionanchor=0%,100% scroll=up viewportanchor=10%,90% width=40% 20 | 21 | NOTE this a nice example 22 | of a VTT 23 | 24 | 1 25 | 00:01:39.000 --> 00:01:41.040 region:bill 26 | (deep rumbling) 27 | 28 | NOTE This a comment inside the VTT 29 | and this is the second line 30 | 31 | 2 32 | 00:02:04.080 --> 00:02:07.120 align:left position:10%,start region:fred size:35% 33 | MAN: 34 | How did we end up here? 35 | 36 | 3 37 | 00:02:12.160 --> 00:02:15.200 38 | This place is horrible. 39 | 40 | 4 41 | 00:02:20.240 --> 00:02:22.280 42 | Smells like balls. 43 | 44 | 5 45 | 00:02:28.320 --> 00:02:31.360 46 | We don't belong 47 | in this shithole. 48 | 49 | 6 50 | 00:02:31.400 --> 00:02:33.440 51 | (computer playing 52 | electronic melody) 53 | -------------------------------------------------------------------------------- /testdata/missing-sequence-in.srt: -------------------------------------------------------------------------------- 1 | 00:01:39 --> 00:01:41,04 2 | (deep rumbling) 3 | 4 | 00:02:04,08 --> 00:02:07,12 X1:40 X2:600 Y1:20 Y2:50 5 | MAN: 6 | How did we end up here? 7 | 8 | 00:02:12.16 --> 00:02:15.20 9 | This place is horrible. 10 | 11 | 00:02:20.24 --> 00:02:22.28 12 | Smells like balls. 13 | 14 | 00:02:28,32 --> 00:02:31,36 15 | We don't belong 16 | in this shithole. 17 | 18 | 00:02:31,40 --> 00:02:33,44 19 | (computer playing 20 | electronic melody) 21 | -------------------------------------------------------------------------------- /ttml.go: -------------------------------------------------------------------------------- 1 | package astisub 2 | 3 | import ( 4 | "encoding/xml" 5 | "fmt" 6 | "io" 7 | "regexp" 8 | "sort" 9 | "strconv" 10 | "strings" 11 | "time" 12 | "unicode" 13 | 14 | "github.com/asticode/go-astikit" 15 | ) 16 | 17 | // https://www.w3.org/TR/ttaf1-dfxp/ 18 | // http://www.skynav.com:8080/ttv/check 19 | // https://www.speechpad.com/captions/ttml 20 | 21 | // TTML languages 22 | const ( 23 | ttmlLanguageChinese = "zh" 24 | ttmlLanguageEnglish = "en" 25 | ttmlLanguageJapanese = "ja" 26 | ttmlLanguageFrench = "fr" 27 | ttmlLanguageNorwegian = "no" 28 | ) 29 | 30 | // TTML language mapping 31 | var ttmlLanguageMapping = astikit.NewBiMap(). 32 | Set(ttmlLanguageChinese, LanguageChinese). 33 | Set(ttmlLanguageEnglish, LanguageEnglish). 34 | Set(ttmlLanguageFrench, LanguageFrench). 35 | Set(ttmlLanguageJapanese, LanguageJapanese). 36 | Set(ttmlLanguageNorwegian, LanguageNorwegian) 37 | 38 | // TTML Clock Time Frames and Offset Time 39 | var ( 40 | ttmlRegexpClockTimeFrames = regexp.MustCompile(`\:[\d]+$`) 41 | ttmlRegexpOffsetTime = regexp.MustCompile(`^(\d+(\.\d+)?)(h|m|s|ms|f|t)$`) 42 | ) 43 | 44 | // TTMLIn represents an input TTML that must be unmarshaled 45 | // We split it from the output TTML as we can't add strict namespace without breaking retrocompatibility 46 | type TTMLIn struct { 47 | Framerate int `xml:"frameRate,attr"` 48 | Lang string `xml:"lang,attr"` 49 | Metadata TTMLInMetadata `xml:"head>metadata"` 50 | Regions []TTMLInRegion `xml:"head>layout>region"` 51 | Styles []TTMLInStyle `xml:"head>styling>style"` 52 | Subtitles []TTMLInSubtitle `xml:"body>div>p"` 53 | Tickrate int `xml:"tickRate,attr"` 54 | XMLName xml.Name `xml:"tt"` 55 | } 56 | 57 | // metadata returns the Metadata of the TTML 58 | func (t TTMLIn) metadata() (m *Metadata) { 59 | m = &Metadata{ 60 | Framerate: t.Framerate, 61 | Title: t.Metadata.Title, 62 | TTMLCopyright: t.Metadata.Copyright, 63 | } 64 | if v, ok := ttmlLanguageMapping.Get(astikit.StrPad(t.Lang, ' ', 2, astikit.PadCut)); ok { 65 | m.Language = v.(string) 66 | } 67 | return 68 | } 69 | 70 | // TTMLInMetadata represents an input TTML Metadata 71 | type TTMLInMetadata struct { 72 | Copyright string `xml:"copyright"` 73 | Title string `xml:"title"` 74 | } 75 | 76 | // TTMLInStyleAttributes represents input TTML style attributes 77 | type TTMLInStyleAttributes struct { 78 | BackgroundColor *string `xml:"backgroundColor,attr,omitempty"` 79 | Color *string `xml:"color,attr,omitempty"` 80 | Direction *string `xml:"direction,attr,omitempty"` 81 | Display *string `xml:"display,attr,omitempty"` 82 | DisplayAlign *string `xml:"displayAlign,attr,omitempty"` 83 | Extent *string `xml:"extent,attr,omitempty"` 84 | FontFamily *string `xml:"fontFamily,attr,omitempty"` 85 | FontSize *string `xml:"fontSize,attr,omitempty"` 86 | FontStyle *string `xml:"fontStyle,attr,omitempty"` 87 | FontWeight *string `xml:"fontWeight,attr,omitempty"` 88 | LineHeight *string `xml:"lineHeight,attr,omitempty"` 89 | Opacity *string `xml:"opacity,attr,omitempty"` 90 | Origin *string `xml:"origin,attr,omitempty"` 91 | Overflow *string `xml:"overflow,attr,omitempty"` 92 | Padding *string `xml:"padding,attr,omitempty"` 93 | ShowBackground *string `xml:"showBackground,attr,omitempty"` 94 | TextAlign *string `xml:"textAlign,attr,omitempty"` 95 | TextDecoration *string `xml:"textDecoration,attr,omitempty"` 96 | TextOutline *string `xml:"textOutline,attr,omitempty"` 97 | UnicodeBidi *string `xml:"unicodeBidi,attr,omitempty"` 98 | Visibility *string `xml:"visibility,attr,omitempty"` 99 | WrapOption *string `xml:"wrapOption,attr,omitempty"` 100 | WritingMode *string `xml:"writingMode,attr,omitempty"` 101 | ZIndex *int `xml:"zIndex,attr,omitempty"` 102 | } 103 | 104 | // StyleAttributes converts TTMLInStyleAttributes into a StyleAttributes 105 | func (s TTMLInStyleAttributes) styleAttributes() (o *StyleAttributes) { 106 | o = &StyleAttributes{ 107 | TTMLBackgroundColor: s.BackgroundColor, 108 | TTMLColor: s.Color, 109 | TTMLDirection: s.Direction, 110 | TTMLDisplay: s.Display, 111 | TTMLDisplayAlign: s.DisplayAlign, 112 | TTMLExtent: s.Extent, 113 | TTMLFontFamily: s.FontFamily, 114 | TTMLFontSize: s.FontSize, 115 | TTMLFontStyle: s.FontStyle, 116 | TTMLFontWeight: s.FontWeight, 117 | TTMLLineHeight: s.LineHeight, 118 | TTMLOpacity: s.Opacity, 119 | TTMLOrigin: s.Origin, 120 | TTMLOverflow: s.Overflow, 121 | TTMLPadding: s.Padding, 122 | TTMLShowBackground: s.ShowBackground, 123 | TTMLTextAlign: s.TextAlign, 124 | TTMLTextDecoration: s.TextDecoration, 125 | TTMLTextOutline: s.TextOutline, 126 | TTMLUnicodeBidi: s.UnicodeBidi, 127 | TTMLVisibility: s.Visibility, 128 | TTMLWrapOption: s.WrapOption, 129 | TTMLWritingMode: s.WritingMode, 130 | TTMLZIndex: s.ZIndex, 131 | } 132 | o.propagateTTMLAttributes() 133 | return 134 | } 135 | 136 | // TTMLInHeader represents an input TTML header 137 | type TTMLInHeader struct { 138 | ID string `xml:"id,attr,omitempty"` 139 | Style string `xml:"style,attr,omitempty"` 140 | TTMLInStyleAttributes 141 | } 142 | 143 | // TTMLInRegion represents an input TTML region 144 | type TTMLInRegion struct { 145 | TTMLInHeader 146 | XMLName xml.Name `xml:"region"` 147 | } 148 | 149 | // TTMLInStyle represents an input TTML style 150 | type TTMLInStyle struct { 151 | TTMLInHeader 152 | XMLName xml.Name `xml:"style"` 153 | } 154 | 155 | // TTMLInSubtitle represents an input TTML subtitle 156 | type TTMLInSubtitle struct { 157 | Begin *TTMLInDuration `xml:"begin,attr,omitempty"` 158 | End *TTMLInDuration `xml:"end,attr,omitempty"` 159 | ID string `xml:"id,attr,omitempty"` 160 | // We must store inner XML temporarily here since there's no tag to describe both any tag and chardata 161 | // Real unmarshal will be done manually afterwards 162 | Items string `xml:",innerxml"` 163 | Region string `xml:"region,attr,omitempty"` 164 | Style string `xml:"style,attr,omitempty"` 165 | TTMLInStyleAttributes 166 | } 167 | 168 | // TTMLInItems represents input TTML items 169 | type TTMLInItems []TTMLInItem 170 | 171 | // UnmarshalXML implements the XML unmarshaler interface 172 | func (i *TTMLInItems) UnmarshalXML(d *xml.Decoder, start xml.StartElement) (err error) { 173 | // Get next tokens 174 | var t xml.Token 175 | for { 176 | // Get next token 177 | if t, err = d.Token(); err != nil { 178 | if err == io.EOF { 179 | break 180 | } 181 | err = fmt.Errorf("astisub: getting next token failed: %w", err) 182 | return 183 | } 184 | 185 | // Start element 186 | if se, ok := t.(xml.StartElement); ok { 187 | var e = TTMLInItem{} 188 | if err = d.DecodeElement(&e, &se); err != nil { 189 | err = fmt.Errorf("astisub: decoding xml.StartElement failed: %w", err) 190 | return 191 | } 192 | *i = append(*i, e) 193 | } else if b, ok := t.(xml.CharData); ok { 194 | if str := string(b); len(strings.TrimSpace(str)) > 0 { 195 | *i = append(*i, TTMLInItem{Text: str}) 196 | } 197 | } 198 | } 199 | return nil 200 | } 201 | 202 | type ttmlXmlTokenReader struct { 203 | xmlTokenReader xml.TokenReader 204 | holdingToken xml.Token 205 | } 206 | 207 | // Token implements the TokenReader interface, when it meets the "br" tag, it will hold the token and return a newline 208 | // instead. This is to work around the fact that the go xml unmarshaler will ignore the "br" tag if it's within a 209 | // character data field. 210 | func (r *ttmlXmlTokenReader) Token() (xml.Token, error) { 211 | if r.holdingToken != nil { 212 | returnToken := r.holdingToken 213 | r.holdingToken = nil 214 | return returnToken, nil 215 | } 216 | 217 | t, err := r.xmlTokenReader.Token() 218 | if err != nil { 219 | return nil, err 220 | } 221 | 222 | if se, ok := t.(xml.StartElement); ok && strings.ToLower(se.Name.Local) == "br" { 223 | r.holdingToken = t 224 | return xml.CharData("\n"), nil 225 | } 226 | 227 | return t, nil 228 | } 229 | 230 | func newTTMLXmlDecoder(s string) *xml.Decoder { 231 | return xml.NewTokenDecoder( 232 | &ttmlXmlTokenReader{ 233 | xmlTokenReader: xml.NewDecoder(strings.NewReader("

" + s + "

")), 234 | holdingToken: nil, 235 | }, 236 | ) 237 | } 238 | 239 | // TTMLInItem represents an input TTML item 240 | type TTMLInItem struct { 241 | Style string `xml:"style,attr,omitempty"` 242 | Text string `xml:",chardata"` 243 | TTMLInStyleAttributes 244 | XMLName xml.Name 245 | } 246 | 247 | // TTMLInDuration represents an input TTML duration 248 | type TTMLInDuration struct { 249 | d time.Duration 250 | frames, framerate int // Framerate is in frame/s 251 | ticks, tickrate int // Tickrate is in ticks/s 252 | } 253 | 254 | // UnmarshalText implements the TextUnmarshaler interface 255 | // Possible formats are: 256 | // - hh:mm:ss.mmm 257 | // - hh:mm:ss:fff (fff being frames) 258 | // - [ticks]t ([ticks] being the tick amount) 259 | func (d *TTMLInDuration) UnmarshalText(i []byte) (err error) { 260 | // Reset duration 261 | d.d = time.Duration(0) 262 | d.frames = 0 263 | d.ticks = 0 264 | 265 | // Check offset time 266 | text := string(i) 267 | if matches := ttmlRegexpOffsetTime.FindStringSubmatch(text); matches != nil { 268 | // Parse value 269 | var value float64 270 | if value, err = strconv.ParseFloat(matches[1], 64); err != nil { 271 | err = fmt.Errorf("astisub: failed to parse value %s", matches[1]) 272 | return 273 | } 274 | 275 | // Parse metric 276 | metric := matches[3] 277 | 278 | // Update duration 279 | if metric == "t" { 280 | d.ticks = int(value) 281 | } else if metric == "f" { 282 | d.frames = int(value) 283 | } else { 284 | // Get timebase 285 | var timebase time.Duration 286 | switch metric { 287 | case "h": 288 | timebase = time.Hour 289 | case "m": 290 | timebase = time.Minute 291 | case "s": 292 | timebase = time.Second 293 | case "ms": 294 | timebase = time.Millisecond 295 | default: 296 | err = fmt.Errorf("astisub: invalid metric %s", metric) 297 | return 298 | } 299 | 300 | // Update duration 301 | d.d = time.Duration(value * float64(timebase.Nanoseconds())) 302 | } 303 | return 304 | } 305 | 306 | // Extract clock time frames 307 | if indexes := ttmlRegexpClockTimeFrames.FindStringIndex(text); indexes != nil { 308 | // Parse frames 309 | var s = text[indexes[0]+1 : indexes[1]] 310 | if d.frames, err = strconv.Atoi(s); err != nil { 311 | err = fmt.Errorf("astisub: atoi %s failed: %w", s, err) 312 | return 313 | } 314 | 315 | // Update text 316 | text = text[:indexes[0]] + ".000" 317 | } 318 | 319 | d.d, err = parseDuration(text, ".", 3) 320 | return 321 | } 322 | 323 | // duration returns the input TTML Duration's time.Duration 324 | func (d TTMLInDuration) duration() (o time.Duration) { 325 | if d.ticks > 0 && d.tickrate > 0 { 326 | return time.Duration(float64(d.ticks) * 1e9 / float64(d.tickrate)) 327 | } 328 | o = d.d 329 | if d.frames > 0 && d.framerate > 0 { 330 | o += time.Duration(float64(d.frames) / float64(d.framerate) * float64(time.Second.Nanoseconds())) 331 | } 332 | return 333 | } 334 | 335 | // ReadFromTTML parses a .ttml content 336 | func ReadFromTTML(i io.Reader) (o *Subtitles, err error) { 337 | // Init 338 | o = NewSubtitles() 339 | 340 | // Unmarshal XML 341 | var ttml TTMLIn 342 | if err = xml.NewDecoder(i).Decode(&ttml); err != nil { 343 | err = fmt.Errorf("astisub: xml decoding failed: %w", err) 344 | return 345 | } 346 | 347 | // Add metadata 348 | o.Metadata = ttml.metadata() 349 | 350 | // Loop through styles 351 | var parentStyles = make(map[string]*Style) 352 | for _, ts := range ttml.Styles { 353 | var s = &Style{ 354 | ID: ts.ID, 355 | InlineStyle: ts.TTMLInStyleAttributes.styleAttributes(), 356 | } 357 | o.Styles[s.ID] = s 358 | if len(ts.Style) > 0 { 359 | parentStyles[ts.Style] = s 360 | } 361 | } 362 | 363 | // Take care of parent styles 364 | for id, s := range parentStyles { 365 | if _, ok := o.Styles[id]; !ok { 366 | err = fmt.Errorf("astisub: Style %s requested by style %s doesn't exist", id, s.ID) 367 | return 368 | } 369 | s.Style = o.Styles[id] 370 | } 371 | 372 | // Loop through regions 373 | for _, tr := range ttml.Regions { 374 | var r = &Region{ 375 | ID: tr.ID, 376 | InlineStyle: tr.TTMLInStyleAttributes.styleAttributes(), 377 | } 378 | if len(tr.Style) > 0 { 379 | if _, ok := o.Styles[tr.Style]; !ok { 380 | err = fmt.Errorf("astisub: Style %s requested by region %s doesn't exist", tr.Style, r.ID) 381 | return 382 | } 383 | r.Style = o.Styles[tr.Style] 384 | } 385 | o.Regions[r.ID] = r 386 | } 387 | 388 | // Loop through subtitles 389 | for _, ts := range ttml.Subtitles { 390 | // Init item 391 | ts.Begin.framerate = ttml.Framerate 392 | ts.Begin.tickrate = ttml.Tickrate 393 | ts.End.framerate = ttml.Framerate 394 | ts.End.tickrate = ttml.Tickrate 395 | 396 | var s = &Item{ 397 | EndAt: ts.End.duration(), 398 | InlineStyle: ts.TTMLInStyleAttributes.styleAttributes(), 399 | StartAt: ts.Begin.duration(), 400 | } 401 | 402 | // Add region 403 | if len(ts.Region) > 0 { 404 | if _, ok := o.Regions[ts.Region]; !ok { 405 | err = fmt.Errorf("astisub: Region %s requested by subtitle between %s and %s doesn't exist", ts.Region, s.StartAt, s.EndAt) 406 | return 407 | } 408 | s.Region = o.Regions[ts.Region] 409 | } 410 | 411 | // Add style 412 | if len(ts.Style) > 0 { 413 | if _, ok := o.Styles[ts.Style]; !ok { 414 | err = fmt.Errorf("astisub: Style %s requested by subtitle between %s and %s doesn't exist", ts.Style, s.StartAt, s.EndAt) 415 | return 416 | } 417 | s.Style = o.Styles[ts.Style] 418 | } 419 | 420 | // Remove items identation 421 | lines := strings.Split(ts.Items, "\n") 422 | for i := 0; i < len(lines); i++ { 423 | lines[i] = strings.TrimLeftFunc(lines[i], unicode.IsSpace) 424 | } 425 | 426 | // Unmarshal items 427 | var items = TTMLInItems{} 428 | if err = newTTMLXmlDecoder(strings.Join(lines, "")).Decode(&items); err != nil { 429 | err = fmt.Errorf("astisub: unmarshaling items failed: %w", err) 430 | return 431 | } 432 | 433 | // Loop through texts 434 | var l = &Line{} 435 | for _, tt := range items { 436 | // New line specified with the "br" tag 437 | if strings.ToLower(tt.XMLName.Local) == "br" { 438 | s.Lines = append(s.Lines, *l) 439 | l = &Line{} 440 | continue 441 | } 442 | 443 | // New line decoded as a line break. This can happen if there's a "br" tag within the text since 444 | // since the go xml unmarshaler will unmarshal a "br" tag as a line break if the field has the 445 | // chardata xml tag. 446 | for idx, li := range strings.Split(tt.Text, "\n") { 447 | // New line 448 | if idx > 0 { 449 | s.Lines = append(s.Lines, *l) 450 | l = &Line{} 451 | } 452 | 453 | // Init line item 454 | var t = LineItem{ 455 | InlineStyle: tt.TTMLInStyleAttributes.styleAttributes(), 456 | Text: li, 457 | } 458 | 459 | // Add style 460 | if len(tt.Style) > 0 { 461 | if _, ok := o.Styles[tt.Style]; !ok { 462 | err = fmt.Errorf("astisub: Style %s requested by item with text %s doesn't exist", tt.Style, tt.Text) 463 | return 464 | } 465 | t.Style = o.Styles[tt.Style] 466 | } 467 | 468 | // Append items 469 | l.Items = append(l.Items, t) 470 | } 471 | 472 | } 473 | s.Lines = append(s.Lines, *l) 474 | 475 | // Append subtitle 476 | o.Items = append(o.Items, s) 477 | } 478 | return 479 | } 480 | 481 | // TTMLOut represents an output TTML that must be marshaled 482 | // We split it from the input TTML as this time we'll add strict namespaces 483 | type TTMLOut struct { 484 | Lang string `xml:"xml:lang,attr,omitempty"` 485 | Metadata *TTMLOutMetadata `xml:"head>metadata,omitempty"` 486 | Styles []TTMLOutStyle `xml:"head>styling>style,omitempty"` //!\\ Order is important! Keep Styling above Layout 487 | Regions []TTMLOutRegion `xml:"head>layout>region,omitempty"` 488 | Subtitles []TTMLOutSubtitle `xml:"body>div>p,omitempty"` 489 | XMLName xml.Name `xml:"http://www.w3.org/ns/ttml tt"` 490 | XMLNamespaceTTM string `xml:"xmlns:ttm,attr"` 491 | XMLNamespaceTTS string `xml:"xmlns:tts,attr"` 492 | } 493 | 494 | // TTMLOutMetadata represents an output TTML Metadata 495 | type TTMLOutMetadata struct { 496 | Copyright string `xml:"ttm:copyright,omitempty"` 497 | Title string `xml:"ttm:title,omitempty"` 498 | } 499 | 500 | // TTMLOutStyleAttributes represents output TTML style attributes 501 | type TTMLOutStyleAttributes struct { 502 | BackgroundColor *string `xml:"tts:backgroundColor,attr,omitempty"` 503 | Color *string `xml:"tts:color,attr,omitempty"` 504 | Direction *string `xml:"tts:direction,attr,omitempty"` 505 | Display *string `xml:"tts:display,attr,omitempty"` 506 | DisplayAlign *string `xml:"tts:displayAlign,attr,omitempty"` 507 | Extent *string `xml:"tts:extent,attr,omitempty"` 508 | FontFamily *string `xml:"tts:fontFamily,attr,omitempty"` 509 | FontSize *string `xml:"tts:fontSize,attr,omitempty"` 510 | FontStyle *string `xml:"tts:fontStyle,attr,omitempty"` 511 | FontWeight *string `xml:"tts:fontWeight,attr,omitempty"` 512 | LineHeight *string `xml:"tts:lineHeight,attr,omitempty"` 513 | Opacity *string `xml:"tts:opacity,attr,omitempty"` 514 | Origin *string `xml:"tts:origin,attr,omitempty"` 515 | Overflow *string `xml:"tts:overflow,attr,omitempty"` 516 | Padding *string `xml:"tts:padding,attr,omitempty"` 517 | ShowBackground *string `xml:"tts:showBackground,attr,omitempty"` 518 | TextAlign *string `xml:"tts:textAlign,attr,omitempty"` 519 | TextDecoration *string `xml:"tts:textDecoration,attr,omitempty"` 520 | TextOutline *string `xml:"tts:textOutline,attr,omitempty"` 521 | UnicodeBidi *string `xml:"tts:unicodeBidi,attr,omitempty"` 522 | Visibility *string `xml:"tts:visibility,attr,omitempty"` 523 | WrapOption *string `xml:"tts:wrapOption,attr,omitempty"` 524 | WritingMode *string `xml:"tts:writingMode,attr,omitempty"` 525 | ZIndex *int `xml:"tts:zIndex,attr,omitempty"` 526 | } 527 | 528 | // ttmlOutStyleAttributesFromStyleAttributes converts StyleAttributes into a TTMLOutStyleAttributes 529 | func ttmlOutStyleAttributesFromStyleAttributes(s *StyleAttributes) TTMLOutStyleAttributes { 530 | if s == nil { 531 | return TTMLOutStyleAttributes{} 532 | } 533 | return TTMLOutStyleAttributes{ 534 | BackgroundColor: s.TTMLBackgroundColor, 535 | Color: s.TTMLColor, 536 | Direction: s.TTMLDirection, 537 | Display: s.TTMLDisplay, 538 | DisplayAlign: s.TTMLDisplayAlign, 539 | Extent: s.TTMLExtent, 540 | FontFamily: s.TTMLFontFamily, 541 | FontSize: s.TTMLFontSize, 542 | FontStyle: s.TTMLFontStyle, 543 | FontWeight: s.TTMLFontWeight, 544 | LineHeight: s.TTMLLineHeight, 545 | Opacity: s.TTMLOpacity, 546 | Origin: s.TTMLOrigin, 547 | Overflow: s.TTMLOverflow, 548 | Padding: s.TTMLPadding, 549 | ShowBackground: s.TTMLShowBackground, 550 | TextAlign: s.TTMLTextAlign, 551 | TextDecoration: s.TTMLTextDecoration, 552 | TextOutline: s.TTMLTextOutline, 553 | UnicodeBidi: s.TTMLUnicodeBidi, 554 | Visibility: s.TTMLVisibility, 555 | WrapOption: s.TTMLWrapOption, 556 | WritingMode: s.TTMLWritingMode, 557 | ZIndex: s.TTMLZIndex, 558 | } 559 | } 560 | 561 | // TTMLOutHeader represents an output TTML header 562 | type TTMLOutHeader struct { 563 | ID string `xml:"xml:id,attr,omitempty"` 564 | Style string `xml:"style,attr,omitempty"` 565 | TTMLOutStyleAttributes 566 | } 567 | 568 | // TTMLOutRegion represents an output TTML region 569 | type TTMLOutRegion struct { 570 | TTMLOutHeader 571 | XMLName xml.Name `xml:"region"` 572 | } 573 | 574 | // TTMLOutStyle represents an output TTML style 575 | type TTMLOutStyle struct { 576 | TTMLOutHeader 577 | XMLName xml.Name `xml:"style"` 578 | } 579 | 580 | // TTMLOutSubtitle represents an output TTML subtitle 581 | type TTMLOutSubtitle struct { 582 | Begin TTMLOutDuration `xml:"begin,attr"` 583 | End TTMLOutDuration `xml:"end,attr"` 584 | ID string `xml:"id,attr,omitempty"` 585 | Items []TTMLOutItem 586 | Region string `xml:"region,attr,omitempty"` 587 | Style string `xml:"style,attr,omitempty"` 588 | TTMLOutStyleAttributes 589 | } 590 | 591 | // TTMLOutItem represents an output TTML Item 592 | type TTMLOutItem struct { 593 | Style string `xml:"style,attr,omitempty"` 594 | Text string `xml:",chardata"` 595 | TTMLOutStyleAttributes 596 | XMLName xml.Name 597 | } 598 | 599 | // TTMLOutDuration represents an output TTML duration 600 | type TTMLOutDuration time.Duration 601 | 602 | // MarshalText implements the TextMarshaler interface 603 | func (t TTMLOutDuration) MarshalText() ([]byte, error) { 604 | return []byte(formatDuration(time.Duration(t), ".", 3)), nil 605 | } 606 | 607 | // WriteToTTMLOptions represents TTML write options. 608 | type WriteToTTMLOptions struct { 609 | Indent string // Default is 4 spaces. 610 | } 611 | 612 | // WriteToTTMLOption represents a WriteToTTML option. 613 | type WriteToTTMLOption func(o *WriteToTTMLOptions) 614 | 615 | // WriteToTTMLWithIndentOption sets the indent option. 616 | func WriteToTTMLWithIndentOption(indent string) WriteToTTMLOption { 617 | return func(o *WriteToTTMLOptions) { 618 | o.Indent = indent 619 | } 620 | } 621 | 622 | // WriteToTTML writes subtitles in .ttml format 623 | func (s Subtitles) WriteToTTML(o io.Writer, opts ...WriteToTTMLOption) (err error) { 624 | // Create write options 625 | wo := &WriteToTTMLOptions{Indent: " "} 626 | for _, opt := range opts { 627 | opt(wo) 628 | } 629 | 630 | // Do not write anything if no subtitles 631 | if len(s.Items) == 0 { 632 | return ErrNoSubtitlesToWrite 633 | } 634 | 635 | // Init TTML 636 | var ttml = TTMLOut{ 637 | XMLNamespaceTTM: "http://www.w3.org/ns/ttml#metadata", 638 | XMLNamespaceTTS: "http://www.w3.org/ns/ttml#styling", 639 | } 640 | 641 | // Add metadata 642 | if s.Metadata != nil { 643 | if v, ok := ttmlLanguageMapping.GetInverse(s.Metadata.Language); ok { 644 | ttml.Lang = v.(string) 645 | } 646 | if len(s.Metadata.TTMLCopyright) > 0 || len(s.Metadata.Title) > 0 { 647 | ttml.Metadata = &TTMLOutMetadata{ 648 | Copyright: s.Metadata.TTMLCopyright, 649 | Title: s.Metadata.Title, 650 | } 651 | } 652 | } 653 | 654 | // Add regions 655 | var k []string 656 | for _, region := range s.Regions { 657 | k = append(k, region.ID) 658 | } 659 | sort.Strings(k) 660 | for _, id := range k { 661 | var ttmlRegion = TTMLOutRegion{TTMLOutHeader: TTMLOutHeader{ 662 | ID: s.Regions[id].ID, 663 | TTMLOutStyleAttributes: ttmlOutStyleAttributesFromStyleAttributes(s.Regions[id].InlineStyle), 664 | }} 665 | if s.Regions[id].Style != nil { 666 | ttmlRegion.Style = s.Regions[id].Style.ID 667 | } 668 | ttml.Regions = append(ttml.Regions, ttmlRegion) 669 | } 670 | 671 | // Add styles 672 | k = []string{} 673 | for _, style := range s.Styles { 674 | k = append(k, style.ID) 675 | } 676 | sort.Strings(k) 677 | for _, id := range k { 678 | var ttmlStyle = TTMLOutStyle{TTMLOutHeader: TTMLOutHeader{ 679 | ID: s.Styles[id].ID, 680 | TTMLOutStyleAttributes: ttmlOutStyleAttributesFromStyleAttributes(s.Styles[id].InlineStyle), 681 | }} 682 | if s.Styles[id].Style != nil { 683 | ttmlStyle.Style = s.Styles[id].Style.ID 684 | } 685 | ttml.Styles = append(ttml.Styles, ttmlStyle) 686 | } 687 | 688 | // Add items 689 | for _, item := range s.Items { 690 | // Init subtitle 691 | var ttmlSubtitle = TTMLOutSubtitle{ 692 | Begin: TTMLOutDuration(item.StartAt), 693 | End: TTMLOutDuration(item.EndAt), 694 | TTMLOutStyleAttributes: ttmlOutStyleAttributesFromStyleAttributes(item.InlineStyle), 695 | } 696 | 697 | // Add region 698 | if item.Region != nil { 699 | ttmlSubtitle.Region = item.Region.ID 700 | } 701 | 702 | // Add style 703 | if item.Style != nil { 704 | ttmlSubtitle.Style = item.Style.ID 705 | } 706 | 707 | // Add lines 708 | for _, line := range item.Lines { 709 | // Loop through line items 710 | for _, lineItem := range line.Items { 711 | // Init ttml item 712 | var ttmlItem = TTMLOutItem{ 713 | Text: lineItem.Text, 714 | TTMLOutStyleAttributes: ttmlOutStyleAttributesFromStyleAttributes(lineItem.InlineStyle), 715 | XMLName: xml.Name{Local: "span"}, 716 | } 717 | 718 | // Add style 719 | if lineItem.Style != nil { 720 | ttmlItem.Style = lineItem.Style.ID 721 | } 722 | 723 | // Add ttml item 724 | ttmlSubtitle.Items = append(ttmlSubtitle.Items, ttmlItem) 725 | } 726 | 727 | // Add line break 728 | ttmlSubtitle.Items = append(ttmlSubtitle.Items, TTMLOutItem{XMLName: xml.Name{Local: "br"}}) 729 | } 730 | 731 | // Remove last line break 732 | if len(ttmlSubtitle.Items) > 0 { 733 | ttmlSubtitle.Items = ttmlSubtitle.Items[:len(ttmlSubtitle.Items)-1] 734 | } 735 | 736 | // Append subtitle 737 | ttml.Subtitles = append(ttml.Subtitles, ttmlSubtitle) 738 | } 739 | 740 | // Marshal XML 741 | var e = xml.NewEncoder(o) 742 | 743 | // Set indent 744 | e.Indent("", wo.Indent) 745 | 746 | if err = e.Encode(ttml); err != nil { 747 | err = fmt.Errorf("astisub: xml encoding failed: %w", err) 748 | return 749 | } 750 | return 751 | } 752 | -------------------------------------------------------------------------------- /ttml_internal_test.go: -------------------------------------------------------------------------------- 1 | package astisub 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestTTMLDuration(t *testing.T) { 11 | // Unmarshal hh:mm:ss.mmm format - clock time 12 | var d = &TTMLInDuration{} 13 | err := d.UnmarshalText([]byte("12:34:56.789")) 14 | assert.NoError(t, err) 15 | assert.Equal(t, 12*time.Hour+34*time.Minute+56*time.Second+789*time.Millisecond, d.duration()) 16 | 17 | // Marshal 18 | b, err := TTMLOutDuration(d.duration()).MarshalText() 19 | assert.NoError(t, err) 20 | assert.Equal(t, "12:34:56.789", string(b)) 21 | 22 | // Unmarshal hh:mm:ss:fff format 23 | err = d.UnmarshalText([]byte("12:34:56:2")) 24 | assert.NoError(t, err) 25 | assert.Equal(t, 12*time.Hour+34*time.Minute+56*time.Second, d.duration()) 26 | assert.Equal(t, 2, d.frames) 27 | 28 | // Duration 29 | d.framerate = 8 30 | assert.Equal(t, 12*time.Hour+34*time.Minute+56*time.Second+250*time.Millisecond, d.duration()) 31 | 32 | // Unmarshal offset time 33 | err = d.UnmarshalText([]byte("123h")) 34 | assert.Equal(t, 123*time.Hour, d.duration()) 35 | assert.NoError(t, err) 36 | 37 | err = d.UnmarshalText([]byte("123.4h")) 38 | assert.Equal(t, 123*time.Hour+4*time.Hour/10, d.duration()) 39 | assert.NoError(t, err) 40 | 41 | err = d.UnmarshalText([]byte("123m")) 42 | assert.Equal(t, 123*time.Minute, d.duration()) 43 | assert.NoError(t, err) 44 | 45 | err = d.UnmarshalText([]byte("123.4m")) 46 | assert.Equal(t, 123*time.Minute+4*time.Minute/10, d.duration()) 47 | assert.NoError(t, err) 48 | 49 | err = d.UnmarshalText([]byte("123s")) 50 | assert.Equal(t, 123*time.Second, d.duration()) 51 | assert.NoError(t, err) 52 | 53 | err = d.UnmarshalText([]byte("123.4s")) 54 | assert.Equal(t, 123*time.Second+4*time.Second/10, d.duration()) 55 | assert.NoError(t, err) 56 | 57 | err = d.UnmarshalText([]byte("123ms")) 58 | assert.Equal(t, 123*time.Millisecond, d.duration()) 59 | assert.NoError(t, err) 60 | 61 | err = d.UnmarshalText([]byte("123.4ms")) 62 | assert.Equal(t, 123*time.Millisecond+4*time.Millisecond/10, d.duration()) 63 | assert.NoError(t, err) 64 | 65 | d.framerate = 25 66 | err = d.UnmarshalText([]byte("100f")) 67 | assert.Equal(t, 4*time.Second, d.duration()) 68 | assert.NoError(t, err) 69 | 70 | // Tick rate duration 71 | d.tickrate = 4 72 | err = d.UnmarshalText([]byte("6t")) 73 | assert.Equal(t, time.Second+500*time.Millisecond, d.duration()) 74 | assert.NoError(t, err) 75 | } 76 | -------------------------------------------------------------------------------- /ttml_test.go: -------------------------------------------------------------------------------- 1 | package astisub_test 2 | 3 | import ( 4 | "bytes" 5 | "io/ioutil" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/asticode/go-astikit" 10 | 11 | "github.com/asticode/go-astisub" 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | func TestTTML(t *testing.T) { 16 | // Open 17 | s, err := astisub.OpenFile("./testdata/example-in.ttml") 18 | assert.NoError(t, err) 19 | assertSubtitleItems(t, s) 20 | // Metadata 21 | assert.Equal(t, &astisub.Metadata{Framerate: 25, Language: astisub.LanguageFrench, Title: "Title test", TTMLCopyright: "Copyright test"}, s.Metadata) 22 | // Styles 23 | assert.Equal(t, 3, len(s.Styles)) 24 | assert.Equal(t, astisub.Style{ID: "style_0", InlineStyle: &astisub.StyleAttributes{TTMLColor: astikit.StrPtr("white"), TTMLExtent: astikit.StrPtr("100% 10%"), TTMLFontFamily: astikit.StrPtr("sansSerif"), TTMLFontStyle: astikit.StrPtr("normal"), TTMLOrigin: astikit.StrPtr("0% 90%"), TTMLTextAlign: astikit.StrPtr("center"), WebVTTAlign: "center", WebVTTLine: "0%", WebVTTLines: 2, WebVTTPosition: "90%", WebVTTRegionAnchor: "0%,0%", WebVTTScroll: "up", WebVTTSize: "10%", WebVTTViewportAnchor: "0%,90%", WebVTTWidth: "100%"}, Style: s.Styles["style_2"]}, *s.Styles["style_0"]) 25 | assert.Equal(t, astisub.Style{ID: "style_1", InlineStyle: &astisub.StyleAttributes{TTMLColor: astikit.StrPtr("white"), TTMLExtent: astikit.StrPtr("100% 13%"), TTMLFontFamily: astikit.StrPtr("sansSerif"), TTMLFontStyle: astikit.StrPtr("normal"), TTMLOrigin: astikit.StrPtr("0% 87%"), TTMLTextAlign: astikit.StrPtr("center"), WebVTTAlign: "center", WebVTTLine: "0%", WebVTTLines: 2, WebVTTPosition: "87%", WebVTTRegionAnchor: "0%,0%", WebVTTScroll: "up", WebVTTSize: "13%", WebVTTViewportAnchor: "0%,87%", WebVTTWidth: "100%"}}, *s.Styles["style_1"]) 26 | assert.Equal(t, astisub.Style{ID: "style_2", InlineStyle: &astisub.StyleAttributes{TTMLColor: astikit.StrPtr("white"), TTMLExtent: astikit.StrPtr("100% 20%"), TTMLFontFamily: astikit.StrPtr("sansSerif"), TTMLFontStyle: astikit.StrPtr("normal"), TTMLOrigin: astikit.StrPtr("0% 80%"), TTMLTextAlign: astikit.StrPtr("center"), WebVTTAlign: "center", WebVTTLine: "0%", WebVTTLines: 4, WebVTTPosition: "80%", WebVTTRegionAnchor: "0%,0%", WebVTTScroll: "up", WebVTTSize: "20%", WebVTTViewportAnchor: "0%,80%", WebVTTWidth: "100%"}}, *s.Styles["style_2"]) 27 | // Regions 28 | assert.Equal(t, 3, len(s.Regions)) 29 | assert.Equal(t, astisub.Region{ID: "region_0", Style: s.Styles["style_0"], InlineStyle: &astisub.StyleAttributes{TTMLColor: astikit.StrPtr("blue")}}, *s.Regions["region_0"]) 30 | assert.Equal(t, astisub.Region{ID: "region_1", Style: s.Styles["style_1"], InlineStyle: &astisub.StyleAttributes{}}, *s.Regions["region_1"]) 31 | assert.Equal(t, astisub.Region{ID: "region_2", Style: s.Styles["style_2"], InlineStyle: &astisub.StyleAttributes{}}, *s.Regions["region_2"]) 32 | // Items 33 | assert.Equal(t, s.Regions["region_1"], s.Items[0].Region) 34 | assert.Equal(t, s.Styles["style_1"], s.Items[0].Style) 35 | assert.Equal(t, &astisub.StyleAttributes{TTMLColor: astikit.StrPtr("red")}, s.Items[0].InlineStyle) 36 | assert.Equal(t, []astisub.Line{{Items: []astisub.LineItem{{Style: s.Styles["style_1"], InlineStyle: &astisub.StyleAttributes{TTMLColor: astikit.StrPtr("black")}, Text: "(deep rumbling)"}}}}, s.Items[0].Lines) 37 | 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: astikit.StrPtr("green")}, Style: s.Styles["style_1"], Text: "end up"}, {InlineStyle: &astisub.StyleAttributes{}, Text: " here?"}}}}, s.Items[1].Lines) 38 | 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) 39 | assert.Equal(t, []astisub.Line{{Items: []astisub.LineItem{{InlineStyle: &astisub.StyleAttributes{}, Style: s.Styles["style_1"], Text: "Smells like balls."}}}}, s.Items[3].Lines) 40 | 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) 41 | 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) 42 | 43 | // No subtitles to write 44 | w := &bytes.Buffer{} 45 | err = astisub.Subtitles{}.WriteToTTML(w) 46 | assert.EqualError(t, err, astisub.ErrNoSubtitlesToWrite.Error()) 47 | 48 | // Write 49 | c, err := ioutil.ReadFile("./testdata/example-out.ttml") 50 | assert.NoError(t, err) 51 | err = s.WriteToTTML(w) 52 | assert.NoError(t, err) 53 | assert.Equal(t, string(c), w.String()) 54 | } 55 | 56 | func TestTTMLBreakLines(t *testing.T) { 57 | // Open 58 | s, err := astisub.OpenFile("./testdata/example-in-breaklines.ttml") 59 | assert.NoError(t, err) 60 | 61 | // Write 62 | w := &bytes.Buffer{} 63 | err = s.WriteToTTML(w) 64 | assert.NoError(t, err) 65 | 66 | c, err := ioutil.ReadFile("./testdata/example-out-breaklines.ttml") 67 | assert.NoError(t, err) 68 | 69 | assert.Equal(t, strings.TrimSpace(string(c)), strings.TrimSpace(w.String())) 70 | } 71 | 72 | func TestWriteToTTMLWithIndentOption(t *testing.T) { 73 | // Open 74 | s, err := astisub.OpenFile("./testdata/example-in.ttml") 75 | assert.NoError(t, err) 76 | 77 | // Write 78 | w := &bytes.Buffer{} 79 | 80 | err = s.WriteToTTML(w, astisub.WriteToTTMLWithIndentOption("")) 81 | assert.NoError(t, err) 82 | 83 | c, err := ioutil.ReadFile("./testdata/example-out-no-indent.ttml") 84 | assert.NoError(t, err) 85 | 86 | assert.Equal(t, strings.TrimSpace(string(c)), strings.TrimSpace(w.String())) 87 | } 88 | -------------------------------------------------------------------------------- /webvtt.go: -------------------------------------------------------------------------------- 1 | package astisub 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "log" 8 | "regexp" 9 | "sort" 10 | "strconv" 11 | "strings" 12 | "time" 13 | "unicode/utf8" 14 | 15 | "golang.org/x/net/html" 16 | ) 17 | 18 | // https://www.w3.org/TR/webvtt1/ 19 | 20 | // Constants 21 | const ( 22 | webvttBlockNameComment = "comment" 23 | webvttBlockNameRegion = "region" 24 | webvttBlockNameStyle = "style" 25 | webvttBlockNameText = "text" 26 | webvttDefaultStyleID = "astisub-webvtt-default-style-id" 27 | webvttTimeBoundariesSeparator = "-->" 28 | webvttTimestampMapHeader = "X-TIMESTAMP-MAP" 29 | ) 30 | 31 | // Vars 32 | var ( 33 | bytesWebVTTItalicEndTag = []byte("
") 34 | bytesWebVTTItalicStartTag = []byte("") 35 | bytesWebVTTTimeBoundariesSeparator = []byte(" " + webvttTimeBoundariesSeparator + " ") 36 | webVTTRegexpInlineTimestamp = regexp.MustCompile(`<((?:\d{2,}:)?\d{2}:\d{2}\.\d{3})>`) 37 | webVTTRegexpTag = regexp.MustCompile(`()`) 38 | ) 39 | 40 | // parseDurationWebVTT parses a .vtt duration 41 | func parseDurationWebVTT(i string) (time.Duration, error) { 42 | return parseDuration(i, ".", 3) 43 | } 44 | 45 | // WebVTTTimestampMap is a structure for storing timestamps for WEBVTT's 46 | // X-TIMESTAMP-MAP feature commonly used for syncing cue times with 47 | // MPEG-TS streams. 48 | type WebVTTTimestampMap struct { 49 | Local time.Duration 50 | MpegTS int64 51 | } 52 | 53 | // Offset calculates and returns the time offset described by the 54 | // timestamp map. 55 | func (t *WebVTTTimestampMap) Offset() time.Duration { 56 | if t == nil { 57 | return 0 58 | } 59 | return time.Duration(t.MpegTS)*time.Second/90000 - t.Local 60 | } 61 | 62 | // String implements Stringer interface for TimestampMap, returning 63 | // the fully formatted header string for the instance. 64 | func (t *WebVTTTimestampMap) String() string { 65 | mpegts := fmt.Sprintf("MPEGTS:%d", t.MpegTS) 66 | local := fmt.Sprintf("LOCAL:%s", formatDurationWebVTT(t.Local)) 67 | return fmt.Sprintf("%s=%s,%s", webvttTimestampMapHeader, local, mpegts) 68 | } 69 | 70 | // https://tools.ietf.org/html/rfc8216#section-3.5 71 | // Eg., `X-TIMESTAMP-MAP=LOCAL:00:00:00.000,MPEGTS:900000` => 10s 72 | // 73 | // `X-TIMESTAMP-MAP=LOCAL:00:00:00.000,MPEGTS:180000` => 2s 74 | func parseWebVTTTimestampMap(line string) (timestampMap *WebVTTTimestampMap, err error) { 75 | splits := strings.Split(line, "=") 76 | if len(splits) <= 1 { 77 | err = fmt.Errorf("astisub: invalid X-TIMESTAMP-MAP, no '=' found") 78 | return 79 | } 80 | right := splits[1] 81 | 82 | var local time.Duration 83 | var mpegts int64 84 | for _, split := range strings.Split(right, ",") { 85 | splits := strings.SplitN(split, ":", 2) 86 | if len(splits) <= 1 { 87 | err = fmt.Errorf("astisub: invalid X-TIMESTAMP-MAP, part %q didn't contain ':'", right) 88 | return 89 | } 90 | 91 | switch strings.ToLower(strings.TrimSpace(splits[0])) { 92 | case "local": 93 | local, err = parseDurationWebVTT(splits[1]) 94 | if err != nil { 95 | err = fmt.Errorf("astisub: parsing webvtt duration failed: %w", err) 96 | return 97 | } 98 | case "mpegts": 99 | mpegts, err = strconv.ParseInt(splits[1], 10, 0) 100 | if err != nil { 101 | err = fmt.Errorf("astisub: parsing int %s failed: %w", splits[1], err) 102 | return 103 | } 104 | } 105 | } 106 | 107 | timestampMap = &WebVTTTimestampMap{ 108 | Local: local, 109 | MpegTS: mpegts, 110 | } 111 | return 112 | } 113 | 114 | // ReadFromWebVTT parses a .vtt content 115 | // TODO Tags (u, i, b) 116 | // TODO Class 117 | func ReadFromWebVTT(i io.Reader) (o *Subtitles, err error) { 118 | // Init 119 | o = NewSubtitles() 120 | var scanner = newScanner(i) 121 | 122 | var line string 123 | var lineNum int 124 | 125 | // Skip the header 126 | for scanner.Scan() { 127 | lineNum++ 128 | line = scanner.Text() 129 | line = strings.TrimPrefix(line, string(BytesBOM)) 130 | if !utf8.ValidString(line) { 131 | err = fmt.Errorf("astisub: line %d is not valid utf-8", lineNum) 132 | return 133 | } 134 | if fs := strings.Fields(line); len(fs) > 0 && fs[0] == "WEBVTT" { 135 | break 136 | } 137 | } 138 | 139 | // Scan 140 | var item = &Item{} 141 | var blockName string 142 | var comments []string 143 | var index int 144 | var sa = &StyleAttributes{} 145 | 146 | for scanner.Scan() { 147 | // Fetch line 148 | line = strings.TrimSpace(scanner.Text()) 149 | lineNum++ 150 | if !utf8.ValidString(line) { 151 | err = fmt.Errorf("astisub: line %d is not valid utf-8", lineNum) 152 | return 153 | } 154 | 155 | switch { 156 | // Comment 157 | case strings.HasPrefix(line, "NOTE "): 158 | blockName = webvttBlockNameComment 159 | comments = append(comments, strings.TrimPrefix(line, "NOTE ")) 160 | // Empty line 161 | case len(line) == 0: 162 | // Reset block name, if we are not in the middle of CSS. 163 | // If we are in STYLE block and the CSS is empty or we meet the right brace at the end of last line, 164 | // then we are not in CSS and can switch to parse next WebVTT block. 165 | if blockName != webvttBlockNameStyle || sa == nil || 166 | len(sa.WebVTTStyles) == 0 || 167 | strings.HasSuffix(sa.WebVTTStyles[len(sa.WebVTTStyles)-1], "}") { 168 | blockName = "" 169 | } 170 | 171 | // Reset WebVTTTags 172 | sa.WebVTTTags = []WebVTTTag{} 173 | 174 | // Region 175 | case strings.HasPrefix(line, "Region: "): 176 | // Add region styles 177 | var r = &Region{InlineStyle: &StyleAttributes{}} 178 | for _, part := range strings.Split(strings.TrimPrefix(line, "Region: "), " ") { 179 | // Split on "=" 180 | var split = strings.Split(part, "=") 181 | if len(split) <= 1 { 182 | err = fmt.Errorf("astisub: line %d: Invalid region style %s", lineNum, part) 183 | return 184 | } 185 | 186 | // Switch on key 187 | switch split[0] { 188 | case "id": 189 | r.ID = split[1] 190 | case "lines": 191 | if r.InlineStyle.WebVTTLines, err = strconv.Atoi(split[1]); err != nil { 192 | err = fmt.Errorf("atoi of %s failed: %w", split[1], err) 193 | return 194 | } 195 | case "regionanchor": 196 | r.InlineStyle.WebVTTRegionAnchor = split[1] 197 | case "scroll": 198 | r.InlineStyle.WebVTTScroll = split[1] 199 | case "viewportanchor": 200 | r.InlineStyle.WebVTTViewportAnchor = split[1] 201 | case "width": 202 | r.InlineStyle.WebVTTWidth = split[1] 203 | } 204 | } 205 | r.InlineStyle.propagateWebVTTAttributes() 206 | 207 | // Add region 208 | o.Regions[r.ID] = r 209 | // Style 210 | case strings.HasPrefix(line, "STYLE"): 211 | blockName = webvttBlockNameStyle 212 | 213 | if _, ok := o.Styles[webvttDefaultStyleID]; !ok { 214 | sa = &StyleAttributes{} 215 | o.Styles[webvttDefaultStyleID] = &Style{ 216 | InlineStyle: sa, 217 | ID: webvttDefaultStyleID, 218 | } 219 | } 220 | 221 | // Time boundaries 222 | case strings.Contains(line, webvttTimeBoundariesSeparator): 223 | // Set block name 224 | blockName = webvttBlockNameText 225 | 226 | // Init new item 227 | item = &Item{ 228 | Comments: comments, 229 | Index: index, 230 | InlineStyle: &StyleAttributes{}, 231 | } 232 | 233 | // Reset index 234 | index = 0 235 | 236 | // Split line on time boundaries 237 | var left = strings.Split(line, webvttTimeBoundariesSeparator) 238 | 239 | // Split line on space to get remaining of time data 240 | var right = strings.Fields(left[1]) 241 | 242 | // Parse time boundaries 243 | if item.StartAt, err = parseDurationWebVTT(left[0]); err != nil { 244 | err = fmt.Errorf("astisub: line %d: parsing webvtt duration %s failed: %w", lineNum, left[0], err) 245 | return 246 | } 247 | if item.EndAt, err = parseDurationWebVTT(right[0]); err != nil { 248 | err = fmt.Errorf("astisub: line %d: parsing webvtt duration %s failed: %w", lineNum, right[0], err) 249 | return 250 | } 251 | 252 | // Parse style 253 | if len(right) > 1 { 254 | // Add styles 255 | for index := 1; index < len(right); index++ { 256 | // Empty 257 | if right[index] == "" { 258 | continue 259 | } 260 | 261 | // Split line on ":" 262 | var split = strings.Split(right[index], ":") 263 | if len(split) <= 1 { 264 | err = fmt.Errorf("astisub: line %d: Invalid inline style '%s'", lineNum, right[index]) 265 | return 266 | } 267 | 268 | // Switch on key 269 | switch split[0] { 270 | case "align": 271 | item.InlineStyle.WebVTTAlign = split[1] 272 | case "line": 273 | item.InlineStyle.WebVTTLine = split[1] 274 | case "position": 275 | item.InlineStyle.WebVTTPosition = split[1] 276 | case "region": 277 | if _, ok := o.Regions[split[1]]; !ok { 278 | err = fmt.Errorf("astisub: line %d: Unknown region %s", lineNum, split[1]) 279 | return 280 | } 281 | item.Region = o.Regions[split[1]] 282 | case "size": 283 | item.InlineStyle.WebVTTSize = split[1] 284 | case "vertical": 285 | item.InlineStyle.WebVTTVertical = split[1] 286 | } 287 | } 288 | } 289 | item.InlineStyle.propagateWebVTTAttributes() 290 | 291 | // Reset comments 292 | comments = []string{} 293 | 294 | // Append item 295 | o.Items = append(o.Items, item) 296 | 297 | case strings.HasPrefix(line, webvttTimestampMapHeader): 298 | if len(item.Lines) > 0 { 299 | err = errors.New("astisub: found timestamp map after processing subtitle items") 300 | return 301 | } 302 | 303 | var timestampMap *WebVTTTimestampMap 304 | timestampMap, err = parseWebVTTTimestampMap(line) 305 | if err != nil { 306 | err = fmt.Errorf("astisub: parsing webvtt timestamp map failed: %w", err) 307 | return 308 | } 309 | if o.Metadata == nil { 310 | o.Metadata = new(Metadata) 311 | } 312 | o.Metadata.WebVTTTimestampMap = timestampMap 313 | 314 | // Text 315 | default: 316 | // Switch on block name 317 | switch blockName { 318 | case webvttBlockNameComment: 319 | comments = append(comments, line) 320 | case webvttBlockNameStyle: 321 | sa.WebVTTStyles = append(sa.WebVTTStyles, line) 322 | case webvttBlockNameText: 323 | // Parse line 324 | if l := parseTextWebVTT(line, sa); len(l.Items) > 0 { 325 | item.Lines = append(item.Lines, l) 326 | } 327 | default: 328 | // This is the ID 329 | index, _ = strconv.Atoi(line) 330 | } 331 | } 332 | } 333 | return 334 | } 335 | 336 | // parseTextWebVTT parses the input line to fill the Line 337 | func parseTextWebVTT(i string, sa *StyleAttributes) (o Line) { 338 | // Create tokenizer 339 | tr := html.NewTokenizer(strings.NewReader(i)) 340 | 341 | // Loop 342 | for { 343 | // Get next tag 344 | t := tr.Next() 345 | // Process error 346 | if err := tr.Err(); err != nil { 347 | break 348 | } 349 | 350 | switch t { 351 | case html.EndTagToken: 352 | // Pop the top of stack if we meet end tag 353 | if len(sa.WebVTTTags) > 0 { 354 | sa.WebVTTTags = sa.WebVTTTags[:len(sa.WebVTTTags)-1] 355 | } 356 | case html.StartTagToken: 357 | if matches := webVTTRegexpTag.FindStringSubmatch(string(tr.Raw())); len(matches) > 4 { 358 | tagName := matches[2] 359 | 360 | var classes []string 361 | if matches[3] != "" { 362 | classes = strings.Split(strings.Trim(matches[3], "."), ".") 363 | } 364 | 365 | annotation := "" 366 | if matches[4] != "" { 367 | annotation = strings.TrimSpace(matches[4]) 368 | } 369 | 370 | if tagName == "v" { 371 | if o.VoiceName == "" { 372 | // Only get voicename of the first appears in the line 373 | o.VoiceName = annotation 374 | } else { 375 | // TODO: do something with other instead of ignoring 376 | log.Printf("astisub: found another voice name %q in %q. Ignore", annotation, i) 377 | } 378 | continue 379 | } 380 | 381 | // Push the tag to stack 382 | sa.WebVTTTags = append(sa.WebVTTTags, WebVTTTag{ 383 | Name: tagName, 384 | Classes: classes, 385 | Annotation: annotation, 386 | }) 387 | } 388 | 389 | case html.TextToken: 390 | // Get style attribute 391 | var styleAttributes *StyleAttributes 392 | if len(sa.WebVTTTags) > 0 { 393 | tags := make([]WebVTTTag, len(sa.WebVTTTags)) 394 | copy(tags, sa.WebVTTTags) 395 | styleAttributes = &StyleAttributes{ 396 | WebVTTTags: tags, 397 | } 398 | styleAttributes.propagateWebVTTAttributes() 399 | } 400 | 401 | // Append items 402 | o.Items = append(o.Items, parseTextWebVTTTextToken(styleAttributes, string(tr.Raw()))...) 403 | } 404 | } 405 | return 406 | } 407 | 408 | func parseTextWebVTTTextToken(sa *StyleAttributes, line string) (ret []LineItem) { 409 | // split the line by inline timestamps 410 | indexes := webVTTRegexpInlineTimestamp.FindAllStringSubmatchIndex(line, -1) 411 | 412 | if len(indexes) == 0 { 413 | return []LineItem{{ 414 | InlineStyle: sa, 415 | Text: unescapeHTML(line), 416 | }} 417 | } 418 | 419 | // get the text before the first timestamp 420 | if s := line[:indexes[0][0]]; strings.TrimSpace(s) != "" { 421 | ret = append(ret, LineItem{ 422 | InlineStyle: sa, 423 | Text: unescapeHTML(s), 424 | }) 425 | } 426 | 427 | for i, match := range indexes { 428 | // get the text between the timestamps 429 | endIndex := len(line) 430 | if i+1 < len(indexes) { 431 | endIndex = indexes[i+1][0] 432 | } 433 | s := line[match[1]:endIndex] 434 | if strings.TrimSpace(s) == "" { 435 | continue 436 | } 437 | 438 | // Parse timestamp 439 | t, err := parseDurationWebVTT(line[match[2]:match[3]]) 440 | if err != nil { 441 | log.Printf("astisub: parsing webvtt duration %s failed, ignoring: %v", line[match[2]:match[3]], err) 442 | } 443 | 444 | ret = append(ret, LineItem{ 445 | InlineStyle: sa, 446 | StartAt: t, 447 | Text: unescapeHTML(s), 448 | }) 449 | } 450 | 451 | return 452 | } 453 | 454 | // formatDurationWebVTT formats a .vtt duration 455 | func formatDurationWebVTT(i time.Duration) string { 456 | return formatDuration(i, ".", 3) 457 | } 458 | 459 | // WriteToWebVTT writes subtitles in .vtt format 460 | func (s Subtitles) WriteToWebVTT(o io.Writer) (err error) { 461 | // Do not write anything if no subtitles 462 | if len(s.Items) == 0 { 463 | err = ErrNoSubtitlesToWrite 464 | return 465 | } 466 | 467 | // Add header 468 | var c []byte 469 | c = append(c, []byte("WEBVTT")...) 470 | 471 | // Write X-TIMESTAMP-MAP if set 472 | if s.Metadata != nil { 473 | webVTTTimestampMap := s.Metadata.WebVTTTimestampMap 474 | if webVTTTimestampMap != nil { 475 | c = append(c, []byte("\n")...) 476 | c = append(c, []byte(webVTTTimestampMap.String())...) 477 | } 478 | } 479 | c = append(c, []byte("\n\n")...) 480 | 481 | var style []string 482 | for _, s := range s.Styles { 483 | if s.InlineStyle != nil { 484 | style = append(style, s.InlineStyle.WebVTTStyles...) 485 | } 486 | } 487 | 488 | if len(style) > 0 { 489 | c = append(c, []byte(fmt.Sprintf("STYLE\n%s\n\n", strings.Join(style, "\n")))...) 490 | } 491 | 492 | // Add regions 493 | var k []string 494 | for _, region := range s.Regions { 495 | k = append(k, region.ID) 496 | } 497 | 498 | sort.Strings(k) 499 | for _, id := range k { 500 | c = append(c, []byte("Region: id="+s.Regions[id].ID)...) 501 | if s.Regions[id].InlineStyle.WebVTTLines != 0 { 502 | c = append(c, bytesSpace...) 503 | c = append(c, []byte("lines="+strconv.Itoa(s.Regions[id].InlineStyle.WebVTTLines))...) 504 | } else if s.Regions[id].Style != nil && s.Regions[id].Style.InlineStyle != nil && s.Regions[id].Style.InlineStyle.WebVTTLines != 0 { 505 | c = append(c, bytesSpace...) 506 | c = append(c, []byte("lines="+strconv.Itoa(s.Regions[id].Style.InlineStyle.WebVTTLines))...) 507 | } 508 | if s.Regions[id].InlineStyle.WebVTTRegionAnchor != "" { 509 | c = append(c, bytesSpace...) 510 | c = append(c, []byte("regionanchor="+s.Regions[id].InlineStyle.WebVTTRegionAnchor)...) 511 | } else if s.Regions[id].Style != nil && s.Regions[id].Style.InlineStyle != nil && s.Regions[id].Style.InlineStyle.WebVTTRegionAnchor != "" { 512 | c = append(c, bytesSpace...) 513 | c = append(c, []byte("regionanchor="+s.Regions[id].Style.InlineStyle.WebVTTRegionAnchor)...) 514 | } 515 | if s.Regions[id].InlineStyle.WebVTTScroll != "" { 516 | c = append(c, bytesSpace...) 517 | c = append(c, []byte("scroll="+s.Regions[id].InlineStyle.WebVTTScroll)...) 518 | } else if s.Regions[id].Style != nil && s.Regions[id].Style.InlineStyle != nil && s.Regions[id].Style.InlineStyle.WebVTTScroll != "" { 519 | c = append(c, bytesSpace...) 520 | c = append(c, []byte("scroll="+s.Regions[id].Style.InlineStyle.WebVTTScroll)...) 521 | } 522 | if s.Regions[id].InlineStyle.WebVTTViewportAnchor != "" { 523 | c = append(c, bytesSpace...) 524 | c = append(c, []byte("viewportanchor="+s.Regions[id].InlineStyle.WebVTTViewportAnchor)...) 525 | } else if s.Regions[id].Style != nil && s.Regions[id].Style.InlineStyle != nil && s.Regions[id].Style.InlineStyle.WebVTTViewportAnchor != "" { 526 | c = append(c, bytesSpace...) 527 | c = append(c, []byte("viewportanchor="+s.Regions[id].Style.InlineStyle.WebVTTViewportAnchor)...) 528 | } 529 | if s.Regions[id].InlineStyle.WebVTTWidth != "" { 530 | c = append(c, bytesSpace...) 531 | c = append(c, []byte("width="+s.Regions[id].InlineStyle.WebVTTWidth)...) 532 | } else if s.Regions[id].Style != nil && s.Regions[id].Style.InlineStyle != nil && s.Regions[id].Style.InlineStyle.WebVTTWidth != "" { 533 | c = append(c, bytesSpace...) 534 | c = append(c, []byte("width="+s.Regions[id].Style.InlineStyle.WebVTTWidth)...) 535 | } 536 | c = append(c, bytesLineSeparator...) 537 | } 538 | if len(s.Regions) > 0 { 539 | c = append(c, bytesLineSeparator...) 540 | } 541 | 542 | // Loop through subtitles 543 | for index, item := range s.Items { 544 | // Add comments 545 | if len(item.Comments) > 0 { 546 | c = append(c, []byte("NOTE ")...) 547 | for _, comment := range item.Comments { 548 | c = append(c, []byte(comment)...) 549 | c = append(c, bytesLineSeparator...) 550 | } 551 | c = append(c, bytesLineSeparator...) 552 | } 553 | 554 | // Add time boundaries 555 | c = append(c, []byte(strconv.Itoa(index+1))...) 556 | c = append(c, bytesLineSeparator...) 557 | c = append(c, []byte(formatDurationWebVTT(item.StartAt))...) 558 | c = append(c, bytesWebVTTTimeBoundariesSeparator...) 559 | c = append(c, []byte(formatDurationWebVTT(item.EndAt))...) 560 | 561 | // Add styles 562 | if item.InlineStyle != nil { 563 | if item.InlineStyle.WebVTTAlign != "" { 564 | c = append(c, bytesSpace...) 565 | c = append(c, []byte("align:"+item.InlineStyle.WebVTTAlign)...) 566 | } else if item.Style != nil && item.Style.InlineStyle != nil && item.Style.InlineStyle.WebVTTAlign != "" { 567 | c = append(c, bytesSpace...) 568 | c = append(c, []byte("align:"+item.Style.InlineStyle.WebVTTAlign)...) 569 | } 570 | if item.InlineStyle.WebVTTLine != "" { 571 | c = append(c, bytesSpace...) 572 | c = append(c, []byte("line:"+item.InlineStyle.WebVTTLine)...) 573 | } else if item.Style != nil && item.Style.InlineStyle != nil && item.Style.InlineStyle.WebVTTLine != "" { 574 | c = append(c, bytesSpace...) 575 | c = append(c, []byte("line:"+item.Style.InlineStyle.WebVTTLine)...) 576 | } 577 | if item.InlineStyle.WebVTTPosition != "" { 578 | c = append(c, bytesSpace...) 579 | c = append(c, []byte("position:"+item.InlineStyle.WebVTTPosition)...) 580 | } else if item.Style != nil && item.Style.InlineStyle != nil && item.Style.InlineStyle.WebVTTPosition != "" { 581 | c = append(c, bytesSpace...) 582 | c = append(c, []byte("position:"+item.Style.InlineStyle.WebVTTPosition)...) 583 | } 584 | if item.Region != nil { 585 | c = append(c, bytesSpace...) 586 | c = append(c, []byte("region:"+item.Region.ID)...) 587 | } 588 | if item.InlineStyle.WebVTTSize != "" { 589 | c = append(c, bytesSpace...) 590 | c = append(c, []byte("size:"+item.InlineStyle.WebVTTSize)...) 591 | } else if item.Style != nil && item.Style.InlineStyle != nil && item.Style.InlineStyle.WebVTTSize != "" { 592 | c = append(c, bytesSpace...) 593 | c = append(c, []byte("size:"+item.Style.InlineStyle.WebVTTSize)...) 594 | } 595 | if item.InlineStyle.WebVTTVertical != "" { 596 | c = append(c, bytesSpace...) 597 | c = append(c, []byte("vertical:"+item.InlineStyle.WebVTTVertical)...) 598 | } else if item.Style != nil && item.Style.InlineStyle != nil && item.Style.InlineStyle.WebVTTVertical != "" { 599 | c = append(c, bytesSpace...) 600 | c = append(c, []byte("vertical:"+item.Style.InlineStyle.WebVTTVertical)...) 601 | } 602 | } 603 | 604 | // Add new line 605 | c = append(c, bytesLineSeparator...) 606 | 607 | // Loop through lines 608 | for _, l := range item.Lines { 609 | c = append(c, l.webVTTBytes()...) 610 | } 611 | 612 | // Add new line 613 | c = append(c, bytesLineSeparator...) 614 | } 615 | 616 | // Remove last new line 617 | c = c[:len(c)-1] 618 | 619 | // Write 620 | if _, err = o.Write(c); err != nil { 621 | err = fmt.Errorf("astisub: writing failed: %w", err) 622 | return 623 | } 624 | return 625 | } 626 | 627 | func (l Line) webVTTBytes() (c []byte) { 628 | if l.VoiceName != "" { 629 | c = append(c, []byte("")...) 630 | } 631 | for idx := 0; idx < len(l.Items); idx++ { 632 | var previous, next *LineItem 633 | if idx > 0 { 634 | previous = &l.Items[idx-1] 635 | } 636 | if idx < len(l.Items)-1 { 637 | next = &l.Items[idx+1] 638 | } 639 | c = append(c, l.Items[idx].webVTTBytes(previous, next)...) 640 | } 641 | c = append(c, bytesLineSeparator...) 642 | return 643 | } 644 | 645 | func (li LineItem) webVTTBytes(previous, next *LineItem) (c []byte) { 646 | // Add timestamp 647 | if li.StartAt > 0 { 648 | c = append(c, []byte("<"+formatDurationWebVTT(li.StartAt)+">")...) 649 | } 650 | 651 | // Get color 652 | var color string 653 | if li.InlineStyle != nil && li.InlineStyle.TTMLColor != nil { 654 | color = cssColor(*li.InlineStyle.TTMLColor) 655 | } 656 | 657 | // Append 658 | if color != "" { 659 | c = append(c, []byte("")...) 660 | } 661 | if li.InlineStyle != nil { 662 | for idx, tag := range li.InlineStyle.WebVTTTags { 663 | if previous != nil && previous.InlineStyle != nil && len(previous.InlineStyle.WebVTTTags) > idx && tag.Name == previous.InlineStyle.WebVTTTags[idx].Name { 664 | continue 665 | } 666 | c = append(c, []byte(tag.startTag())...) 667 | } 668 | } 669 | c = append(c, []byte(escapeHTML(li.Text))...) 670 | if li.InlineStyle != nil { 671 | for i := len(li.InlineStyle.WebVTTTags) - 1; i >= 0; i-- { 672 | tag := li.InlineStyle.WebVTTTags[i] 673 | if next != nil && next.InlineStyle != nil && len(next.InlineStyle.WebVTTTags) > i && tag.Name == next.InlineStyle.WebVTTTags[i].Name { 674 | continue 675 | } 676 | c = append(c, []byte(tag.endTag())...) 677 | } 678 | } 679 | if color != "" { 680 | c = append(c, []byte("")...) 681 | } 682 | return 683 | } 684 | 685 | func cssColor(rgb string) string { 686 | colors := map[string]string{ 687 | "#00ffff": "cyan", // narrator, thought 688 | "#ffff00": "yellow", // out of vision 689 | "#ff0000": "red", // noises 690 | "#ff00ff": "magenta", // song 691 | "#00ff00": "lime", // foreign speak 692 | } 693 | return colors[strings.ToLower(rgb)] // returning the empty string is ok 694 | } 695 | -------------------------------------------------------------------------------- /webvtt_internal_test.go: -------------------------------------------------------------------------------- 1 | package astisub 2 | 3 | import ( 4 | "strconv" 5 | "testing" 6 | "time" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestParseTextWebVTT(t *testing.T) { 13 | 14 | t.Run("When both voice tags are available", func(t *testing.T) { 15 | testData := `Correct tag` 16 | 17 | s := parseTextWebVTT(testData, &StyleAttributes{}) 18 | assert.Equal(t, "Bob", s.VoiceName) 19 | assert.Equal(t, 1, len(s.Items)) 20 | assert.Equal(t, "Correct tag", s.Items[0].Text) 21 | }) 22 | 23 | t.Run("When there is no end tag", func(t *testing.T) { 24 | testData := `Text without end tag` 25 | 26 | s := parseTextWebVTT(testData, &StyleAttributes{}) 27 | assert.Equal(t, "Bob", s.VoiceName) 28 | assert.Equal(t, 1, len(s.Items)) 29 | assert.Equal(t, "Text without end tag", s.Items[0].Text) 30 | }) 31 | 32 | t.Run("When the end tag is correct", func(t *testing.T) { 33 | testData := `Incorrect end tag` 34 | 35 | s := parseTextWebVTT(testData, &StyleAttributes{}) 36 | assert.Equal(t, "Bob", s.VoiceName) 37 | assert.Equal(t, 1, len(s.Items)) 38 | assert.Equal(t, "Incorrect end tag", s.Items[0].Text) 39 | }) 40 | 41 | t.Run("When inline timestamps are included", func(t *testing.T) { 42 | testData := `<00:01:01.000>With inline <00:01:02.000>timestamps` 43 | 44 | s := parseTextWebVTT(testData, &StyleAttributes{}) 45 | assert.Equal(t, 2, len(s.Items)) 46 | assert.Equal(t, "With inline ", s.Items[0].Text) 47 | assert.Equal(t, time.Minute+time.Second, s.Items[0].StartAt) 48 | assert.Equal(t, "timestamps", s.Items[1].Text) 49 | assert.Equal(t, time.Minute+2*time.Second, s.Items[1].StartAt) 50 | }) 51 | 52 | t.Run("When inline timestamps together", func(t *testing.T) { 53 | testData := `<00:01:01.000><00:01:02.000>With timestamp tags together` 54 | 55 | s := parseTextWebVTT(testData, &StyleAttributes{}) 56 | assert.Equal(t, 1, len(s.Items)) 57 | assert.Equal(t, "With timestamp tags together", s.Items[0].Text) 58 | assert.Equal(t, time.Minute+2*time.Second, s.Items[0].StartAt) 59 | }) 60 | 61 | t.Run("When inline timestamps is at end", func(t *testing.T) { 62 | testData := `With end timestamp<00:01:02.000>` 63 | 64 | s := parseTextWebVTT(testData, &StyleAttributes{}) 65 | assert.Equal(t, 1, len(s.Items)) 66 | assert.Equal(t, "With end timestamp", s.Items[0].Text) 67 | assert.Equal(t, time.Duration(0), s.Items[0].StartAt) 68 | }) 69 | } 70 | 71 | func TestTimestampMap(t *testing.T) { 72 | for i, c := range []struct { 73 | line string 74 | expectedOffset time.Duration 75 | expectError bool 76 | }{ 77 | { 78 | line: "X-TIMESTAMP-MAP=LOCAL:00:00:00.000,MPEGTS:180000", 79 | expectedOffset: 2 * time.Second, 80 | }, 81 | { 82 | line: "X-TIMESTAMP-MAP=LOCAL:00:00:00.500,MPEGTS:180000", 83 | expectedOffset: 1500 * time.Millisecond, 84 | }, 85 | { 86 | line: "X-TIMESTAMP-MAP=LOCAL:00:00:00.000,MPEGTS:135000", 87 | expectedOffset: 1500 * time.Millisecond, 88 | }, 89 | { 90 | line: "X-TIMESTAMP-MAP=LOCAL:00:00:00.000,MPEGTS:324090000", 91 | expectedOffset: time.Hour + time.Second, 92 | }, 93 | { 94 | line: "X-TIMESTAMP-MAP=MPEGTS:foo, LOCAL:00:00:00.000", 95 | expectError: true, 96 | }, 97 | { 98 | line: "X-TIMESTAMP-MAP=MPEGTS:180000,LOCAL:bar", 99 | expectError: true, 100 | }, 101 | { 102 | line: "X-TIMESTAMP-MAP=MPEGTS:180000,LOCAL", 103 | expectError: true, 104 | }, 105 | { 106 | line: "X-TIMESTAMP-MAP=MPEGTS,LOCAL:00:00:00.000", 107 | expectError: true, 108 | }, 109 | } { 110 | t.Run(strconv.Itoa(i), func(t *testing.T) { 111 | timestampMap, err := parseWebVTTTimestampMap(c.line) 112 | assert.Equal(t, c.expectedOffset, timestampMap.Offset()) 113 | if c.expectError { 114 | assert.Error(t, err) 115 | } else { 116 | assert.NoError(t, err) 117 | assert.Equal(t, c.line, timestampMap.String()) 118 | } 119 | }) 120 | } 121 | } 122 | 123 | func TestCueVoiceSpanRegex(t *testing.T) { 124 | tests := []struct { 125 | give string 126 | want string 127 | }{ 128 | { 129 | give: ` this is the content`, 130 | want: `中文`, 131 | }, 132 | { 133 | give: ` this is the content`, 134 | want: `中文`, 135 | }, 136 | { 137 | give: ` this is the content`, 138 | want: `中文`, 139 | }, 140 | { 141 | give: ` this is the content`, 142 | want: `言語の`, 143 | }, 144 | { 145 | give: ` this is the content`, 146 | want: `언어`, 147 | }, 148 | { 149 | give: ` this is the content`, 150 | want: `foo bar`, 151 | }, 152 | { 153 | give: ` this is the content`, 154 | want: `هذا عربي`, 155 | }, 156 | } 157 | 158 | for _, tt := range tests { 159 | t.Run(tt.want, func(t *testing.T) { 160 | results := webVTTRegexpTag.FindStringSubmatch(tt.give) 161 | assert.True(t, len(results) == 5) 162 | assert.Equal(t, tt.want, results[4]) 163 | }) 164 | } 165 | } 166 | 167 | func TestLineWebVTTBytes(t *testing.T) { 168 | require.Equal(t, "1 2 3\n", string(Line{Items: []LineItem{ 169 | { 170 | InlineStyle: &StyleAttributes{WebVTTTags: []WebVTTTag{ 171 | {Name: "t1"}, 172 | }}, 173 | Text: "1 ", 174 | }, 175 | { 176 | InlineStyle: &StyleAttributes{WebVTTTags: []WebVTTTag{ 177 | {Name: "t1"}, 178 | {Name: "t2"}, 179 | }}, 180 | Text: "2", 181 | }, 182 | { 183 | InlineStyle: &StyleAttributes{WebVTTTags: []WebVTTTag{ 184 | {Name: "t1"}, 185 | }}, 186 | Text: " 3", 187 | }, 188 | }}.webVTTBytes())) 189 | } 190 | -------------------------------------------------------------------------------- /webvtt_test.go: -------------------------------------------------------------------------------- 1 | package astisub_test 2 | 3 | import ( 4 | "bytes" 5 | "io/ioutil" 6 | "strings" 7 | "testing" 8 | "time" 9 | 10 | "github.com/asticode/go-astisub" 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | ) 14 | 15 | func TestWebVTT(t *testing.T) { 16 | // Open 17 | s, err := astisub.OpenFile("./testdata/example-in.vtt") 18 | assert.NoError(t, err) 19 | assertSubtitleItems(t, s) 20 | // Comments 21 | assert.Equal(t, []string{"this a nice example", "of a VTT"}, s.Items[0].Comments) 22 | assert.Equal(t, []string{"This a comment inside the VTT", "and this is the second line"}, s.Items[1].Comments) 23 | // Regions 24 | assert.Equal(t, 2, len(s.Regions)) 25 | 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"]) 26 | 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"]) 27 | assert.Equal(t, s.Regions["bill"], s.Items[0].Region) 28 | assert.Equal(t, s.Regions["fred"], s.Items[1].Region) 29 | // Styles 30 | assert.Equal(t, astisub.StyleAttributes{WebVTTAlign: "left", WebVTTPosition: "10%,start", WebVTTSize: "35%"}, *s.Items[1].InlineStyle) 31 | 32 | // No subtitles to write 33 | w := &bytes.Buffer{} 34 | err = astisub.Subtitles{}.WriteToWebVTT(w) 35 | assert.EqualError(t, err, astisub.ErrNoSubtitlesToWrite.Error()) 36 | 37 | // Write 38 | c, err := ioutil.ReadFile("./testdata/example-out.vtt") 39 | assert.NoError(t, err) 40 | err = s.WriteToWebVTT(w) 41 | assert.NoError(t, err) 42 | assert.Equal(t, string(c), w.String()) 43 | } 44 | 45 | func TestBroken1WebVTT(t *testing.T) { 46 | // Open bad, broken WebVTT file 47 | _, err := astisub.OpenFile("./testdata/broken-1-in.vtt") 48 | assert.Nil(t, err) 49 | } 50 | 51 | func TestNonUTF8WebVTT(t *testing.T) { 52 | _, err := astisub.OpenFile("./testdata/example-in-non-utf8.vtt") 53 | assert.Error(t, err) 54 | } 55 | 56 | func TestWebVTTWithVoiceName(t *testing.T) { 57 | testData := `WEBVTT 58 | 59 | NOTE this a example with voicename 60 | 61 | 1 62 | 00:02:34.000 --> 00:02:35.000 63 | I'm the fist speaker 64 | 65 | 2 66 | 00:02:34.000 --> 00:02:35.000 67 | I'm the second speaker 68 | 69 | 3 70 | 00:00:04.000 --> 00:00:08.000 71 | What are you doing here? 72 | 73 | 4 74 | 00:00:04.000 --> 00:00:08.000 75 | Incorrect tag?` 76 | 77 | s, err := astisub.ReadFromWebVTT(strings.NewReader(testData)) 78 | assert.NoError(t, err) 79 | 80 | assert.Len(t, s.Items, 4) 81 | assert.Equal(t, "Roger Bingham", s.Items[0].Lines[0].VoiceName) 82 | assert.Equal(t, "Bingham", s.Items[1].Lines[0].VoiceName) 83 | assert.Equal(t, "Lee", s.Items[2].Lines[0].VoiceName) 84 | assert.Equal(t, "Bob", s.Items[3].Lines[0].VoiceName) 85 | 86 | b := &bytes.Buffer{} 87 | err = s.WriteToWebVTT(b) 88 | assert.NoError(t, err) 89 | assert.Equal(t, `WEBVTT 90 | 91 | NOTE this a example with voicename 92 | 93 | 1 94 | 00:02:34.000 --> 00:02:35.000 95 | I'm the fist speaker 96 | 97 | 2 98 | 00:02:34.000 --> 00:02:35.000 99 | I'm the second speaker 100 | 101 | 3 102 | 00:00:04.000 --> 00:00:08.000 103 | What are you doing here? 104 | 105 | 4 106 | 00:00:04.000 --> 00:00:08.000 107 | Incorrect tag? 108 | `, b.String()) 109 | } 110 | 111 | func TestWebVTTWithTimestampMap(t *testing.T) { 112 | testData := `WEBVTT 113 | X-TIMESTAMP-MAP=MPEGTS:180000, LOCAL:00:00:00.000 114 | 115 | 00:00.933 --> 00:02.366 116 | ♪ ♪ 117 | 118 | 00:02.400 --> 00:03.633 119 | Evening.` 120 | 121 | s, err := astisub.ReadFromWebVTT(strings.NewReader(testData)) 122 | assert.NoError(t, err) 123 | 124 | assert.Len(t, s.Items, 2) 125 | 126 | assert.Equal(t, s.Items[0].StartAt.Milliseconds(), int64(933)) 127 | assert.Equal(t, s.Items[0].EndAt.Milliseconds(), int64(2366)) 128 | assert.Equal(t, s.Items[1].StartAt.Milliseconds(), int64(2400)) 129 | assert.Equal(t, s.Items[1].EndAt.Milliseconds(), int64(3633)) 130 | assert.Equal(t, s.Metadata.WebVTTTimestampMap.Offset(), time.Duration(time.Second*2)) 131 | 132 | b := &bytes.Buffer{} 133 | err = s.WriteToWebVTT(b) 134 | assert.NoError(t, err) 135 | assert.Equal(t, `WEBVTT 136 | X-TIMESTAMP-MAP=LOCAL:00:00:00.000,MPEGTS:180000 137 | 138 | 1 139 | 00:00:00.933 --> 00:00:02.366 140 | ♪ ♪ 141 | 142 | 2 143 | 00:00:02.400 --> 00:00:03.633 144 | Evening. 145 | `, b.String()) 146 | } 147 | 148 | func TestWebVTTTags(t *testing.T) { 149 | testData := `WEBVTT 150 | 151 | 00:01:00.000 --> 00:02:00.000 152 | Italic with underline text some extra 153 | 154 | 00:02:00.000 --> 00:03:00.000 155 | English here Yellow text on blue background 156 | 157 | 00:03:00.000 --> 00:04:00.000 158 | Joe's words are red in italic 159 | 160 | 00:04:00.000 --> 00:05:00.000 161 | Text here 162 | 163 | 00:05:00.000 --> 00:06:00.000 164 | Joe says something Bob says something 165 | 166 | 00:06:00.000 --> 00:07:00.000 167 | Text with a <00:06:30.000>timestamp in the middle 168 | 169 | 00:08:00.000 --> 00:09:00.000 170 | Test with multi line italics 171 | Terminated on the next line 172 | 173 | 00:09:00.000 --> 00:10:00.000 174 | Unterminated styles 175 | 176 | 00:10:00.000 --> 00:11:00.000 177 | Do no fall to the next item 178 | 179 | 00:12:00.000 --> 00:13:00.000 180 | x^3 * x = 100` 181 | 182 | s, err := astisub.ReadFromWebVTT(strings.NewReader(testData)) 183 | require.NoError(t, err) 184 | 185 | require.Len(t, s.Items, 10) 186 | 187 | b := &bytes.Buffer{} 188 | err = s.WriteToWebVTT(b) 189 | require.NoError(t, err) 190 | require.Equal(t, `WEBVTT 191 | 192 | 1 193 | 00:01:00.000 --> 00:02:00.000 194 | Italic with underline text some extra 195 | 196 | 2 197 | 00:02:00.000 --> 00:03:00.000 198 | English here Yellow text on blue background 199 | 200 | 3 201 | 00:03:00.000 --> 00:04:00.000 202 | Joe's words are red in italic 203 | 204 | 4 205 | 00:04:00.000 --> 00:05:00.000 206 | Text here 207 | 208 | 5 209 | 00:05:00.000 --> 00:06:00.000 210 | Joe says something Bob says something 211 | 212 | 6 213 | 00:06:00.000 --> 00:07:00.000 214 | Text with a <00:06:30.000>timestamp in the middle 215 | 216 | 7 217 | 00:08:00.000 --> 00:09:00.000 218 | Test with multi line italics 219 | Terminated on the next line 220 | 221 | 8 222 | 00:09:00.000 --> 00:10:00.000 223 | Unterminated styles 224 | 225 | 9 226 | 00:10:00.000 --> 00:11:00.000 227 | Do no fall to the next item 228 | 229 | 10 230 | 00:12:00.000 --> 00:13:00.000 231 | x^3 * x = 100 232 | `, b.String()) 233 | } 234 | 235 | func TestWebVTTParseDuration(t *testing.T) { 236 | testData := `WEBVTT 237 | 1 238 | 00:00:01.876-->00:0:03.390 239 | Duration without enclosing space 240 | 241 | 2 242 | 00:00:03.391-->00:00:06.567 align:middle 243 | Duration with tab spaced styles` 244 | 245 | s, err := astisub.ReadFromWebVTT(strings.NewReader(testData)) 246 | require.NoError(t, err) 247 | 248 | require.Len(t, s.Items, 2) 249 | assert.Equal(t, 1*time.Second+876*time.Millisecond, s.Items[0].StartAt) 250 | assert.Equal(t, 3*time.Second+390*time.Millisecond, s.Items[0].EndAt) 251 | assert.Equal(t, "Duration without enclosing space", s.Items[0].Lines[0].String()) 252 | assert.Equal(t, 3*time.Second+391*time.Millisecond, s.Items[1].StartAt) 253 | assert.Equal(t, 6*time.Second+567*time.Millisecond, s.Items[1].EndAt) 254 | assert.Equal(t, "Duration with tab spaced styles", s.Items[1].Lines[0].String()) 255 | assert.NotNil(t, s.Items[1].InlineStyle) 256 | assert.Equal(t, s.Items[1].InlineStyle.WebVTTAlign, "middle") 257 | } 258 | --------------------------------------------------------------------------------