Caption support (#2462)

Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
This commit is contained in:
cj
2022-05-05 20:59:28 -05:00
committed by GitHub
parent ab1b30ffb7
commit c1a096a1a6
114 changed files with 16899 additions and 17 deletions

5
vendor/github.com/asticode/go-astisub/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,5 @@
.DS_Store
Thumbs.db
.idea/
cover*
test

14
vendor/github.com/asticode/go-astisub/.travis.yml generated vendored Normal file
View File

@@ -0,0 +1,14 @@
language: go
go:
- 1.x
- tip
install:
- go get -t ./...
- go get golang.org/x/tools/cmd/cover
- go get github.com/mattn/goveralls
matrix:
allow_failures:
- go: tip
script:
- go test -race -v -coverprofile=coverage.out
- $HOME/gopath/bin/goveralls -coverprofile=coverage.out -service=travis-ci

21
vendor/github.com/asticode/go-astisub/LICENSE generated vendored Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2016 Quentin Renard
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

95
vendor/github.com/asticode/go-astisub/README.md generated vendored Normal file
View File

@@ -0,0 +1,95 @@
[![GoReportCard](http://goreportcard.com/badge/github.com/asticode/go-astisub)](http://goreportcard.com/report/github.com/asticode/go-astisub)
[![GoDoc](https://godoc.org/github.com/asticode/go-astisub?status.svg)](https://godoc.org/github.com/asticode/go-astisub)
[![Travis](https://travis-ci.com/asticode/go-astisub.svg?branch=master)](https://travis-ci.com/asticode/go-astisub#)
[![Coveralls](https://coveralls.io/repos/github/asticode/go-astisub/badge.svg?branch=master)](https://coveralls.io/github/asticode/go-astisub)
This is a Golang library to manipulate subtitles.
It allows you to manipulate `srt`, `stl`, `ttml`, `ssa/ass`, `webvtt` and `teletext` files for now.
Available operations are `parsing`, `writing`, `syncing`, `fragmenting`, `unfragmenting`, `merging` and `optimizing`.
# Installation
To install the library:
go get github.com/asticode/go-astisub
To install the CLI:
go install github.com/asticode/go-astisub/astisub
# Using the library in your code
WARNING: the code below doesn't handle errors for readibility purposes. However you SHOULD!
```go
// Open subtitles
s1, _ := astisub.OpenFile("/path/to/example.ttml")
s2, _ := astisub.ReadFromSRT(bytes.NewReader([]byte("00:01:00.000 --> 00:02:00.000\nCredits")))
// Add a duration to every subtitles (syncing)
s1.Add(-2*time.Second)
// Fragment the subtitles
s1.Fragment(2*time.Second)
// Merge subtitles
s1.Merge(s2)
// Optimize subtitles
s1.Optimize()
// Unfragment the subtitles
s1.Unfragment()
// Write subtitles
s1.Write("/path/to/example.srt")
var buf = &bytes.Buffer{}
s2.WriteToTTML(buf)
```
# Using the CLI
If **astisub** has been installed properly you can:
- convert any type of subtitle to any other type of subtitle:
astisub convert -i example.srt -o example.ttml
- fragment any type of subtitle:
astisub fragment -i example.srt -f 2s -o example.out.srt
- merge any type of subtitle into any other type of subtitle:
astisub merge -i example.srt -i example.ttml -o example.out.srt
- optimize any type of subtitle:
astisub optimize -i example.srt -o example.out.srt
- unfragment any type of subtitle:
astisub unfragment -i example.srt -o example.out.srt
- sync any type of subtitle:
astisub sync -i example.srt -s "-2s" -o example.out.srt
# Features and roadmap
- [x] parsing
- [x] writing
- [x] syncing
- [x] fragmenting/unfragmenting
- [x] merging
- [x] ordering
- [x] optimizing
- [x] .srt
- [x] .ttml
- [x] .vtt
- [x] .stl
- [x] .ssa/.ass
- [x] .teletext
- [ ] .smi

10
vendor/github.com/asticode/go-astisub/language.go generated vendored Normal file
View File

@@ -0,0 +1,10 @@
package astisub
// Languages
const (
LanguageChinese = "chinese"
LanguageEnglish = "english"
LanguageFrench = "french"
LanguageJapanese = "japanese"
LanguageNorwegian = "norwegian"
)

159
vendor/github.com/asticode/go-astisub/srt.go generated vendored Normal file
View File

@@ -0,0 +1,159 @@
package astisub
import (
"bufio"
"fmt"
"io"
"strconv"
"strings"
"time"
)
// Constants
const (
srtTimeBoundariesSeparator = " --> "
)
// Vars
var (
bytesSRTTimeBoundariesSeparator = []byte(srtTimeBoundariesSeparator)
)
// parseDurationSRT parses an .srt duration
func parseDurationSRT(i string) (time.Duration, error) {
return parseDuration(i, ",", 3)
}
// ReadFromSRT parses an .srt content
func ReadFromSRT(i io.Reader) (o *Subtitles, err error) {
// Init
o = NewSubtitles()
var scanner = bufio.NewScanner(i)
// Scan
var line string
var lineNum int
var s = &Item{}
for scanner.Scan() {
// Fetch line
line = strings.TrimSpace(scanner.Text())
lineNum++
// Remove BOM header
if lineNum == 1 {
line = strings.TrimPrefix(line, string(BytesBOM))
}
// Line contains time boundaries
if strings.Contains(line, srtTimeBoundariesSeparator) {
// Return the wrong number of rows
if len(s.Lines) == 0 {
err = fmt.Errorf("astisub: line %d: no lines", lineNum)
return
}
// Remove last item of previous subtitle since it's the index
index := s.Lines[len(s.Lines)-1]
s.Lines = s.Lines[:len(s.Lines)-1]
// Remove trailing empty lines
if len(s.Lines) > 0 {
for i := len(s.Lines) - 1; i >= 0; i-- {
if len(s.Lines[i].Items) > 0 {
for j := len(s.Lines[i].Items) - 1; j >= 0; j-- {
if len(s.Lines[i].Items[j].Text) == 0 {
s.Lines[i].Items = s.Lines[i].Items[:j]
} else {
break
}
}
if len(s.Lines[i].Items) == 0 {
s.Lines = s.Lines[:i]
}
}
}
}
// Init subtitle
s = &Item{}
// Fetch Index
s.Index, _ = strconv.Atoi(index.String())
// Extract time boundaries
s1 := strings.Split(line, srtTimeBoundariesSeparator)
if l := len(s1); l < 2 {
err = fmt.Errorf("astisub: line %d: time boundaries has only %d element(s)", lineNum, l)
return
}
// We do this to eliminate extra stuff like positions which are not documented anywhere
s2 := strings.Split(s1[1], " ")
// Parse time boundaries
if s.StartAt, err = parseDurationSRT(s1[0]); err != nil {
err = fmt.Errorf("astisub: line %d: parsing srt duration %s failed: %w", lineNum, s1[0], err)
return
}
if s.EndAt, err = parseDurationSRT(s2[0]); err != nil {
err = fmt.Errorf("astisub: line %d: parsing srt duration %s failed: %w", lineNum, s2[0], err)
return
}
// Append subtitle
o.Items = append(o.Items, s)
} else {
// Add text
s.Lines = append(s.Lines, Line{Items: []LineItem{{Text: strings.TrimSpace(line)}}})
}
}
return
}
// formatDurationSRT formats an .srt duration
func formatDurationSRT(i time.Duration) string {
return formatDuration(i, ",", 3)
}
// WriteToSRT writes subtitles in .srt format
func (s Subtitles) WriteToSRT(o io.Writer) (err error) {
// Do not write anything if no subtitles
if len(s.Items) == 0 {
err = ErrNoSubtitlesToWrite
return
}
// Add BOM header
var c []byte
c = append(c, BytesBOM...)
// Loop through subtitles
for k, v := range s.Items {
// Add time boundaries
c = append(c, []byte(strconv.Itoa(k+1))...)
c = append(c, bytesLineSeparator...)
c = append(c, []byte(formatDurationSRT(v.StartAt))...)
c = append(c, bytesSRTTimeBoundariesSeparator...)
c = append(c, []byte(formatDurationSRT(v.EndAt))...)
c = append(c, bytesLineSeparator...)
// Loop through lines
for _, l := range v.Lines {
c = append(c, []byte(l.String())...)
c = append(c, bytesLineSeparator...)
}
// Add new line
c = append(c, bytesLineSeparator...)
}
// Remove last new line
c = c[:len(c)-1]
// Write
if _, err = o.Write(c); err != nil {
err = fmt.Errorf("astisub: writing failed: %w", err)
return
}
return
}

1297
vendor/github.com/asticode/go-astisub/ssa.go generated vendored Normal file

File diff suppressed because it is too large Load Diff

1085
vendor/github.com/asticode/go-astisub/stl.go generated vendored Normal file

File diff suppressed because it is too large Load Diff

779
vendor/github.com/asticode/go-astisub/subtitles.go generated vendored Normal file
View File

@@ -0,0 +1,779 @@
package astisub
import (
"errors"
"fmt"
"math"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/asticode/go-astikit"
)
// Bytes
var (
BytesBOM = []byte{239, 187, 191}
bytesLineSeparator = []byte("\n")
bytesSpace = []byte(" ")
)
// Colors
var (
ColorBlack = &Color{}
ColorBlue = &Color{Blue: 255}
ColorCyan = &Color{Blue: 255, Green: 255}
ColorGray = &Color{Blue: 128, Green: 128, Red: 128}
ColorGreen = &Color{Green: 128}
ColorLime = &Color{Green: 255}
ColorMagenta = &Color{Blue: 255, Red: 255}
ColorMaroon = &Color{Red: 128}
ColorNavy = &Color{Blue: 128}
ColorOlive = &Color{Green: 128, Red: 128}
ColorPurple = &Color{Blue: 128, Red: 128}
ColorRed = &Color{Red: 255}
ColorSilver = &Color{Blue: 192, Green: 192, Red: 192}
ColorTeal = &Color{Blue: 128, Green: 128}
ColorYellow = &Color{Green: 255, Red: 255}
ColorWhite = &Color{Blue: 255, Green: 255, Red: 255}
)
// Errors
var (
ErrInvalidExtension = errors.New("astisub: invalid extension")
ErrNoSubtitlesToWrite = errors.New("astisub: no subtitles to write")
)
// Now allows testing functions using it
var Now = func() time.Time {
return time.Now()
}
// Options represents open or write options
type Options struct {
Filename string
Teletext TeletextOptions
STL STLOptions
}
// Open opens a subtitle reader based on options
func Open(o Options) (s *Subtitles, err error) {
// Open the file
var f *os.File
if f, err = os.Open(o.Filename); err != nil {
err = fmt.Errorf("astisub: opening %s failed: %w", o.Filename, err)
return
}
defer f.Close()
// Parse the content
switch filepath.Ext(strings.ToLower(o.Filename)) {
case ".srt":
s, err = ReadFromSRT(f)
case ".ssa", ".ass":
s, err = ReadFromSSA(f)
case ".stl":
s, err = ReadFromSTL(f, o.STL)
case ".ts":
s, err = ReadFromTeletext(f, o.Teletext)
case ".ttml":
s, err = ReadFromTTML(f)
case ".vtt":
s, err = ReadFromWebVTT(f)
default:
err = ErrInvalidExtension
}
return
}
// OpenFile opens a file regardless of other options
func OpenFile(filename string) (*Subtitles, error) {
return Open(Options{Filename: filename})
}
// Subtitles represents an ordered list of items with formatting
type Subtitles struct {
Items []*Item
Metadata *Metadata
Regions map[string]*Region
Styles map[string]*Style
}
// NewSubtitles creates new subtitles
func NewSubtitles() *Subtitles {
return &Subtitles{
Regions: make(map[string]*Region),
Styles: make(map[string]*Style),
}
}
// Item represents a text to show between 2 time boundaries with formatting
type Item struct {
Comments []string
Index int
EndAt time.Duration
InlineStyle *StyleAttributes
Lines []Line
Region *Region
StartAt time.Duration
Style *Style
}
// String implements the Stringer interface
func (i Item) String() string {
var os []string
for _, l := range i.Lines {
os = append(os, l.String())
}
return strings.Join(os, " - ")
}
// Color represents a color
type Color struct {
Alpha, Blue, Green, Red uint8
}
// newColorFromSSAString builds a new color based on an SSA string
func newColorFromSSAString(s string, base int) (c *Color, err error) {
var i int64
if i, err = strconv.ParseInt(s, base, 64); err != nil {
err = fmt.Errorf("parsing int %s with base %d failed: %w", s, base, err)
return
}
c = &Color{
Alpha: uint8(i>>24) & 0xff,
Blue: uint8(i>>16) & 0xff,
Green: uint8(i>>8) & 0xff,
Red: uint8(i) & 0xff,
}
return
}
// SSAString expresses the color as an SSA string
func (c *Color) SSAString() string {
return fmt.Sprintf("%.8x", uint32(c.Alpha)<<24|uint32(c.Blue)<<16|uint32(c.Green)<<8|uint32(c.Red))
}
// TTMLString expresses the color as a TTML string
func (c *Color) TTMLString() string {
return fmt.Sprintf("%.6x", uint32(c.Red)<<16|uint32(c.Green)<<8|uint32(c.Blue))
}
type Justification int
var (
JustificationUnchanged = Justification(1)
JustificationLeft = Justification(2)
JustificationCentered = Justification(3)
JustificationRight = Justification(4)
)
// StyleAttributes represents style attributes
type StyleAttributes struct {
SSAAlignment *int
SSAAlphaLevel *float64
SSAAngle *float64 // degrees
SSABackColour *Color
SSABold *bool
SSABorderStyle *int
SSAEffect string
SSAEncoding *int
SSAFontName string
SSAFontSize *float64
SSAItalic *bool
SSALayer *int
SSAMarginLeft *int // pixels
SSAMarginRight *int // pixels
SSAMarginVertical *int // pixels
SSAMarked *bool
SSAOutline *float64 // pixels
SSAOutlineColour *Color
SSAPrimaryColour *Color
SSAScaleX *float64 // %
SSAScaleY *float64 // %
SSASecondaryColour *Color
SSAShadow *float64 // pixels
SSASpacing *float64 // pixels
SSAStrikeout *bool
SSAUnderline *bool
STLBoxing *bool
STLItalics *bool
STLJustification *Justification
STLPosition *STLPosition
STLUnderline *bool
TeletextColor *Color
TeletextDoubleHeight *bool
TeletextDoubleSize *bool
TeletextDoubleWidth *bool
TeletextSpacesAfter *int
TeletextSpacesBefore *int
// TODO Use pointers with real types below
TTMLBackgroundColor *string // https://htmlcolorcodes.com/fr/
TTMLColor *string
TTMLDirection *string
TTMLDisplay *string
TTMLDisplayAlign *string
TTMLExtent *string
TTMLFontFamily *string
TTMLFontSize *string
TTMLFontStyle *string
TTMLFontWeight *string
TTMLLineHeight *string
TTMLOpacity *string
TTMLOrigin *string
TTMLOverflow *string
TTMLPadding *string
TTMLShowBackground *string
TTMLTextAlign *string
TTMLTextDecoration *string
TTMLTextOutline *string
TTMLUnicodeBidi *string
TTMLVisibility *string
TTMLWrapOption *string
TTMLWritingMode *string
TTMLZIndex *int
WebVTTAlign string
WebVTTItalics bool
WebVTTLine string
WebVTTLines int
WebVTTPosition string
WebVTTRegionAnchor string
WebVTTScroll string
WebVTTSize string
WebVTTVertical string
WebVTTViewportAnchor string
WebVTTWidth string
}
func (sa *StyleAttributes) propagateSSAAttributes() {}
func (sa *StyleAttributes) propagateSTLAttributes() {
if sa.STLJustification != nil {
switch *sa.STLJustification {
case JustificationCentered:
// default to middle anyway?
case JustificationRight:
sa.WebVTTAlign = "right"
case JustificationLeft:
sa.WebVTTAlign = "left"
}
}
}
func (sa *StyleAttributes) propagateTeletextAttributes() {
if sa.TeletextColor != nil {
sa.TTMLColor = astikit.StrPtr("#" + sa.TeletextColor.TTMLString())
}
}
//reference for migration: https://w3c.github.io/ttml-webvtt-mapping/
func (sa *StyleAttributes) propagateTTMLAttributes() {
if sa.TTMLTextAlign != nil {
sa.WebVTTAlign = *sa.TTMLTextAlign
}
if sa.TTMLExtent != nil {
//region settings
lineHeight := 5 //assuming height of line as 5.33vh
dimensions := strings.Split(*sa.TTMLExtent, " ")
if len(dimensions) > 1 {
sa.WebVTTWidth = dimensions[0]
if height, err := strconv.Atoi(strings.ReplaceAll(dimensions[1], "%", "")); err == nil {
sa.WebVTTLines = height / lineHeight
}
//cue settings
//default TTML WritingMode is lrtb i.e. left to right, top to bottom
sa.WebVTTSize = dimensions[1]
if sa.TTMLWritingMode != nil && strings.HasPrefix(*sa.TTMLWritingMode, "tb") {
sa.WebVTTSize = dimensions[0]
}
}
}
if sa.TTMLOrigin != nil {
//region settings
sa.WebVTTRegionAnchor = "0%,0%"
sa.WebVTTViewportAnchor = strings.ReplaceAll(strings.TrimSpace(*sa.TTMLOrigin), " ", ",")
sa.WebVTTScroll = "up"
//cue settings
coordinates := strings.Split(*sa.TTMLOrigin, " ")
if len(coordinates) > 1 {
sa.WebVTTLine = coordinates[0]
sa.WebVTTPosition = coordinates[1]
if sa.TTMLWritingMode != nil && strings.HasPrefix(*sa.TTMLWritingMode, "tb") {
sa.WebVTTLine = coordinates[1]
sa.WebVTTPosition = coordinates[0]
}
}
}
}
func (sa *StyleAttributes) propagateWebVTTAttributes() {}
// Metadata represents metadata
// TODO Merge attributes
type Metadata struct {
Comments []string
Framerate int
Language string
SSACollisions string
SSAOriginalEditing string
SSAOriginalScript string
SSAOriginalTiming string
SSAOriginalTranslation string
SSAPlayDepth *int
SSAPlayResX, SSAPlayResY *int
SSAScriptType string
SSAScriptUpdatedBy string
SSASynchPoint string
SSATimer *float64
SSAUpdateDetails string
SSAWrapStyle string
STLCountryOfOrigin string
STLCreationDate *time.Time
STLDisplayStandardCode string
STLMaximumNumberOfDisplayableCharactersInAnyTextRow *int
STLMaximumNumberOfDisplayableRows *int
STLPublisher string
STLRevisionDate *time.Time
STLSubtitleListReferenceCode string
STLTimecodeStartOfProgramme time.Duration
Title string
TTMLCopyright string
}
// Region represents a subtitle's region
type Region struct {
ID string
InlineStyle *StyleAttributes
Style *Style
}
// Style represents a subtitle's style
type Style struct {
ID string
InlineStyle *StyleAttributes
Style *Style
}
// Line represents a set of formatted line items
type Line struct {
Items []LineItem
VoiceName string
}
// String implement the Stringer interface
func (l Line) String() string {
var texts []string
for _, i := range l.Items {
texts = append(texts, i.Text)
}
return strings.Join(texts, " ")
}
// LineItem represents a formatted line item
type LineItem struct {
InlineStyle *StyleAttributes
Style *Style
Text string
}
// Add adds a duration to each time boundaries. As in the time package, duration can be negative.
func (s *Subtitles) Add(d time.Duration) {
for idx := 0; idx < len(s.Items); idx++ {
s.Items[idx].EndAt += d
s.Items[idx].StartAt += d
if s.Items[idx].EndAt <= 0 && s.Items[idx].StartAt <= 0 {
s.Items = append(s.Items[:idx], s.Items[idx+1:]...)
idx--
} else if s.Items[idx].StartAt <= 0 {
s.Items[idx].StartAt = time.Duration(0)
}
}
}
// Duration returns the subtitles duration
func (s Subtitles) Duration() time.Duration {
if len(s.Items) == 0 {
return time.Duration(0)
}
return s.Items[len(s.Items)-1].EndAt
}
// ForceDuration updates the subtitles duration.
// If requested duration is bigger, then we create a dummy item.
// If requested duration is smaller, then we remove useless items and we cut the last item or add a dummy item.
func (s *Subtitles) ForceDuration(d time.Duration, addDummyItem bool) {
// Requested duration is the same as the subtitles'one
if s.Duration() == d {
return
}
// Requested duration is bigger than subtitles'one
if s.Duration() > d {
// Find last item before input duration and update end at
var lastIndex = -1
for index, i := range s.Items {
// Start at is bigger than input duration, we've found the last item
if i.StartAt >= d {
lastIndex = index
break
} else if i.EndAt > d {
s.Items[index].EndAt = d
}
}
// Last index has been found
if lastIndex != -1 {
s.Items = s.Items[:lastIndex]
}
}
// Add dummy item with the minimum duration possible
if addDummyItem && s.Duration() < d {
s.Items = append(s.Items, &Item{EndAt: d, Lines: []Line{{Items: []LineItem{{Text: "..."}}}}, StartAt: d - time.Millisecond})
}
}
// Fragment fragments subtitles with a specific fragment duration
func (s *Subtitles) Fragment(f time.Duration) {
// Nothing to fragment
if len(s.Items) == 0 {
return
}
// Here we want to simulate fragments of duration f until there are no subtitles left in that period of time
var fragmentStartAt, fragmentEndAt = time.Duration(0), f
for fragmentStartAt < s.Items[len(s.Items)-1].EndAt {
// We loop through subtitles and process the ones that either contain the fragment start at,
// or contain the fragment end at
//
// It's useless processing subtitles contained between fragment start at and end at
// |____________________| <- subtitle
// | |
// fragment start at fragment end at
for i, sub := range s.Items {
// Init
var newSub = &Item{}
*newSub = *sub
// A switch is more readable here
switch {
// Subtitle contains fragment start at
// |____________________| <- subtitle
// | |
// fragment start at fragment end at
case sub.StartAt < fragmentStartAt && sub.EndAt > fragmentStartAt:
sub.StartAt = fragmentStartAt
newSub.EndAt = fragmentStartAt
// Subtitle contains fragment end at
// |____________________| <- subtitle
// | |
// fragment start at fragment end at
case sub.StartAt < fragmentEndAt && sub.EndAt > fragmentEndAt:
sub.StartAt = fragmentEndAt
newSub.EndAt = fragmentEndAt
default:
continue
}
// Insert new sub
s.Items = append(s.Items[:i], append([]*Item{newSub}, s.Items[i:]...)...)
}
// Update fragments boundaries
fragmentStartAt += f
fragmentEndAt += f
}
// Order
s.Order()
}
// IsEmpty returns whether the subtitles are empty
func (s Subtitles) IsEmpty() bool {
return len(s.Items) == 0
}
// Merge merges subtitles i into subtitles
func (s *Subtitles) Merge(i *Subtitles) {
// Append items
s.Items = append(s.Items, i.Items...)
s.Order()
// Add regions
for _, region := range i.Regions {
if _, ok := s.Regions[region.ID]; !ok {
s.Regions[region.ID] = region
}
}
// Add styles
for _, style := range i.Styles {
if _, ok := s.Styles[style.ID]; !ok {
s.Styles[style.ID] = style
}
}
}
// Optimize optimizes subtitles
func (s *Subtitles) Optimize() {
// Nothing to optimize
if len(s.Items) == 0 {
return
}
// Remove unused regions and style
s.removeUnusedRegionsAndStyles()
}
// removeUnusedRegionsAndStyles removes unused regions and styles
func (s *Subtitles) removeUnusedRegionsAndStyles() {
// Loop through items
var usedRegions, usedStyles = make(map[string]bool), make(map[string]bool)
for _, item := range s.Items {
// Add region
if item.Region != nil {
usedRegions[item.Region.ID] = true
}
// Add style
if item.Style != nil {
usedStyles[item.Style.ID] = true
}
// Loop through lines
for _, line := range item.Lines {
// Loop through line items
for _, lineItem := range line.Items {
// Add style
if lineItem.Style != nil {
usedStyles[lineItem.Style.ID] = true
}
}
}
}
// Loop through regions
for id, region := range s.Regions {
if _, ok := usedRegions[region.ID]; ok {
if region.Style != nil {
usedStyles[region.Style.ID] = true
}
} else {
delete(s.Regions, id)
}
}
// Loop through style
for id, style := range s.Styles {
if _, ok := usedStyles[style.ID]; !ok {
delete(s.Styles, id)
}
}
}
// Order orders items
func (s *Subtitles) Order() {
// Nothing to do if less than 1 element
if len(s.Items) <= 1 {
return
}
// Order
var swapped = true
for swapped {
swapped = false
for index := 1; index < len(s.Items); index++ {
if s.Items[index-1].StartAt > s.Items[index].StartAt {
var tmp = s.Items[index-1]
s.Items[index-1] = s.Items[index]
s.Items[index] = tmp
swapped = true
}
}
}
}
// RemoveStyling removes the styling from the subtitles
func (s *Subtitles) RemoveStyling() {
s.Regions = map[string]*Region{}
s.Styles = map[string]*Style{}
for _, i := range s.Items {
i.Region = nil
i.Style = nil
i.InlineStyle = nil
for idxLine, l := range i.Lines {
for idxLineItem := range l.Items {
i.Lines[idxLine].Items[idxLineItem].InlineStyle = nil
i.Lines[idxLine].Items[idxLineItem].Style = nil
}
}
}
}
// Unfragment unfragments subtitles
func (s *Subtitles) Unfragment() {
// Nothing to do if less than 1 element
if len(s.Items) <= 1 {
return
}
// Order
s.Order()
// Loop through items
for i := 0; i < len(s.Items)-1; i++ {
for j := i + 1; j < len(s.Items); j++ {
// Items are the same
if s.Items[i].String() == s.Items[j].String() && s.Items[i].EndAt >= s.Items[j].StartAt {
// Only override end time if longer
if s.Items[i].EndAt < s.Items[j].EndAt {
s.Items[i].EndAt = s.Items[j].EndAt
}
s.Items = append(s.Items[:j], s.Items[j+1:]...)
j--
} else if s.Items[i].EndAt < s.Items[j].StartAt {
break
}
}
}
}
// Write writes subtitles to a file
func (s Subtitles) Write(dst string) (err error) {
// Create the file
var f *os.File
if f, err = os.Create(dst); err != nil {
err = fmt.Errorf("astisub: creating %s failed: %w", dst, err)
return
}
defer f.Close()
// Write the content
switch filepath.Ext(strings.ToLower(dst)) {
case ".srt":
err = s.WriteToSRT(f)
case ".ssa", ".ass":
err = s.WriteToSSA(f)
case ".stl":
err = s.WriteToSTL(f)
case ".ttml":
err = s.WriteToTTML(f)
case ".vtt":
err = s.WriteToWebVTT(f)
default:
err = ErrInvalidExtension
}
return
}
// parseDuration parses a duration in "00:00:00.000", "00:00:00,000" or "0:00:00:00" format
func parseDuration(i, millisecondSep string, numberOfMillisecondDigits int) (o time.Duration, err error) {
// Split milliseconds
var parts = strings.Split(i, millisecondSep)
var milliseconds int
var s string
if len(parts) >= 2 {
// Invalid number of millisecond digits
s = strings.TrimSpace(parts[len(parts)-1])
if len(s) > 3 {
err = fmt.Errorf("astisub: Invalid number of millisecond digits detected in %s", i)
return
}
// Parse milliseconds
if milliseconds, err = strconv.Atoi(s); err != nil {
err = fmt.Errorf("astisub: atoi of %s failed: %w", s, err)
return
}
milliseconds *= int(math.Pow10(numberOfMillisecondDigits - len(s)))
s = strings.Join(parts[:len(parts)-1], millisecondSep)
} else {
s = i
}
// Split hours, minutes and seconds
parts = strings.Split(strings.TrimSpace(s), ":")
var partSeconds, partMinutes, partHours string
if len(parts) == 2 {
partSeconds = parts[1]
partMinutes = parts[0]
} else if len(parts) == 3 {
partSeconds = parts[2]
partMinutes = parts[1]
partHours = parts[0]
} else {
err = fmt.Errorf("astisub: No hours, minutes or seconds detected in %s", i)
return
}
// Parse seconds
var seconds int
s = strings.TrimSpace(partSeconds)
if seconds, err = strconv.Atoi(s); err != nil {
err = fmt.Errorf("astisub: atoi of %s failed: %w", s, err)
return
}
// Parse minutes
var minutes int
s = strings.TrimSpace(partMinutes)
if minutes, err = strconv.Atoi(s); err != nil {
err = fmt.Errorf("astisub: atoi of %s failed: %w", s, err)
return
}
// Parse hours
var hours int
if len(partHours) > 0 {
s = strings.TrimSpace(partHours)
if hours, err = strconv.Atoi(s); err != nil {
err = fmt.Errorf("astisub: atoi of %s failed: %w", s, err)
return
}
}
// Generate output
o = time.Duration(milliseconds)*time.Millisecond + time.Duration(seconds)*time.Second + time.Duration(minutes)*time.Minute + time.Duration(hours)*time.Hour
return
}
// formatDuration formats a duration
func formatDuration(i time.Duration, millisecondSep string, numberOfMillisecondDigits int) (s string) {
// Parse hours
var hours = int(i / time.Hour)
var n = i % time.Hour
if hours < 10 {
s += "0"
}
s += strconv.Itoa(hours) + ":"
// Parse minutes
var minutes = int(n / time.Minute)
n = i % time.Minute
if minutes < 10 {
s += "0"
}
s += strconv.Itoa(minutes) + ":"
// Parse seconds
var seconds = int(n / time.Second)
n = i % time.Second
if seconds < 10 {
s += "0"
}
s += strconv.Itoa(seconds) + millisecondSep
// Parse milliseconds
var milliseconds = float64(n/time.Millisecond) / float64(1000)
s += fmt.Sprintf("%."+strconv.Itoa(numberOfMillisecondDigits)+"f", milliseconds)[2:]
return
}
// appendStringToBytesWithNewLine adds a string to bytes then adds a new line
func appendStringToBytesWithNewLine(i []byte, s string) (o []byte) {
o = append(i, []byte(s)...)
o = append(o, bytesLineSeparator...)
return
}

997
vendor/github.com/asticode/go-astisub/teletext.go generated vendored Normal file
View File

@@ -0,0 +1,997 @@
package astisub
import (
"context"
"errors"
"fmt"
"io"
"log"
"math/bits"
"sort"
"strings"
"time"
"github.com/asticode/go-astikit"
"github.com/asticode/go-astits"
)
// Errors
var (
ErrNoValidTeletextPID = errors.New("astisub: no valid teletext PID")
)
type teletextCharset [96][]byte
type teletextNationalSubset [13][]byte
// Chapter: 15.2 | Page: 109 | Link: http://www.etsi.org/deliver/etsi_i_ets/300700_300799/300706/01_60/ets_300706e01p.pdf
// It is indexed by triplet1 then by national option subset code
var teletextCharsets = map[uint8]map[uint8]struct {
g0 *teletextCharset
g2 *teletextCharset
national *teletextNationalSubset
}{
0: {
0: {g0: teletextCharsetG0Latin, g2: teletextCharsetG2Latin, national: teletextNationalSubsetEnglish},
1: {g0: teletextCharsetG0Latin, g2: teletextCharsetG2Latin, national: teletextNationalSubsetFrench},
2: {g0: teletextCharsetG0Latin, g2: teletextCharsetG2Latin, national: teletextNationalSubsetSwedishFinnishHungarian},
3: {g0: teletextCharsetG0Latin, g2: teletextCharsetG2Latin, national: teletextNationalSubsetCzechSlovak},
4: {g0: teletextCharsetG0Latin, g2: teletextCharsetG2Latin, national: teletextNationalSubsetGerman},
5: {g0: teletextCharsetG0Latin, g2: teletextCharsetG2Latin, national: teletextNationalSubsetPortugueseSpanish},
6: {g0: teletextCharsetG0Latin, g2: teletextCharsetG2Latin, national: teletextNationalSubsetItalian},
7: {g0: teletextCharsetG0Latin, g2: teletextCharsetG2Latin},
},
1: {
0: {g0: teletextCharsetG0Latin, g2: teletextCharsetG2Latin, national: teletextNationalSubsetPolish},
1: {g0: teletextCharsetG0Latin, g2: teletextCharsetG2Latin, national: teletextNationalSubsetFrench},
2: {g0: teletextCharsetG0Latin, g2: teletextCharsetG2Latin, national: teletextNationalSubsetSwedishFinnishHungarian},
3: {g0: teletextCharsetG0Latin, g2: teletextCharsetG2Latin, national: teletextNationalSubsetCzechSlovak},
4: {g0: teletextCharsetG0Latin, g2: teletextCharsetG2Latin, national: teletextNationalSubsetGerman},
5: {g0: teletextCharsetG0Latin, g2: teletextCharsetG2Latin},
6: {g0: teletextCharsetG0Latin, g2: teletextCharsetG2Latin, national: teletextNationalSubsetItalian},
7: {g0: teletextCharsetG0Latin, g2: teletextCharsetG2Latin},
},
2: {
0: {g0: teletextCharsetG0Latin, g2: teletextCharsetG2Latin, national: teletextNationalSubsetEnglish},
1: {g0: teletextCharsetG0Latin, g2: teletextCharsetG2Latin, national: teletextNationalSubsetFrench},
2: {g0: teletextCharsetG0Latin, g2: teletextCharsetG2Latin, national: teletextNationalSubsetSwedishFinnishHungarian},
3: {g0: teletextCharsetG0Latin, g2: teletextCharsetG2Latin, national: teletextNationalSubsetCzechSlovak},
4: {g0: teletextCharsetG0Latin, g2: teletextCharsetG2Latin, national: teletextNationalSubsetGerman},
5: {g0: teletextCharsetG0Latin, g2: teletextCharsetG2Latin, national: teletextNationalSubsetPortugueseSpanish},
6: {g0: teletextCharsetG0Latin, g2: teletextCharsetG2Latin, national: teletextNationalSubsetItalian},
7: {g0: teletextCharsetG0Latin, g2: teletextCharsetG2Latin},
},
3: {
0: {g0: teletextCharsetG0Latin, g2: teletextCharsetG2Latin},
1: {g0: teletextCharsetG0Latin, g2: teletextCharsetG2Latin},
2: {g0: teletextCharsetG0Latin, g2: teletextCharsetG2Latin},
3: {g0: teletextCharsetG0Latin, g2: teletextCharsetG2Latin},
4: {g0: teletextCharsetG0Latin, g2: teletextCharsetG2Latin},
5: {g0: teletextCharsetG0Latin, g2: teletextCharsetG2Latin, national: teletextNationalSubsetSerbianCroatianSlovenian},
6: {g0: teletextCharsetG0Latin, g2: teletextCharsetG2Latin},
7: {g0: teletextCharsetG0Latin, g2: teletextCharsetG2Latin, national: teletextNationalSubsetRomanian},
},
4: {
0: {g0: teletextCharsetG0CyrillicOption1, g2: teletextCharsetG2Cyrillic},
1: {g0: teletextCharsetG0CyrillicOption2, g2: teletextCharsetG2Cyrillic},
2: {g0: teletextCharsetG0Latin, g2: teletextCharsetG2Latin, national: teletextNationalSubsetEstonian},
3: {g0: teletextCharsetG0Latin, g2: teletextCharsetG2Latin, national: teletextNationalSubsetCzechSlovak},
4: {g0: teletextCharsetG0Latin, g2: teletextCharsetG2Latin, national: teletextNationalSubsetGerman},
5: {g0: teletextCharsetG0CyrillicOption3, g2: teletextCharsetG2Cyrillic},
6: {g0: teletextCharsetG0Latin, g2: teletextCharsetG2Latin, national: teletextNationalSubsetLettishLithuanian},
},
6: {
3: {g0: teletextCharsetG0Latin, g2: teletextCharsetG2Latin, national: teletextNationalSubsetTurkish},
7: {g0: teletextCharsetG0Greek, g2: teletextCharsetG2Greek},
},
8: {
0: {g0: teletextCharsetG0Latin, g2: teletextCharsetG2Arabic, national: teletextNationalSubsetEnglish},
1: {g0: teletextCharsetG0Latin, g2: teletextCharsetG2Arabic, national: teletextNationalSubsetFrench},
7: {g0: teletextCharsetG0Arabic, g2: teletextCharsetG2Arabic},
},
10: {
5: {g0: teletextCharsetG0Hebrew, g2: teletextCharsetG2Arabic},
7: {g0: teletextCharsetG0Arabic, g2: teletextCharsetG2Arabic},
},
}
// Teletext G0 charsets
var (
teletextCharsetG0CyrillicOption1 = &teletextCharset{
[]byte{0x20}, []byte{0x21}, []byte{0x22}, []byte{0x23}, []byte{0x24}, []byte{0x25}, []byte{0xd1, 0x8b},
[]byte{0x27}, []byte{0x28}, []byte{0x29}, []byte{0x2a}, []byte{0x2b}, []byte{0x2c}, []byte{0x2d},
[]byte{0x2e}, []byte{0x2f}, []byte{0x30}, []byte{0x31}, []byte{0xe3, 0x88, 0x80}, []byte{0x33}, []byte{0x34},
[]byte{0x35}, []byte{0x36}, []byte{0x37}, []byte{0x38}, []byte{0x39}, []byte{0x3a}, []byte{0x3b},
[]byte{0x3c}, []byte{0x3d}, []byte{0x3e}, []byte{0x3f}, []byte{0xd0, 0xa7}, []byte{0xd0, 0x90},
[]byte{0xd0, 0x91}, []byte{0xd0, 0xa6}, []byte{0xd0, 0x94}, []byte{0xd0, 0x95}, []byte{0xd0, 0xa4},
[]byte{0xd0, 0x93}, []byte{0xd0, 0xa5}, []byte{0xd0, 0x98}, []byte{0xd0, 0x88}, []byte{0xd0, 0x9a},
[]byte{0xd0, 0x9b}, []byte{0xd0, 0x9c}, []byte{0xd0, 0x9d}, []byte{0xd0, 0x9e}, []byte{0xd0, 0x9f},
[]byte{0xd0, 0x8c}, []byte{0xd0, 0xa0}, []byte{0xd0, 0xa1}, []byte{0xd0, 0xa2}, []byte{0xd0, 0xa3},
[]byte{0xd0, 0x92}, []byte{0xd0, 0x83}, []byte{0xd0, 0x89}, []byte{0xd0, 0x8a}, []byte{0xd0, 0x97},
[]byte{0xd0, 0x8b}, []byte{0xd0, 0x96}, []byte{0xd0, 0x82}, []byte{0xd0, 0xa8}, []byte{0xd0, 0x8f},
[]byte{0xd1, 0x87}, []byte{0xd0, 0xb0}, []byte{0xd0, 0xb1}, []byte{0xd1, 0x86}, []byte{0xd0, 0xb4},
[]byte{0xd0, 0xb5}, []byte{0xd1, 0x84}, []byte{0xd0, 0xb3}, []byte{0xd1, 0x85}, []byte{0xd0, 0xb8},
[]byte{0xd0, 0xa8}, []byte{0xd0, 0xba}, []byte{0xd0, 0xbb}, []byte{0xd0, 0xbc}, []byte{0xd0, 0xbd},
[]byte{0xd0, 0xbe}, []byte{0xd0, 0xbf}, []byte{0xd0, 0xac}, []byte{0xd1, 0x80}, []byte{0xd1, 0x81},
[]byte{0xd1, 0x82}, []byte{0xd1, 0x83}, []byte{0xd0, 0xb2}, []byte{0xd0, 0xa3}, []byte{0xd0, 0xa9},
[]byte{0xd0, 0xaa}, []byte{0xd0, 0xb7}, []byte{0xd0, 0xab}, []byte{0xd0, 0xb6}, []byte{0xd0, 0xa2},
[]byte{0xd1, 0x88}, []byte{0xd0, 0xaf},
}
teletextCharsetG0CyrillicOption2 = &teletextCharset{
[]byte{0x20}, []byte{0x21}, []byte{0x22}, []byte{0x23}, []byte{0x24}, []byte{0x25}, []byte{0xd1, 0x8b},
[]byte{0x27}, []byte{0x28}, []byte{0x29}, []byte{0x2a}, []byte{0x2b}, []byte{0x2c}, []byte{0x2d},
[]byte{0x2e}, []byte{0x2f}, []byte{0x30}, []byte{0x31}, []byte{0x32}, []byte{0x33}, []byte{0x34},
[]byte{0x35}, []byte{0x36}, []byte{0x37}, []byte{0x38}, []byte{0x39}, []byte{0x3a}, []byte{0x3b},
[]byte{0x3c}, []byte{0x3d}, []byte{0x3e}, []byte{0x3f}, []byte{0xd0, 0xae}, []byte{0xd0, 0x90},
[]byte{0xd0, 0x91}, []byte{0xd0, 0xa6}, []byte{0xd0, 0x94}, []byte{0xd0, 0x95}, []byte{0xd0, 0xa4},
[]byte{0xd0, 0x93}, []byte{0xd0, 0xa5}, []byte{0xd0, 0x98}, []byte{0xd0, 0x99}, []byte{0xd0, 0x9a},
[]byte{0xd0, 0x9b}, []byte{0xd0, 0x9c}, []byte{0xd0, 0x9d}, []byte{0xd0, 0x9e}, []byte{0xd0, 0x9f},
[]byte{0xd0, 0xaf}, []byte{0xd0, 0xa0}, []byte{0xd0, 0xa1}, []byte{0xd0, 0xa2}, []byte{0xd0, 0xa3},
[]byte{0xd0, 0x96}, []byte{0xd0, 0x92}, []byte{0xd0, 0xac}, []byte{0xd0, 0xaa}, []byte{0xd0, 0x97},
[]byte{0xd0, 0xa8}, []byte{0xd0, 0xad}, []byte{0xd0, 0xa9}, []byte{0xd0, 0xa7}, []byte{0xd0, 0xab},
[]byte{0xd1, 0x8e}, []byte{0xd0, 0xb0}, []byte{0xd0, 0xb1}, []byte{0xd1, 0x86}, []byte{0xd0, 0xb4},
[]byte{0xd0, 0xb5}, []byte{0xd1, 0x84}, []byte{0xd0, 0xb3}, []byte{0xd1, 0x85}, []byte{0xd0, 0xb8},
[]byte{0xd0, 0xb9}, []byte{0xd0, 0xba}, []byte{0xd0, 0xbb}, []byte{0xd0, 0xbc}, []byte{0xd0, 0xbd},
[]byte{0xd0, 0xbe}, []byte{0xd0, 0xbf}, []byte{0xd1, 0x8f}, []byte{0xd1, 0x80}, []byte{0xd1, 0x81},
[]byte{0xd1, 0x82}, []byte{0xd1, 0x83}, []byte{0xd0, 0xb6}, []byte{0xd0, 0xb2}, []byte{0xd1, 0x8c},
[]byte{0xd1, 0x8a}, []byte{0xd0, 0xb7}, []byte{0xd1, 0x88}, []byte{0xd1, 0x8d}, []byte{0xd1, 0x89},
[]byte{0xd1, 0x87}, []byte{0xd1, 0x8b},
}
teletextCharsetG0CyrillicOption3 = &teletextCharset{
[]byte{0x20}, []byte{0x21}, []byte{0x22}, []byte{0x23}, []byte{0x24}, []byte{0x25}, []byte{0xc3, 0xaf},
[]byte{0x27}, []byte{0x28}, []byte{0x29}, []byte{0x2a}, []byte{0x2b}, []byte{0x2c}, []byte{0x2d},
[]byte{0x2e}, []byte{0x2f}, []byte{0x30}, []byte{0x31}, []byte{0x32}, []byte{0x33}, []byte{0x34},
[]byte{0x35}, []byte{0x36}, []byte{0x37}, []byte{0x38}, []byte{0x39}, []byte{0x3a}, []byte{0x3b},
[]byte{0x3c}, []byte{0x3d}, []byte{0x3e}, []byte{0x3f}, []byte{0xd0, 0xae}, []byte{0xd0, 0x90},
[]byte{0xd0, 0x91}, []byte{0xd0, 0xa6}, []byte{0xd0, 0x94}, []byte{0xd0, 0x95}, []byte{0xd0, 0xa4},
[]byte{0xd0, 0x93}, []byte{0xd0, 0xa5}, []byte{0xd0, 0x98}, []byte{0xd0, 0x99}, []byte{0xd0, 0x9a},
[]byte{0xd0, 0x9b}, []byte{0xd0, 0x9c}, []byte{0xd0, 0x9d}, []byte{0xd0, 0x9e}, []byte{0xd0, 0x9f},
[]byte{0xd0, 0xaf}, []byte{0xd0, 0xa0}, []byte{0xd0, 0xa1}, []byte{0xd0, 0xa2}, []byte{0xd0, 0xa3},
[]byte{0xd0, 0x96}, []byte{0xd0, 0x92}, []byte{0xd0, 0xac}, []byte{0x49}, []byte{0xd0, 0x97},
[]byte{0xd0, 0xa8}, []byte{0xd0, 0xad}, []byte{0xd0, 0xa9}, []byte{0xd0, 0xa7}, []byte{0xc3, 0x8f},
[]byte{0xd1, 0x8e}, []byte{0xd0, 0xb0}, []byte{0xd0, 0xb1}, []byte{0xd1, 0x86}, []byte{0xd0, 0xb4},
[]byte{0xd0, 0xb5}, []byte{0xd1, 0x84}, []byte{0xd0, 0xb3}, []byte{0xd1, 0x85}, []byte{0xd0, 0xb8},
[]byte{0xd0, 0xb9}, []byte{0xd0, 0xba}, []byte{0xd0, 0xbb}, []byte{0xd0, 0xbc}, []byte{0xd0, 0xbd},
[]byte{0xd0, 0xbe}, []byte{0xd0, 0xbf}, []byte{0xd1, 0x8f}, []byte{0xd1, 0x80}, []byte{0xd1, 0x81},
[]byte{0xd1, 0x82}, []byte{0xd1, 0x83}, []byte{0xd0, 0xb6}, []byte{0xd0, 0xb2}, []byte{0xd1, 0x8c},
[]byte{0x69}, []byte{0xd0, 0xb7}, []byte{0xd1, 0x88}, []byte{0xd1, 0x8d}, []byte{0xd1, 0x89},
[]byte{0xd1, 0x87}, []byte{0xc3, 0xbf},
}
teletextCharsetG0Greek = &teletextCharset{
[]byte{0x20}, []byte{0x21}, []byte{0x22}, []byte{0x23}, []byte{0x24}, []byte{0x25}, []byte{0x26},
[]byte{0x27}, []byte{0x28}, []byte{0x29}, []byte{0x2a}, []byte{0x2b}, []byte{0x2c}, []byte{0x2d},
[]byte{0x2e}, []byte{0x2f}, []byte{0x30}, []byte{0x31}, []byte{0x32}, []byte{0x33}, []byte{0x34},
[]byte{0x35}, []byte{0x36}, []byte{0x37}, []byte{0x38}, []byte{0x39}, []byte{0x3a}, []byte{0x3b},
[]byte{0x3c}, []byte{0x3d}, []byte{0x3e}, []byte{0x3f}, []byte{0xce, 0x90}, []byte{0xce, 0x91},
[]byte{0xce, 0x92}, []byte{0xce, 0x93}, []byte{0xce, 0x94}, []byte{0xce, 0x95}, []byte{0xce, 0x96},
[]byte{0xce, 0x97}, []byte{0xce, 0x98}, []byte{0xce, 0x99}, []byte{0xce, 0x9a}, []byte{0xce, 0x9b},
[]byte{0xce, 0x9c}, []byte{0xce, 0x9d}, []byte{0xce, 0x9e}, []byte{0xce, 0x9f}, []byte{0xce, 0xa0},
[]byte{0xce, 0xa1}, []byte{0xce, 0xa2}, []byte{0xce, 0xa3}, []byte{0xce, 0xa4}, []byte{0xce, 0xa5},
[]byte{0xce, 0xa6}, []byte{0xce, 0xa7}, []byte{0xce, 0xa8}, []byte{0xce, 0xa9}, []byte{0xce, 0xaa},
[]byte{0xce, 0xab}, []byte{0xce, 0xac}, []byte{0xce, 0xad}, []byte{0xce, 0xae}, []byte{0xce, 0xaf},
[]byte{0xce, 0xb0}, []byte{0xce, 0xb1}, []byte{0xce, 0xb2}, []byte{0xce, 0xb3}, []byte{0xce, 0xb4},
[]byte{0xce, 0xb5}, []byte{0xce, 0xb6}, []byte{0xce, 0xb7}, []byte{0xce, 0xb8}, []byte{0xce, 0xb9},
[]byte{0xce, 0xba}, []byte{0xce, 0xbb}, []byte{0xce, 0xbc}, []byte{0xce, 0xbd}, []byte{0xce, 0xbe},
[]byte{0xce, 0xbf}, []byte{0xcf, 0x80}, []byte{0xcf, 0x81}, []byte{0xcf, 0x82}, []byte{0xcf, 0x83},
[]byte{0xcf, 0x84}, []byte{0xcf, 0x85}, []byte{0xcf, 0x86}, []byte{0xcf, 0x87}, []byte{0xcf, 0x88},
[]byte{0xcf, 0x89}, []byte{0xcf, 0x8a}, []byte{0xcf, 0x8b}, []byte{0xcf, 0x8c}, []byte{0xcf, 0x8d},
[]byte{0xcf, 0x8e}, []byte{0xcf, 0x8f},
}
teletextCharsetG0Latin = &teletextCharset{
[]byte{0x20}, []byte{0x21}, []byte{0x22}, []byte{0xc2, 0xa3}, []byte{0x24}, []byte{0x25}, []byte{0x26},
[]byte{0x27}, []byte{0x28}, []byte{0x29}, []byte{0x2a}, []byte{0x2b}, []byte{0x2c}, []byte{0x2d},
[]byte{0x2e}, []byte{0x2f}, []byte{0x30}, []byte{0x31}, []byte{0x32}, []byte{0x33}, []byte{0x34},
[]byte{0x35}, []byte{0x36}, []byte{0x37}, []byte{0x38}, []byte{0x39}, []byte{0x3a}, []byte{0x3b},
[]byte{0x3c}, []byte{0x3d}, []byte{0x3e}, []byte{0x3f}, []byte{0x40}, []byte{0x41}, []byte{0x42},
[]byte{0x43}, []byte{0x44}, []byte{0x45}, []byte{0x46}, []byte{0x47}, []byte{0x48}, []byte{0x49},
[]byte{0x4a}, []byte{0x4b}, []byte{0x4c}, []byte{0x4d}, []byte{0x4e}, []byte{0x4f}, []byte{0x50},
[]byte{0x51}, []byte{0x52}, []byte{0x53}, []byte{0x54}, []byte{0x55}, []byte{0x56}, []byte{0x57},
[]byte{0x58}, []byte{0x59}, []byte{0x5a}, []byte{0xc2, 0xab}, []byte{0xc2, 0xbd}, []byte{0xc2, 0xbb},
[]byte{0x5e}, []byte{0x23}, []byte{0x2d}, []byte{0x61}, []byte{0x62}, []byte{0x63}, []byte{0x64},
[]byte{0x65}, []byte{0x66}, []byte{0x67}, []byte{0x68}, []byte{0x69}, []byte{0x6a}, []byte{0x6b},
[]byte{0x6c}, []byte{0x6d}, []byte{0x6e}, []byte{0x6f}, []byte{0x70}, []byte{0x71}, []byte{0x72},
[]byte{0x73}, []byte{0x74}, []byte{0x75}, []byte{0x76}, []byte{0x77}, []byte{0x78}, []byte{0x79},
[]byte{0x7a}, []byte{0xc2, 0xbc}, []byte{0xc2, 0xa6}, []byte{0xc2, 0xbe}, []byte{0xc3, 0xb7}, []byte{0x7f},
}
// TODO Add
teletextCharsetG0Arabic = teletextCharsetG0Latin
teletextCharsetG0Hebrew = teletextCharsetG0Latin
)
// Teletext G2 charsets
var (
teletextCharsetG2Latin = &teletextCharset{
[]byte{0x20}, []byte{0xc2, 0xa1}, []byte{0xc2, 0xa2}, []byte{0xc2, 0xa3}, []byte{0x24},
[]byte{0xc2, 0xa5}, []byte{0x23}, []byte{0xc2, 0xa7}, []byte{0xc2, 0xa4}, []byte{0xe2, 0x80, 0x98},
[]byte{0xe2, 0x80, 0x9c}, []byte{0xc2, 0xab}, []byte{0xe2, 0x86, 0x90}, []byte{0xe2, 0x86, 0x91},
[]byte{0xe2, 0x86, 0x92}, []byte{0xe2, 0x86, 0x93}, []byte{0xc2, 0xb0}, []byte{0xc2, 0xb1},
[]byte{0xc2, 0xb2}, []byte{0xc2, 0xb3}, []byte{0xc3, 0x97}, []byte{0xc2, 0xb5}, []byte{0xc2, 0xb6},
[]byte{0xc2, 0xb7}, []byte{0xc3, 0xb7}, []byte{0xe2, 0x80, 0x99}, []byte{0xe2, 0x80, 0x9d},
[]byte{0xc2, 0xbb}, []byte{0xc2, 0xbc}, []byte{0xc2, 0xbd}, []byte{0xc2, 0xbe}, []byte{0xc2, 0xbf},
[]byte{0x20}, []byte{0xcc, 0x80}, []byte{0xcc, 0x81}, []byte{0xcc, 0x82}, []byte{0xcc, 0x83},
[]byte{0xcc, 0x84}, []byte{0xcc, 0x86}, []byte{0xcc, 0x87}, []byte{0xcc, 0x88}, []byte{0x00},
[]byte{0xcc, 0x8a}, []byte{0xcc, 0xa7}, []byte{0x5f}, []byte{0xcc, 0x8b}, []byte{0xcc, 0xa8},
[]byte{0xcc, 0x8c}, []byte{0xe2, 0x80, 0x95}, []byte{0xc2, 0xb9}, []byte{0xc2, 0xae}, []byte{0xc2, 0xa9},
[]byte{0xe2, 0x84, 0xa2}, []byte{0xe2, 0x99, 0xaa}, []byte{0xe2, 0x82, 0xac}, []byte{0xe2, 0x80, 0xb0},
[]byte{0xce, 0xb1}, []byte{0x00}, []byte{0x00}, []byte{0x00}, []byte{0xe2, 0x85, 0x9b},
[]byte{0xe2, 0x85, 0x9c}, []byte{0xe2, 0x85, 0x9d}, []byte{0xe2, 0x85, 0x9e}, []byte{0xce, 0xa9},
[]byte{0xc3, 0x86}, []byte{0xc4, 0x90}, []byte{0xc2, 0xaa}, []byte{0xc4, 0xa6}, []byte{0x00},
[]byte{0xc4, 0xb2}, []byte{0xc4, 0xbf}, []byte{0xc5, 0x81}, []byte{0xc3, 0x98}, []byte{0xc5, 0x92},
[]byte{0xc2, 0xba}, []byte{0xc3, 0x9e}, []byte{0xc5, 0xa6}, []byte{0xc5, 0x8a}, []byte{0xc5, 0x89},
[]byte{0xc4, 0xb8}, []byte{0xc3, 0xa6}, []byte{0xc4, 0x91}, []byte{0xc3, 0xb0}, []byte{0xc4, 0xa7},
[]byte{0xc4, 0xb1}, []byte{0xc4, 0xb3}, []byte{0xc5, 0x80}, []byte{0xc5, 0x82}, []byte{0xc3, 0xb8},
[]byte{0xc5, 0x93}, []byte{0xc3, 0x9f}, []byte{0xc3, 0xbe}, []byte{0xc5, 0xa7}, []byte{0xc5, 0x8b},
[]byte{0x20},
}
// TODO Add
teletextCharsetG2Arabic = teletextCharsetG2Latin
teletextCharsetG2Cyrillic = teletextCharsetG2Latin
teletextCharsetG2Greek = teletextCharsetG2Latin
)
var teletextNationalSubsetCharactersPositionInG0 = [13]uint8{0x03, 0x04, 0x20, 0x3b, 0x3c, 0x3d, 0x3e, 0x3f, 0x40, 0x5b, 0x5c, 0x5d, 0x5e}
// Teletext national subsets
var (
teletextNationalSubsetCzechSlovak = &teletextNationalSubset{
[]byte{0x23}, []byte{0xc5, 0xaf}, []byte{0xc4, 0x8d}, []byte{0xc5, 0xa5}, []byte{0xc5, 0xbe},
[]byte{0xc3, 0xbd}, []byte{0xc3, 0xad}, []byte{0xc5, 0x99}, []byte{0xc3, 0xa9}, []byte{0xc3, 0xa1},
[]byte{0xc4, 0x9b}, []byte{0xc3, 0xba}, []byte{0xc5, 0xa1},
}
teletextNationalSubsetEnglish = &teletextNationalSubset{
[]byte{0xc2, 0xa3}, []byte{0x24}, []byte{0x40}, []byte{0xc2, 0xab}, []byte{0xc2, 0xbd}, []byte{0xc2, 0xbb},
[]byte{0x5e}, []byte{0x23}, []byte{0x2d}, []byte{0xc2, 0xbc}, []byte{0xc2, 0xa6}, []byte{0xc2, 0xbe},
[]byte{0xc3, 0xb7},
}
teletextNationalSubsetEstonian = &teletextNationalSubset{
[]byte{0x23}, []byte{0xc3, 0xb5}, []byte{0xc5, 0xa0}, []byte{0xc3, 0x84}, []byte{0xc3, 0x96},
[]byte{0xc5, 0xbe}, []byte{0xc3, 0x9c}, []byte{0xc3, 0x95}, []byte{0xc5, 0xa1}, []byte{0xc3, 0xa4},
[]byte{0xc3, 0xb6}, []byte{0xc5, 0xbe}, []byte{0xc3, 0xbc},
}
teletextNationalSubsetFrench = &teletextNationalSubset{
[]byte{0xc3, 0xa9}, []byte{0xc3, 0xaf}, []byte{0xc3, 0xa0}, []byte{0xc3, 0xab}, []byte{0xc3, 0xaa},
[]byte{0xc3, 0xb9}, []byte{0xc3, 0xae}, []byte{0x23}, []byte{0xc3, 0xa8}, []byte{0xc3, 0xa2},
[]byte{0xc3, 0xb4}, []byte{0xc3, 0xbb}, []byte{0xc3, 0xa7},
}
teletextNationalSubsetGerman = &teletextNationalSubset{
[]byte{0x23}, []byte{0x24}, []byte{0xc2, 0xa7}, []byte{0xc3, 0x84}, []byte{0xc3, 0x96}, []byte{0xc3, 0x9c},
[]byte{0x5e}, []byte{0x5f}, []byte{0xc2, 0xb0}, []byte{0xc3, 0xa4}, []byte{0xc3, 0xb6}, []byte{0xc3, 0xbc},
[]byte{0xc3, 0x9f},
}
teletextNationalSubsetItalian = &teletextNationalSubset{
[]byte{0xc2, 0xa3}, []byte{0x24}, []byte{0xc3, 0xa9}, []byte{0xc2, 0xb0}, []byte{0xc3, 0xa7},
[]byte{0xc2, 0xbb}, []byte{0x5e}, []byte{0x23}, []byte{0xc3, 0xb9}, []byte{0xc3, 0xa0}, []byte{0xc3, 0xb2},
[]byte{0xc3, 0xa8}, []byte{0xc3, 0xac},
}
teletextNationalSubsetLettishLithuanian = &teletextNationalSubset{
[]byte{0x23}, []byte{0x24}, []byte{0xc5, 0xa0}, []byte{0xc4, 0x97}, []byte{0xc4, 0x99}, []byte{0xc5, 0xbd},
[]byte{0xc4, 0x8d}, []byte{0xc5, 0xab}, []byte{0xc5, 0xa1}, []byte{0xc4, 0x85}, []byte{0xc5, 0xb3},
[]byte{0xc5, 0xbe}, []byte{0xc4, 0xaf},
}
teletextNationalSubsetPolish = &teletextNationalSubset{
[]byte{0x23}, []byte{0xc5, 0x84}, []byte{0xc4, 0x85}, []byte{0xc5, 0xbb}, []byte{0xc5, 0x9a},
[]byte{0xc5, 0x81}, []byte{0xc4, 0x87}, []byte{0xc3, 0xb3}, []byte{0xc4, 0x99}, []byte{0xc5, 0xbc},
[]byte{0xc5, 0x9b}, []byte{0xc5, 0x82}, []byte{0xc5, 0xba},
}
teletextNationalSubsetPortugueseSpanish = &teletextNationalSubset{
[]byte{0xc3, 0xa7}, []byte{0x24}, []byte{0xc2, 0xa1}, []byte{0xc3, 0xa1}, []byte{0xc3, 0xa9},
[]byte{0xc3, 0xad}, []byte{0xc3, 0xb3}, []byte{0xc3, 0xba}, []byte{0xc2, 0xbf}, []byte{0xc3, 0xbc},
[]byte{0xc3, 0xb1}, []byte{0xc3, 0xa8}, []byte{0xc3, 0xa0},
}
teletextNationalSubsetRomanian = &teletextNationalSubset{
[]byte{0x23}, []byte{0xc2, 0xa4}, []byte{0xc5, 0xa2}, []byte{0xc3, 0x82}, []byte{0xc5, 0x9e},
[]byte{0xc4, 0x82}, []byte{0xc3, 0x8e}, []byte{0xc4, 0xb1}, []byte{0xc5, 0xa3}, []byte{0xc3, 0xa2},
[]byte{0xc5, 0x9f}, []byte{0xc4, 0x83}, []byte{0xc3, 0xae},
}
teletextNationalSubsetSerbianCroatianSlovenian = &teletextNationalSubset{
[]byte{0x23}, []byte{0xc3, 0x8b}, []byte{0xc4, 0x8c}, []byte{0xc4, 0x86}, []byte{0xc5, 0xbd},
[]byte{0xc4, 0x90}, []byte{0xc5, 0xa0}, []byte{0xc3, 0xab}, []byte{0xc4, 0x8d}, []byte{0xc4, 0x87},
[]byte{0xc5, 0xbe}, []byte{0xc4, 0x91}, []byte{0xc5, 0xa1},
}
teletextNationalSubsetSwedishFinnishHungarian = &teletextNationalSubset{
[]byte{0x23}, []byte{0xc2, 0xa4}, []byte{0xc3, 0x89}, []byte{0xc3, 0x84}, []byte{0xc3, 0x96},
[]byte{0xc3, 0x85}, []byte{0xc3, 0x9c}, []byte{0x5f}, []byte{0xc3, 0xa9}, []byte{0xc3, 0xa4},
[]byte{0xc3, 0xb6}, []byte{0xc3, 0xa5}, []byte{0xc3, 0xbc},
}
teletextNationalSubsetTurkish = &teletextNationalSubset{
[]byte{0x54}, []byte{0xc4, 0x9f}, []byte{0xc4, 0xb0}, []byte{0xc5, 0x9e}, []byte{0xc3, 0x96},
[]byte{0xc3, 0x87}, []byte{0xc3, 0x9c}, []byte{0xc4, 0x9e}, []byte{0xc4, 0xb1}, []byte{0xc5, 0x9f},
[]byte{0xc3, 0xb6}, []byte{0xc3, 0xa7}, []byte{0xc3, 0xbc},
}
)
// Teletext PES data types
const (
teletextPESDataTypeEBU = "EBU"
teletextPESDataTypeUnknown = "unknown"
)
func teletextPESDataType(dataIdentifier uint8) string {
switch {
case dataIdentifier >= 0x10 && dataIdentifier <= 0x1f:
return teletextPESDataTypeEBU
}
return teletextPESDataTypeUnknown
}
// Teletext PES data unit ids
const (
teletextPESDataUnitIDEBUNonSubtitleData = 0x2
teletextPESDataUnitIDEBUSubtitleData = 0x3
teletextPESDataUnitIDStuffing = 0xff
)
// TeletextOptions represents teletext options
type TeletextOptions struct {
Page int
PID int
}
// ReadFromTeletext parses a teletext content
// http://www.etsi.org/deliver/etsi_en/300400_300499/300472/01.03.01_60/en_300472v010301p.pdf
// http://www.etsi.org/deliver/etsi_i_ets/300700_300799/300706/01_60/ets_300706e01p.pdf
// TODO Update README
// TODO Add tests
func ReadFromTeletext(r io.Reader, o TeletextOptions) (s *Subtitles, err error) {
// Init
s = &Subtitles{}
var dmx = astits.NewDemuxer(context.Background(), r)
// Get the teletext PID
var pid uint16
if pid, err = teletextPID(dmx, o); err != nil {
if err != ErrNoValidTeletextPID {
err = fmt.Errorf("astisub: getting teletext PID failed: %w", err)
}
return
}
// Create character decoder
cd := newTeletextCharacterDecoder()
// Create page buffer
b := newTeletextPageBuffer(o.Page, cd)
// Loop in data
var firstTime, lastTime time.Time
var d *astits.DemuxerData
var ps []*teletextPage
for {
// Fetch next data
if d, err = dmx.NextData(); err != nil {
if err == astits.ErrNoMorePackets {
err = nil
break
}
err = fmt.Errorf("astisub: fetching next data failed: %w", err)
return
}
// We only parse PES data
if d.PES == nil {
continue
}
// This data is not of interest to us
if d.PID != pid || d.PES.Header.StreamID != astits.StreamIDPrivateStream1 {
continue
}
// Get time
t := teletextDataTime(d)
if t.IsZero() {
continue
}
// First and last time
if firstTime.IsZero() || firstTime.After(t) {
firstTime = t
}
if lastTime.IsZero() || lastTime.Before(t) {
lastTime = t
}
// Append pages
ps = append(ps, b.process(d.PES, t)...)
}
// Dump buffer
ps = append(ps, b.dump(lastTime)...)
// Parse pages
for _, p := range ps {
p.parse(s, cd, firstTime)
}
return
}
// TODO Add tests
func teletextDataTime(d *astits.DemuxerData) time.Time {
if d.PES.Header != nil && d.PES.Header.OptionalHeader != nil && d.PES.Header.OptionalHeader.PTS != nil {
return d.PES.Header.OptionalHeader.PTS.Time()
} else if d.FirstPacket != nil && d.FirstPacket.AdaptationField != nil && d.FirstPacket.AdaptationField.PCR != nil {
return d.FirstPacket.AdaptationField.PCR.Time()
}
return time.Time{}
}
// If the PID teletext option is not indicated, it will walk through the ts data until it reaches a PMT packet to
// detect the first valid teletext PID
// TODO Add tests
func teletextPID(dmx *astits.Demuxer, o TeletextOptions) (pid uint16, err error) {
// PID is in the options
if o.PID > 0 {
pid = uint16(o.PID)
return
}
// Loop in data
var d *astits.DemuxerData
for {
// Fetch next data
if d, err = dmx.NextData(); err != nil {
if err == astits.ErrNoMorePackets {
err = ErrNoValidTeletextPID
return
}
err = fmt.Errorf("astisub: fetching next data failed: %w", err)
return
}
// PMT data
if d.PMT != nil {
// Retrieve valid teletext PIDs
var pids []uint16
for _, s := range d.PMT.ElementaryStreams {
for _, dsc := range s.ElementaryStreamDescriptors {
if dsc.Tag == astits.DescriptorTagTeletext || dsc.Tag == astits.DescriptorTagVBITeletext {
pids = append(pids, s.ElementaryPID)
}
}
}
// No valid teletext PIDs
if len(pids) == 0 {
err = ErrNoValidTeletextPID
return
}
// Set pid
pid = pids[0]
log.Printf("astisub: no teletext pid specified, using pid %d", pid)
// Rewind
if _, err = dmx.Rewind(); err != nil {
err = fmt.Errorf("astisub: rewinding failed: %w", err)
return
}
return
}
}
}
type teletextPageBuffer struct {
cd *teletextCharacterDecoder
currentPage *teletextPage
donePages []*teletextPage
magazineNumber uint8
pageNumber int
receiving bool
}
func newTeletextPageBuffer(page int, cd *teletextCharacterDecoder) *teletextPageBuffer {
return &teletextPageBuffer{
cd: cd,
magazineNumber: uint8(page / 100),
pageNumber: page % 100,
}
}
// TODO Add tests
func (b *teletextPageBuffer) dump(lastTime time.Time) (ps []*teletextPage) {
if b.currentPage != nil {
b.currentPage.end = lastTime
ps = []*teletextPage{b.currentPage}
}
return
}
// TODO Add tests
func (b *teletextPageBuffer) process(d *astits.PESData, t time.Time) (ps []*teletextPage) {
// Data identifier
var offset int
dataIdentifier := uint8(d.Data[offset])
offset += 1
// Check data type
if teletextPESDataType(dataIdentifier) != teletextPESDataTypeEBU {
return
}
// Loop through data units
for offset < len(d.Data) {
// ID
id := uint8(d.Data[offset])
offset += 1
// Length
length := uint8(d.Data[offset])
offset += 1
// Offset end
offsetEnd := offset + int(length)
if offsetEnd > len(d.Data) {
break
}
// Parse data unit
b.parseDataUnit(d.Data[offset:offsetEnd], id, t)
// Seek to end of data unit
offset = offsetEnd
}
// Dump buffer
ps = b.donePages
b.donePages = []*teletextPage(nil)
return ps
}
// TODO Add tests
func (b *teletextPageBuffer) parseDataUnit(i []byte, id uint8, t time.Time) {
// Check id
if id != teletextPESDataUnitIDEBUSubtitleData {
return
}
// Field parity: i[0]&0x20 > 0
// Line offset: uint8(i[0] & 0x1f)
// Framing code
framingCode := uint8(i[1])
// Check framing code
if framingCode != 0xe4 {
return
}
// Magazine number and packet number
h1, ok := astikit.ByteHamming84Decode(i[2])
if !ok {
return
}
h2, ok := astikit.ByteHamming84Decode(i[3])
if !ok {
return
}
h := h2<<4 | h1
magazineNumber := h & 0x7
if magazineNumber == 0 {
magazineNumber = 8
}
packetNumber := h >> 3
// Parse packet
b.parsePacket(i[4:], magazineNumber, packetNumber, t)
}
// TODO Add tests
func (b *teletextPageBuffer) parsePacket(i []byte, magazineNumber, packetNumber uint8, t time.Time) {
if packetNumber == 0 {
b.parsePacketHeader(i, magazineNumber, t)
} else if b.receiving && magazineNumber == b.magazineNumber && (packetNumber >= 1 && packetNumber <= 25) {
b.parsePacketData(i, packetNumber)
} else {
// Designation code
designationCode, ok := astikit.ByteHamming84Decode(i[0])
if !ok {
return
}
// Parse packet
if b.receiving && magazineNumber == b.magazineNumber && packetNumber == 26 {
// TODO Implement
} else if b.receiving && magazineNumber == b.magazineNumber && packetNumber == 28 {
b.parsePacket28And29(i[1:], packetNumber, designationCode)
} else if magazineNumber == b.magazineNumber && packetNumber == 29 {
b.parsePacket28And29(i[1:], packetNumber, designationCode)
} else if magazineNumber == 8 && packetNumber == 30 {
b.parsePacket30(i, designationCode)
}
}
}
// TODO Add tests
func (b *teletextPageBuffer) parsePacketHeader(i []byte, magazineNumber uint8, t time.Time) (transmissionDone bool) {
// Page number units
pageNumberUnits, ok := astikit.ByteHamming84Decode(i[0])
if !ok {
return
}
// Page number tens
pageNumberTens, ok := astikit.ByteHamming84Decode(i[1])
if !ok {
return
}
pageNumber := int(pageNumberTens)*10 + int(pageNumberUnits)
// 0xff is a reserved page number value
if pageNumberTens == 0xf && pageNumberUnits == 0xf {
return
}
// Update magazine and page number
if b.magazineNumber == 0 && b.pageNumber == 0 {
// C6
controlBits, ok := astikit.ByteHamming84Decode(i[5])
if !ok {
return
}
subtitleFlag := controlBits&0x8 > 0
// This is a subtitle page
if subtitleFlag {
b.magazineNumber = magazineNumber
b.pageNumber = pageNumber
log.Printf("astisub: no teletext page specified, using page %d%.2d", b.magazineNumber, b.pageNumber)
}
}
// C11 --> C14
controlBits, ok := astikit.ByteHamming84Decode(i[7])
if !ok {
return
}
magazineSerial := controlBits&0x1 > 0
charsetCode := controlBits >> 1
// Page transmission is done
if b.receiving && ((magazineSerial && pageNumber != b.pageNumber) ||
(!magazineSerial && pageNumber != b.pageNumber && magazineNumber == b.magazineNumber)) {
b.receiving = false
return
}
// Invalid magazine or page number
if pageNumber != b.pageNumber || magazineNumber != b.magazineNumber {
return
}
// Now that we know when the previous page ends we can add it to the done slice
if b.currentPage != nil {
b.currentPage.end = t
b.donePages = append(b.donePages, b.currentPage)
}
// Reset
b.receiving = true
b.currentPage = newTeletextPage(charsetCode, t)
return
}
// TODO Add tests
func (b *teletextPageBuffer) parsePacketData(i []byte, packetNumber uint8) {
// Make sure the map is initialized
if _, ok := b.currentPage.data[packetNumber]; !ok {
b.currentPage.data[packetNumber] = make([]byte, 40)
}
// Loop through input
b.currentPage.rows = append(b.currentPage.rows, int(packetNumber))
for idx := uint8(0); idx < 40; idx++ {
v, ok := astikit.ByteParity(bits.Reverse8(i[idx]))
if !ok {
v = 0
}
b.currentPage.data[packetNumber][idx] = v
}
}
// TODO Add tests
func (b *teletextPageBuffer) parsePacket28And29(i []byte, packetNumber, designationCode uint8) {
// Invalid designation code
if designationCode != 0 && designationCode != 4 {
return
}
// Triplet 1
// TODO triplet1 should be the results of hamming 24/18 decoding
triplet1 := uint32(i[2])<<16 | uint32(i[1])<<8 | uint32(i[0])
// We only process x/28 format 1
if packetNumber == 28 && triplet1&0xf > 0 {
return
}
// Update character decoder
if packetNumber == 28 {
b.cd.setTripletX28(triplet1)
} else {
b.cd.setTripletM29(triplet1)
}
}
// TODO Add tests
func (b *teletextPageBuffer) parsePacket30(i []byte, designationCode uint8) {
// Switch on designation code to determine format
switch designationCode {
case 0, 1:
b.parsePacket30Format1(i)
case 2, 3:
b.parsePacket30Format2(i)
}
}
func (b *teletextPageBuffer) parsePacket30Format1(i []byte) {
// TODO Implement
}
func (b *teletextPageBuffer) parsePacket30Format2(i []byte) {
// TODO Implement
}
type teletextCharacterDecoder struct {
c teletextCharset
lastPageCharsetCode *uint8
tripletM29 *uint32
tripletX28 *uint32
}
func newTeletextCharacterDecoder() *teletextCharacterDecoder {
return &teletextCharacterDecoder{}
}
// TODO Add tests
func (d *teletextCharacterDecoder) setTripletM29(i uint32) {
if *d.tripletM29 != i {
d.tripletM29 = astikit.UInt32Ptr(i)
d.updateCharset(d.lastPageCharsetCode, true)
}
}
// TODO Add tests
func (d *teletextCharacterDecoder) setTripletX28(i uint32) {
if *d.tripletX28 != i {
d.tripletX28 = astikit.UInt32Ptr(i)
d.updateCharset(d.lastPageCharsetCode, true)
}
}
// TODO Add tests
func (d *teletextCharacterDecoder) decode(i byte) []byte {
if i < 0x20 {
return []byte{}
}
return d.c[i-0x20]
}
// TODO Add tests
func (d *teletextCharacterDecoder) updateCharset(pageCharsetCode *uint8, force bool) {
// Charset is up to date
if d.lastPageCharsetCode != nil && *pageCharsetCode == *d.lastPageCharsetCode && !force {
return
}
d.lastPageCharsetCode = pageCharsetCode
// Get triplet
var triplet uint32
if d.tripletX28 != nil {
triplet = *d.tripletX28
} else if d.tripletM29 != nil {
triplet = *d.tripletM29
}
// Get charsets
d.c = *teletextCharsetG0Latin
var nationalOptionSubset *teletextNationalSubset
if v1, ok := teletextCharsets[uint8((triplet&0x3f80)>>10)]; ok {
if v2, ok := v1[*pageCharsetCode]; ok {
d.c = *v2.g0
nationalOptionSubset = v2.national
}
}
// Update g0 with national option subset
if nationalOptionSubset != nil {
for k, v := range nationalOptionSubset {
d.c[teletextNationalSubsetCharactersPositionInG0[k]] = v
}
}
}
type teletextPage struct {
charsetCode uint8
data map[uint8][]byte
end time.Time
rows []int
start time.Time
}
func newTeletextPage(charsetCode uint8, start time.Time) *teletextPage {
return &teletextPage{
charsetCode: charsetCode,
data: make(map[uint8][]byte),
start: start,
}
}
func (p *teletextPage) parse(s *Subtitles, d *teletextCharacterDecoder, firstTime time.Time) {
// Update charset
d.updateCharset(astikit.UInt8Ptr(p.charsetCode), false)
// No data
if len(p.data) == 0 {
return
}
// Order rows
sort.Ints(p.rows)
// Create item
i := &Item{
EndAt: p.end.Sub(firstTime),
StartAt: p.start.Sub(firstTime),
}
// Loop through rows
for _, idxRow := range p.rows {
parseTeletextRow(i, d, nil, p.data[uint8(idxRow)])
}
// Append item
s.Items = append(s.Items, i)
}
type decoder interface {
decode(i byte) []byte
}
type styler interface {
hasBeenSet() bool
hasChanged(s *StyleAttributes) bool
parseSpacingAttribute(i byte)
propagateStyleAttributes(s *StyleAttributes)
update(sa *StyleAttributes)
}
func parseTeletextRow(i *Item, d decoder, fs func() styler, row []byte) {
// Loop through columns
var l = Line{}
var li = LineItem{InlineStyle: &StyleAttributes{}}
var started bool
var s styler
for _, v := range row {
// Create specific styler
if fs != nil {
s = fs()
}
// Get spacing attributes
var color *Color
var doubleHeight, doubleSize, doubleWidth *bool
switch v {
case 0x0:
color = ColorBlack
case 0x1:
color = ColorRed
case 0x2:
color = ColorGreen
case 0x3:
color = ColorYellow
case 0x4:
color = ColorBlue
case 0x5:
color = ColorMagenta
case 0x6:
color = ColorCyan
case 0x7:
color = ColorWhite
case 0xa:
started = false
case 0xb:
started = true
case 0xc:
doubleHeight = astikit.BoolPtr(false)
doubleSize = astikit.BoolPtr(false)
doubleWidth = astikit.BoolPtr(false)
case 0xd:
doubleHeight = astikit.BoolPtr(true)
case 0xe:
doubleWidth = astikit.BoolPtr(true)
case 0xf:
doubleSize = astikit.BoolPtr(true)
default:
if s != nil {
s.parseSpacingAttribute(v)
}
}
// Style has been set
if color != nil || doubleHeight != nil || doubleSize != nil || doubleWidth != nil || (s != nil && s.hasBeenSet()) {
// Style has changed
if color != li.InlineStyle.TeletextColor || doubleHeight != li.InlineStyle.TeletextDoubleHeight ||
doubleSize != li.InlineStyle.TeletextDoubleSize || doubleWidth != li.InlineStyle.TeletextDoubleWidth ||
(s != nil && s.hasChanged(li.InlineStyle)) {
// Line has started
if started {
// Append line item
appendTeletextLineItem(&l, li, s)
// Create new line item
sa := &StyleAttributes{}
*sa = *li.InlineStyle
li = LineItem{InlineStyle: sa}
}
// Update style attributes
if color != nil && color != li.InlineStyle.TeletextColor {
li.InlineStyle.TeletextColor = color
}
if doubleHeight != nil && doubleHeight != li.InlineStyle.TeletextDoubleHeight {
li.InlineStyle.TeletextDoubleHeight = doubleHeight
}
if doubleSize != nil && doubleSize != li.InlineStyle.TeletextDoubleSize {
li.InlineStyle.TeletextDoubleSize = doubleSize
}
if doubleWidth != nil && doubleWidth != li.InlineStyle.TeletextDoubleWidth {
li.InlineStyle.TeletextDoubleWidth = doubleWidth
}
if s != nil {
s.update(li.InlineStyle)
}
}
} else if started {
// Append text
li.Text += string(d.decode(v))
}
}
// Append line item
appendTeletextLineItem(&l, li, s)
// Append line
if len(l.Items) > 0 {
i.Lines = append(i.Lines, l)
}
}
func appendTeletextLineItem(l *Line, li LineItem, s styler) {
// There's some text
if len(strings.TrimSpace(li.Text)) > 0 {
// Make sure inline style exists
if li.InlineStyle == nil {
li.InlineStyle = &StyleAttributes{}
}
// Get number of spaces before
li.InlineStyle.TeletextSpacesBefore = astikit.IntPtr(0)
for _, c := range li.Text {
if c == ' ' {
*li.InlineStyle.TeletextSpacesBefore++
} else {
break
}
}
// Get number of spaces after
li.InlineStyle.TeletextSpacesAfter = astikit.IntPtr(0)
for idx := len(li.Text) - 1; idx >= 0; idx-- {
if li.Text[idx] == ' ' {
*li.InlineStyle.TeletextSpacesAfter++
} else {
break
}
}
// Propagate style attributes
li.InlineStyle.propagateTeletextAttributes()
if s != nil {
s.propagateStyleAttributes(li.InlineStyle)
}
// Append line item
li.Text = strings.TrimSpace(li.Text)
l.Items = append(l.Items, li)
}
}

686
vendor/github.com/asticode/go-astisub/ttml.go generated vendored Normal file
View File

@@ -0,0 +1,686 @@
package astisub
import (
"encoding/xml"
"fmt"
"io"
"regexp"
"sort"
"strconv"
"strings"
"time"
"github.com/asticode/go-astikit"
)
// https://www.w3.org/TR/ttaf1-dfxp/
// http://www.skynav.com:8080/ttv/check
// https://www.speechpad.com/captions/ttml
// TTML languages
const (
ttmlLanguageChinese = "zh"
ttmlLanguageEnglish = "en"
ttmlLanguageJapanese = "ja"
ttmlLanguageFrench = "fr"
ttmlLanguageNorwegian = "no"
)
// TTML language mapping
var ttmlLanguageMapping = astikit.NewBiMap().
Set(ttmlLanguageChinese, LanguageChinese).
Set(ttmlLanguageEnglish, LanguageEnglish).
Set(ttmlLanguageFrench, LanguageFrench).
Set(ttmlLanguageJapanese, LanguageJapanese).
Set(ttmlLanguageNorwegian, LanguageNorwegian)
// TTML Clock Time Frames and Offset Time
var (
ttmlRegexpClockTimeFrames = regexp.MustCompile(`\:[\d]+$`)
ttmlRegexpOffsetTime = regexp.MustCompile(`^(\d+(\.\d+)?)(h|m|s|ms|f|t)$`)
)
// TTMLIn represents an input TTML that must be unmarshaled
// We split it from the output TTML as we can't add strict namespace without breaking retrocompatibility
type TTMLIn struct {
Framerate int `xml:"frameRate,attr"`
Lang string `xml:"lang,attr"`
Metadata TTMLInMetadata `xml:"head>metadata"`
Regions []TTMLInRegion `xml:"head>layout>region"`
Styles []TTMLInStyle `xml:"head>styling>style"`
Subtitles []TTMLInSubtitle `xml:"body>div>p"`
Tickrate int `xml:"tickRate,attr"`
XMLName xml.Name `xml:"tt"`
}
// metadata returns the Metadata of the TTML
func (t TTMLIn) metadata() (m *Metadata) {
m = &Metadata{
Framerate: t.Framerate,
Title: t.Metadata.Title,
TTMLCopyright: t.Metadata.Copyright,
}
if v, ok := ttmlLanguageMapping.Get(astikit.StrPad(t.Lang, ' ', 2, astikit.PadCut)); ok {
m.Language = v.(string)
}
return
}
// TTMLInMetadata represents an input TTML Metadata
type TTMLInMetadata struct {
Copyright string `xml:"copyright"`
Title string `xml:"title"`
}
// TTMLInStyleAttributes represents input TTML style attributes
type TTMLInStyleAttributes struct {
BackgroundColor *string `xml:"backgroundColor,attr,omitempty"`
Color *string `xml:"color,attr,omitempty"`
Direction *string `xml:"direction,attr,omitempty"`
Display *string `xml:"display,attr,omitempty"`
DisplayAlign *string `xml:"displayAlign,attr,omitempty"`
Extent *string `xml:"extent,attr,omitempty"`
FontFamily *string `xml:"fontFamily,attr,omitempty"`
FontSize *string `xml:"fontSize,attr,omitempty"`
FontStyle *string `xml:"fontStyle,attr,omitempty"`
FontWeight *string `xml:"fontWeight,attr,omitempty"`
LineHeight *string `xml:"lineHeight,attr,omitempty"`
Opacity *string `xml:"opacity,attr,omitempty"`
Origin *string `xml:"origin,attr,omitempty"`
Overflow *string `xml:"overflow,attr,omitempty"`
Padding *string `xml:"padding,attr,omitempty"`
ShowBackground *string `xml:"showBackground,attr,omitempty"`
TextAlign *string `xml:"textAlign,attr,omitempty"`
TextDecoration *string `xml:"textDecoration,attr,omitempty"`
TextOutline *string `xml:"textOutline,attr,omitempty"`
UnicodeBidi *string `xml:"unicodeBidi,attr,omitempty"`
Visibility *string `xml:"visibility,attr,omitempty"`
WrapOption *string `xml:"wrapOption,attr,omitempty"`
WritingMode *string `xml:"writingMode,attr,omitempty"`
ZIndex *int `xml:"zIndex,attr,omitempty"`
}
// StyleAttributes converts TTMLInStyleAttributes into a StyleAttributes
func (s TTMLInStyleAttributes) styleAttributes() (o *StyleAttributes) {
o = &StyleAttributes{
TTMLBackgroundColor: s.BackgroundColor,
TTMLColor: s.Color,
TTMLDirection: s.Direction,
TTMLDisplay: s.Display,
TTMLDisplayAlign: s.DisplayAlign,
TTMLExtent: s.Extent,
TTMLFontFamily: s.FontFamily,
TTMLFontSize: s.FontSize,
TTMLFontStyle: s.FontStyle,
TTMLFontWeight: s.FontWeight,
TTMLLineHeight: s.LineHeight,
TTMLOpacity: s.Opacity,
TTMLOrigin: s.Origin,
TTMLOverflow: s.Overflow,
TTMLPadding: s.Padding,
TTMLShowBackground: s.ShowBackground,
TTMLTextAlign: s.TextAlign,
TTMLTextDecoration: s.TextDecoration,
TTMLTextOutline: s.TextOutline,
TTMLUnicodeBidi: s.UnicodeBidi,
TTMLVisibility: s.Visibility,
TTMLWrapOption: s.WrapOption,
TTMLWritingMode: s.WritingMode,
TTMLZIndex: s.ZIndex,
}
o.propagateTTMLAttributes()
return
}
// TTMLInHeader represents an input TTML header
type TTMLInHeader struct {
ID string `xml:"id,attr,omitempty"`
Style string `xml:"style,attr,omitempty"`
TTMLInStyleAttributes
}
// TTMLInRegion represents an input TTML region
type TTMLInRegion struct {
TTMLInHeader
XMLName xml.Name `xml:"region"`
}
// TTMLInStyle represents an input TTML style
type TTMLInStyle struct {
TTMLInHeader
XMLName xml.Name `xml:"style"`
}
// TTMLInSubtitle represents an input TTML subtitle
type TTMLInSubtitle struct {
Begin *TTMLInDuration `xml:"begin,attr,omitempty"`
End *TTMLInDuration `xml:"end,attr,omitempty"`
ID string `xml:"id,attr,omitempty"`
Items string `xml:",innerxml"` // We must store inner XML here since there's no tag to describe both any tag and chardata
Region string `xml:"region,attr,omitempty"`
Style string `xml:"style,attr,omitempty"`
TTMLInStyleAttributes
}
// TTMLInItems represents input TTML items
type TTMLInItems []TTMLInItem
// UnmarshalXML implements the XML unmarshaler interface
func (i *TTMLInItems) UnmarshalXML(d *xml.Decoder, start xml.StartElement) (err error) {
// Get next tokens
var t xml.Token
for {
// Get next token
if t, err = d.Token(); err != nil {
if err == io.EOF {
break
}
err = fmt.Errorf("astisub: getting next token failed: %w", err)
return
}
// Start element
if se, ok := t.(xml.StartElement); ok {
var e = TTMLInItem{}
if err = d.DecodeElement(&e, &se); err != nil {
err = fmt.Errorf("astisub: decoding xml.StartElement failed: %w", err)
return
}
*i = append(*i, e)
} else if b, ok := t.(xml.CharData); ok {
var str = strings.TrimSpace(string(b))
if len(str) > 0 {
*i = append(*i, TTMLInItem{Text: str})
}
}
}
return nil
}
// TTMLInItem represents an input TTML item
type TTMLInItem struct {
Style string `xml:"style,attr,omitempty"`
Text string `xml:",chardata"`
TTMLInStyleAttributes
XMLName xml.Name
}
// TTMLInDuration represents an input TTML duration
type TTMLInDuration struct {
d time.Duration
frames, framerate int // Framerate is in frame/s
ticks, tickrate int // Tickrate is in ticks/s
}
// UnmarshalText implements the TextUnmarshaler interface
// Possible formats are:
// - hh:mm:ss.mmm
// - hh:mm:ss:fff (fff being frames)
// - [ticks]t ([ticks] being the tick amount)
func (d *TTMLInDuration) UnmarshalText(i []byte) (err error) {
// Reset duration
d.d = time.Duration(0)
d.frames = 0
d.ticks = 0
// Check offset time
text := string(i)
if matches := ttmlRegexpOffsetTime.FindStringSubmatch(text); matches != nil {
// Parse value
var value float64
if value, err = strconv.ParseFloat(matches[1], 64); err != nil {
err = fmt.Errorf("astisub: failed to parse value %s", matches[1])
return
}
// Parse metric
metric := matches[3]
// Update duration
if metric == "t" {
d.ticks = int(value)
} else if metric == "f" {
d.frames = int(value)
} else {
// Get timebase
var timebase time.Duration
switch metric {
case "h":
timebase = time.Hour
case "m":
timebase = time.Minute
case "s":
timebase = time.Second
case "ms":
timebase = time.Millisecond
default:
err = fmt.Errorf("astisub: invalid metric %s", metric)
return
}
// Update duration
d.d = time.Duration(value * float64(timebase.Nanoseconds()))
}
return
}
// Extract clock time frames
if indexes := ttmlRegexpClockTimeFrames.FindStringIndex(text); indexes != nil {
// Parse frames
var s = text[indexes[0]+1 : indexes[1]]
if d.frames, err = strconv.Atoi(s); err != nil {
err = fmt.Errorf("astisub: atoi %s failed: %w", s, err)
return
}
// Update text
text = text[:indexes[0]] + ".000"
}
d.d, err = parseDuration(text, ".", 3)
return
}
// duration returns the input TTML Duration's time.Duration
func (d TTMLInDuration) duration() (o time.Duration) {
if d.ticks > 0 && d.tickrate > 0 {
return time.Duration(float64(d.ticks) * 1e9 / float64(d.tickrate))
}
o = d.d
if d.frames > 0 && d.framerate > 0 {
o += time.Duration(float64(d.frames) / float64(d.framerate) * float64(time.Second.Nanoseconds()))
}
return
}
// ReadFromTTML parses a .ttml content
func ReadFromTTML(i io.Reader) (o *Subtitles, err error) {
// Init
o = NewSubtitles()
// Unmarshal XML
var ttml TTMLIn
if err = xml.NewDecoder(i).Decode(&ttml); err != nil {
err = fmt.Errorf("astisub: xml decoding failed: %w", err)
return
}
// Add metadata
o.Metadata = ttml.metadata()
// Loop through styles
var parentStyles = make(map[string]*Style)
for _, ts := range ttml.Styles {
var s = &Style{
ID: ts.ID,
InlineStyle: ts.TTMLInStyleAttributes.styleAttributes(),
}
o.Styles[s.ID] = s
if len(ts.Style) > 0 {
parentStyles[ts.Style] = s
}
}
// Take care of parent styles
for id, s := range parentStyles {
if _, ok := o.Styles[id]; !ok {
err = fmt.Errorf("astisub: Style %s requested by style %s doesn't exist", id, s.ID)
return
}
s.Style = o.Styles[id]
}
// Loop through regions
for _, tr := range ttml.Regions {
var r = &Region{
ID: tr.ID,
InlineStyle: tr.TTMLInStyleAttributes.styleAttributes(),
}
if len(tr.Style) > 0 {
if _, ok := o.Styles[tr.Style]; !ok {
err = fmt.Errorf("astisub: Style %s requested by region %s doesn't exist", tr.Style, r.ID)
return
}
r.Style = o.Styles[tr.Style]
}
o.Regions[r.ID] = r
}
// Loop through subtitles
for _, ts := range ttml.Subtitles {
// Init item
ts.Begin.framerate = ttml.Framerate
ts.Begin.tickrate = ttml.Tickrate
ts.End.framerate = ttml.Framerate
ts.End.tickrate = ttml.Tickrate
var s = &Item{
EndAt: ts.End.duration(),
InlineStyle: ts.TTMLInStyleAttributes.styleAttributes(),
StartAt: ts.Begin.duration(),
}
// Add region
if len(ts.Region) > 0 {
if _, ok := o.Regions[ts.Region]; !ok {
err = fmt.Errorf("astisub: Region %s requested by subtitle between %s and %s doesn't exist", ts.Region, s.StartAt, s.EndAt)
return
}
s.Region = o.Regions[ts.Region]
}
// Add style
if len(ts.Style) > 0 {
if _, ok := o.Styles[ts.Style]; !ok {
err = fmt.Errorf("astisub: Style %s requested by subtitle between %s and %s doesn't exist", ts.Style, s.StartAt, s.EndAt)
return
}
s.Style = o.Styles[ts.Style]
}
// Unmarshal items
var items = TTMLInItems{}
if err = xml.Unmarshal([]byte("<span>"+ts.Items+"</span>"), &items); err != nil {
err = fmt.Errorf("astisub: unmarshaling items failed: %w", err)
return
}
// Loop through texts
var l = &Line{}
for _, tt := range items {
// New line specified with the "br" tag
if strings.ToLower(tt.XMLName.Local) == "br" {
s.Lines = append(s.Lines, *l)
l = &Line{}
continue
}
// New line decoded as a line break. This can happen if there's a "br" tag within the text since
// since the go xml unmarshaler will unmarshal a "br" tag as a line break if the field has the
// chardata xml tag.
for idx, li := range strings.Split(tt.Text, "\n") {
// New line
if idx > 0 {
s.Lines = append(s.Lines, *l)
l = &Line{}
}
// Init line item
var t = LineItem{
InlineStyle: tt.TTMLInStyleAttributes.styleAttributes(),
Text: strings.TrimSpace(li),
}
// Add style
if len(tt.Style) > 0 {
if _, ok := o.Styles[tt.Style]; !ok {
err = fmt.Errorf("astisub: Style %s requested by item with text %s doesn't exist", tt.Style, tt.Text)
return
}
t.Style = o.Styles[tt.Style]
}
// Append items
l.Items = append(l.Items, t)
}
}
s.Lines = append(s.Lines, *l)
// Append subtitle
o.Items = append(o.Items, s)
}
return
}
// TTMLOut represents an output TTML that must be marshaled
// We split it from the input TTML as this time we'll add strict namespaces
type TTMLOut struct {
Lang string `xml:"xml:lang,attr,omitempty"`
Metadata *TTMLOutMetadata `xml:"head>metadata,omitempty"`
Styles []TTMLOutStyle `xml:"head>styling>style,omitempty"` //!\\ Order is important! Keep Styling above Layout
Regions []TTMLOutRegion `xml:"head>layout>region,omitempty"`
Subtitles []TTMLOutSubtitle `xml:"body>div>p,omitempty"`
XMLName xml.Name `xml:"http://www.w3.org/ns/ttml tt"`
XMLNamespaceTTM string `xml:"xmlns:ttm,attr"`
XMLNamespaceTTS string `xml:"xmlns:tts,attr"`
}
// TTMLOutMetadata represents an output TTML Metadata
type TTMLOutMetadata struct {
Copyright string `xml:"ttm:copyright,omitempty"`
Title string `xml:"ttm:title,omitempty"`
}
// TTMLOutStyleAttributes represents output TTML style attributes
type TTMLOutStyleAttributes struct {
BackgroundColor *string `xml:"tts:backgroundColor,attr,omitempty"`
Color *string `xml:"tts:color,attr,omitempty"`
Direction *string `xml:"tts:direction,attr,omitempty"`
Display *string `xml:"tts:display,attr,omitempty"`
DisplayAlign *string `xml:"tts:displayAlign,attr,omitempty"`
Extent *string `xml:"tts:extent,attr,omitempty"`
FontFamily *string `xml:"tts:fontFamily,attr,omitempty"`
FontSize *string `xml:"tts:fontSize,attr,omitempty"`
FontStyle *string `xml:"tts:fontStyle,attr,omitempty"`
FontWeight *string `xml:"tts:fontWeight,attr,omitempty"`
LineHeight *string `xml:"tts:lineHeight,attr,omitempty"`
Opacity *string `xml:"tts:opacity,attr,omitempty"`
Origin *string `xml:"tts:origin,attr,omitempty"`
Overflow *string `xml:"tts:overflow,attr,omitempty"`
Padding *string `xml:"tts:padding,attr,omitempty"`
ShowBackground *string `xml:"tts:showBackground,attr,omitempty"`
TextAlign *string `xml:"tts:textAlign,attr,omitempty"`
TextDecoration *string `xml:"tts:textDecoration,attr,omitempty"`
TextOutline *string `xml:"tts:textOutline,attr,omitempty"`
UnicodeBidi *string `xml:"tts:unicodeBidi,attr,omitempty"`
Visibility *string `xml:"tts:visibility,attr,omitempty"`
WrapOption *string `xml:"tts:wrapOption,attr,omitempty"`
WritingMode *string `xml:"tts:writingMode,attr,omitempty"`
ZIndex *int `xml:"tts:zIndex,attr,omitempty"`
}
// ttmlOutStyleAttributesFromStyleAttributes converts StyleAttributes into a TTMLOutStyleAttributes
func ttmlOutStyleAttributesFromStyleAttributes(s *StyleAttributes) TTMLOutStyleAttributes {
if s == nil {
return TTMLOutStyleAttributes{}
}
return TTMLOutStyleAttributes{
BackgroundColor: s.TTMLBackgroundColor,
Color: s.TTMLColor,
Direction: s.TTMLDirection,
Display: s.TTMLDisplay,
DisplayAlign: s.TTMLDisplayAlign,
Extent: s.TTMLExtent,
FontFamily: s.TTMLFontFamily,
FontSize: s.TTMLFontSize,
FontStyle: s.TTMLFontStyle,
FontWeight: s.TTMLFontWeight,
LineHeight: s.TTMLLineHeight,
Opacity: s.TTMLOpacity,
Origin: s.TTMLOrigin,
Overflow: s.TTMLOverflow,
Padding: s.TTMLPadding,
ShowBackground: s.TTMLShowBackground,
TextAlign: s.TTMLTextAlign,
TextDecoration: s.TTMLTextDecoration,
TextOutline: s.TTMLTextOutline,
UnicodeBidi: s.TTMLUnicodeBidi,
Visibility: s.TTMLVisibility,
WrapOption: s.TTMLWrapOption,
WritingMode: s.TTMLWritingMode,
ZIndex: s.TTMLZIndex,
}
}
// TTMLOutHeader represents an output TTML header
type TTMLOutHeader struct {
ID string `xml:"xml:id,attr,omitempty"`
Style string `xml:"style,attr,omitempty"`
TTMLOutStyleAttributes
}
// TTMLOutRegion represents an output TTML region
type TTMLOutRegion struct {
TTMLOutHeader
XMLName xml.Name `xml:"region"`
}
// TTMLOutStyle represents an output TTML style
type TTMLOutStyle struct {
TTMLOutHeader
XMLName xml.Name `xml:"style"`
}
// TTMLOutSubtitle represents an output TTML subtitle
type TTMLOutSubtitle struct {
Begin TTMLOutDuration `xml:"begin,attr"`
End TTMLOutDuration `xml:"end,attr"`
ID string `xml:"id,attr,omitempty"`
Items []TTMLOutItem
Region string `xml:"region,attr,omitempty"`
Style string `xml:"style,attr,omitempty"`
TTMLOutStyleAttributes
}
// TTMLOutItem represents an output TTML Item
type TTMLOutItem struct {
Style string `xml:"style,attr,omitempty"`
Text string `xml:",chardata"`
TTMLOutStyleAttributes
XMLName xml.Name
}
// TTMLOutDuration represents an output TTML duration
type TTMLOutDuration time.Duration
// MarshalText implements the TextMarshaler interface
func (t TTMLOutDuration) MarshalText() ([]byte, error) {
return []byte(formatDuration(time.Duration(t), ".", 3)), nil
}
// WriteToTTML writes subtitles in .ttml format
func (s Subtitles) WriteToTTML(o io.Writer) (err error) {
// Do not write anything if no subtitles
if len(s.Items) == 0 {
return ErrNoSubtitlesToWrite
}
// Init TTML
var ttml = TTMLOut{
XMLNamespaceTTM: "http://www.w3.org/ns/ttml#metadata",
XMLNamespaceTTS: "http://www.w3.org/ns/ttml#styling",
}
// Add metadata
if s.Metadata != nil {
if v, ok := ttmlLanguageMapping.GetInverse(s.Metadata.Language); ok {
ttml.Lang = v.(string)
}
if len(s.Metadata.TTMLCopyright) > 0 || len(s.Metadata.Title) > 0 {
ttml.Metadata = &TTMLOutMetadata{
Copyright: s.Metadata.TTMLCopyright,
Title: s.Metadata.Title,
}
}
}
// Add regions
var k []string
for _, region := range s.Regions {
k = append(k, region.ID)
}
sort.Strings(k)
for _, id := range k {
var ttmlRegion = TTMLOutRegion{TTMLOutHeader: TTMLOutHeader{
ID: s.Regions[id].ID,
TTMLOutStyleAttributes: ttmlOutStyleAttributesFromStyleAttributes(s.Regions[id].InlineStyle),
}}
if s.Regions[id].Style != nil {
ttmlRegion.Style = s.Regions[id].Style.ID
}
ttml.Regions = append(ttml.Regions, ttmlRegion)
}
// Add styles
k = []string{}
for _, style := range s.Styles {
k = append(k, style.ID)
}
sort.Strings(k)
for _, id := range k {
var ttmlStyle = TTMLOutStyle{TTMLOutHeader: TTMLOutHeader{
ID: s.Styles[id].ID,
TTMLOutStyleAttributes: ttmlOutStyleAttributesFromStyleAttributes(s.Styles[id].InlineStyle),
}}
if s.Styles[id].Style != nil {
ttmlStyle.Style = s.Styles[id].Style.ID
}
ttml.Styles = append(ttml.Styles, ttmlStyle)
}
// Add items
for _, item := range s.Items {
// Init subtitle
var ttmlSubtitle = TTMLOutSubtitle{
Begin: TTMLOutDuration(item.StartAt),
End: TTMLOutDuration(item.EndAt),
TTMLOutStyleAttributes: ttmlOutStyleAttributesFromStyleAttributes(item.InlineStyle),
}
// Add region
if item.Region != nil {
ttmlSubtitle.Region = item.Region.ID
}
// Add style
if item.Style != nil {
ttmlSubtitle.Style = item.Style.ID
}
// Add lines
for _, line := range item.Lines {
// Loop through line items
for idx, lineItem := range line.Items {
// Init ttml item
var ttmlItem = TTMLOutItem{
Text: lineItem.Text,
TTMLOutStyleAttributes: ttmlOutStyleAttributesFromStyleAttributes(lineItem.InlineStyle),
XMLName: xml.Name{Local: "span"},
}
// condition to avoid adding space as the last character.
if idx < len(line.Items)-1 {
ttmlItem.Text = ttmlItem.Text + " "
}
// Add style
if lineItem.Style != nil {
ttmlItem.Style = lineItem.Style.ID
}
// Add ttml item
ttmlSubtitle.Items = append(ttmlSubtitle.Items, ttmlItem)
}
// Add line break
ttmlSubtitle.Items = append(ttmlSubtitle.Items, TTMLOutItem{XMLName: xml.Name{Local: "br"}})
}
// Remove last line break
if len(ttmlSubtitle.Items) > 0 {
ttmlSubtitle.Items = ttmlSubtitle.Items[:len(ttmlSubtitle.Items)-1]
}
// Append subtitle
ttml.Subtitles = append(ttml.Subtitles, ttmlSubtitle)
}
// Marshal XML
var e = xml.NewEncoder(o)
e.Indent("", " ")
if err = e.Encode(ttml); err != nil {
err = fmt.Errorf("astisub: xml encoding failed: %w", err)
return
}
return
}

537
vendor/github.com/asticode/go-astisub/webvtt.go generated vendored Normal file
View File

@@ -0,0 +1,537 @@
package astisub
import (
"bufio"
"bytes"
"errors"
"fmt"
"io"
"regexp"
"sort"
"strconv"
"strings"
"time"
"golang.org/x/net/html"
)
// https://www.w3.org/TR/webvtt1/
// Constants
const (
webvttBlockNameComment = "comment"
webvttBlockNameRegion = "region"
webvttBlockNameStyle = "style"
webvttBlockNameText = "text"
webvttTimeBoundariesSeparator = " --> "
webvttTimestampMap = "X-TIMESTAMP-MAP"
)
// Vars
var (
bytesWebVTTItalicEndTag = []byte("</i>")
bytesWebVTTItalicStartTag = []byte("<i>")
bytesWebVTTTimeBoundariesSeparator = []byte(webvttTimeBoundariesSeparator)
webVTTRegexpStartTag = regexp.MustCompile(`(<v([\.\w]*)([\s\w]+)+>)`)
)
// parseDurationWebVTT parses a .vtt duration
func parseDurationWebVTT(i string) (time.Duration, error) {
return parseDuration(i, ".", 3)
}
// https://tools.ietf.org/html/rfc8216#section-3.5
// Eg., `X-TIMESTAMP-MAP=LOCAL:00:00:00.000,MPEGTS:900000` => 10s
// `X-TIMESTAMP-MAP=LOCAL:00:00:00.000,MPEGTS:180000` => 2s
func parseTimestampMapWebVTT(line string) (timeOffset time.Duration, err error) {
splits := strings.Split(line, "=")
if len(splits) <= 1 {
err = fmt.Errorf("astisub: invalid X-TIMESTAMP-MAP, no '=' found")
return
}
right := splits[1]
var local time.Duration
var mpegts int64
for _, split := range strings.Split(right, ",") {
splits := strings.SplitN(split, ":", 2)
if len(splits) <= 1 {
err = fmt.Errorf("astisub: invalid X-TIMESTAMP-MAP, part %q didn't contain ':'", right)
return
}
switch strings.ToLower(strings.TrimSpace(splits[0])) {
case "local":
local, err = parseDurationWebVTT(splits[1])
if err != nil {
err = fmt.Errorf("astisub: parsing webvtt duration failed: %w", err)
return
}
case "mpegts":
mpegts, err = strconv.ParseInt(splits[1], 10, 0)
if err != nil {
err = fmt.Errorf("astisub: parsing int %s failed: %w", splits[1], err)
return
}
}
}
timeOffset = time.Duration(mpegts)*time.Second/90000 - local
return
}
// ReadFromWebVTT parses a .vtt content
// TODO Tags (u, i, b)
// TODO Class
func ReadFromWebVTT(i io.Reader) (o *Subtitles, err error) {
// Init
o = NewSubtitles()
var scanner = bufio.NewScanner(i)
var line string
var lineNum int
// Skip the header
for scanner.Scan() {
lineNum++
line = scanner.Text()
line = strings.TrimPrefix(line, string(BytesBOM))
if fs := strings.Fields(line); len(fs) > 0 && fs[0] == "WEBVTT" {
break
}
}
// Scan
var item = &Item{}
var blockName string
var comments []string
var index int
var timeOffset time.Duration
for scanner.Scan() {
// Fetch line
line = strings.TrimSpace(scanner.Text())
lineNum++
switch {
// Comment
case strings.HasPrefix(line, "NOTE "):
blockName = webvttBlockNameComment
comments = append(comments, strings.TrimPrefix(line, "NOTE "))
// Empty line
case len(line) == 0:
// Reset block name
blockName = ""
// Region
case strings.HasPrefix(line, "Region: "):
// Add region styles
var r = &Region{InlineStyle: &StyleAttributes{}}
for _, part := range strings.Split(strings.TrimPrefix(line, "Region: "), " ") {
// Split on "="
var split = strings.Split(part, "=")
if len(split) <= 1 {
err = fmt.Errorf("astisub: line %d: Invalid region style %s", lineNum, part)
return
}
// Switch on key
switch split[0] {
case "id":
r.ID = split[1]
case "lines":
if r.InlineStyle.WebVTTLines, err = strconv.Atoi(split[1]); err != nil {
err = fmt.Errorf("atoi of %s failed: %w", split[1], err)
return
}
case "regionanchor":
r.InlineStyle.WebVTTRegionAnchor = split[1]
case "scroll":
r.InlineStyle.WebVTTScroll = split[1]
case "viewportanchor":
r.InlineStyle.WebVTTViewportAnchor = split[1]
case "width":
r.InlineStyle.WebVTTWidth = split[1]
}
}
r.InlineStyle.propagateWebVTTAttributes()
// Add region
o.Regions[r.ID] = r
// Style
case strings.HasPrefix(line, "STYLE"):
blockName = webvttBlockNameStyle
// Time boundaries
case strings.Contains(line, webvttTimeBoundariesSeparator):
// Set block name
blockName = webvttBlockNameText
// Init new item
item = &Item{
Comments: comments,
Index: index,
InlineStyle: &StyleAttributes{},
}
// Reset index
index = 0
// Split line on time boundaries
var left = strings.Split(line, webvttTimeBoundariesSeparator)
// Split line on space to get remaining of time data
var right = strings.Split(left[1], " ")
// Parse time boundaries
if item.StartAt, err = parseDurationWebVTT(left[0]); err != nil {
err = fmt.Errorf("astisub: line %d: parsing webvtt duration %s failed: %w", lineNum, left[0], err)
return
}
if item.EndAt, err = parseDurationWebVTT(right[0]); err != nil {
err = fmt.Errorf("astisub: line %d: parsing webvtt duration %s failed: %w", lineNum, right[0], err)
return
}
// Parse style
if len(right) > 1 {
// Add styles
for index := 1; index < len(right); index++ {
// Empty
if right[index] == "" {
continue
}
// Split line on ":"
var split = strings.Split(right[index], ":")
if len(split) <= 1 {
err = fmt.Errorf("astisub: line %d: Invalid inline style '%s'", lineNum, right[index])
return
}
// Switch on key
switch split[0] {
case "align":
item.InlineStyle.WebVTTAlign = split[1]
case "line":
item.InlineStyle.WebVTTLine = split[1]
case "position":
item.InlineStyle.WebVTTPosition = split[1]
case "region":
if _, ok := o.Regions[split[1]]; !ok {
err = fmt.Errorf("astisub: line %d: Unknown region %s", lineNum, split[1])
return
}
item.Region = o.Regions[split[1]]
case "size":
item.InlineStyle.WebVTTSize = split[1]
case "vertical":
item.InlineStyle.WebVTTVertical = split[1]
}
}
}
item.InlineStyle.propagateWebVTTAttributes()
// Reset comments
comments = []string{}
// Append item
o.Items = append(o.Items, item)
case strings.HasPrefix(line, webvttTimestampMap):
if len(item.Lines) > 0 {
err = errors.New("astisub: found timestamp map after processing subtitle items")
return
}
timeOffset, err = parseTimestampMapWebVTT(line)
if err != nil {
err = fmt.Errorf("astisub: parsing webvtt timestamp map failed: %w", err)
return
}
// Text
default:
// Switch on block name
switch blockName {
case webvttBlockNameComment:
comments = append(comments, line)
case webvttBlockNameStyle:
// TODO Do something with the style
case webvttBlockNameText:
// Parse line
if l := parseTextWebVTT(line); len(l.Items) > 0 {
item.Lines = append(item.Lines, l)
}
default:
// This is the ID
index, _ = strconv.Atoi(line)
}
}
}
if timeOffset > 0 {
o.Add(timeOffset)
}
return
}
// parseTextWebVTT parses the input line to fill the Line
func parseTextWebVTT(i string) (o Line) {
// Create tokenizer
tr := html.NewTokenizer(strings.NewReader(i))
// Loop
italic := false
for {
// Get next tag
t := tr.Next()
// Process error
if err := tr.Err(); err != nil {
break
}
switch t {
case html.EndTagToken:
// Parse italic
if bytes.Equal(tr.Raw(), bytesWebVTTItalicEndTag) {
italic = false
continue
}
case html.StartTagToken:
// Parse voice name
if matches := webVTTRegexpStartTag.FindStringSubmatch(string(tr.Raw())); len(matches) > 3 {
if s := strings.TrimSpace(matches[3]); s != "" {
o.VoiceName = s
}
continue
}
// Parse italic
if bytes.Equal(tr.Raw(), bytesWebVTTItalicStartTag) {
italic = true
continue
}
case html.TextToken:
if s := strings.TrimSpace(string(tr.Raw())); s != "" {
// Get style attribute
var sa *StyleAttributes
if italic {
sa = &StyleAttributes{
WebVTTItalics: italic,
}
sa.propagateWebVTTAttributes()
}
// Append item
o.Items = append(o.Items, LineItem{
InlineStyle: sa,
Text: s,
})
}
}
}
return
}
// formatDurationWebVTT formats a .vtt duration
func formatDurationWebVTT(i time.Duration) string {
return formatDuration(i, ".", 3)
}
// WriteToWebVTT writes subtitles in .vtt format
func (s Subtitles) WriteToWebVTT(o io.Writer) (err error) {
// Do not write anything if no subtitles
if len(s.Items) == 0 {
err = ErrNoSubtitlesToWrite
return
}
// Add header
var c []byte
c = append(c, []byte("WEBVTT\n\n")...)
// Add regions
var k []string
for _, region := range s.Regions {
k = append(k, region.ID)
}
sort.Strings(k)
for _, id := range k {
c = append(c, []byte("Region: id="+s.Regions[id].ID)...)
if s.Regions[id].InlineStyle.WebVTTLines != 0 {
c = append(c, bytesSpace...)
c = append(c, []byte("lines="+strconv.Itoa(s.Regions[id].InlineStyle.WebVTTLines))...)
} else if s.Regions[id].Style != nil && s.Regions[id].Style.InlineStyle != nil && s.Regions[id].Style.InlineStyle.WebVTTLines != 0 {
c = append(c, bytesSpace...)
c = append(c, []byte("lines="+strconv.Itoa(s.Regions[id].Style.InlineStyle.WebVTTLines))...)
}
if s.Regions[id].InlineStyle.WebVTTRegionAnchor != "" {
c = append(c, bytesSpace...)
c = append(c, []byte("regionanchor="+s.Regions[id].InlineStyle.WebVTTRegionAnchor)...)
} else if s.Regions[id].Style != nil && s.Regions[id].Style.InlineStyle != nil && s.Regions[id].Style.InlineStyle.WebVTTRegionAnchor != "" {
c = append(c, bytesSpace...)
c = append(c, []byte("regionanchor="+s.Regions[id].Style.InlineStyle.WebVTTRegionAnchor)...)
}
if s.Regions[id].InlineStyle.WebVTTScroll != "" {
c = append(c, bytesSpace...)
c = append(c, []byte("scroll="+s.Regions[id].InlineStyle.WebVTTScroll)...)
} else if s.Regions[id].Style != nil && s.Regions[id].Style.InlineStyle != nil && s.Regions[id].Style.InlineStyle.WebVTTScroll != "" {
c = append(c, bytesSpace...)
c = append(c, []byte("scroll="+s.Regions[id].Style.InlineStyle.WebVTTScroll)...)
}
if s.Regions[id].InlineStyle.WebVTTViewportAnchor != "" {
c = append(c, bytesSpace...)
c = append(c, []byte("viewportanchor="+s.Regions[id].InlineStyle.WebVTTViewportAnchor)...)
} else if s.Regions[id].Style != nil && s.Regions[id].Style.InlineStyle != nil && s.Regions[id].Style.InlineStyle.WebVTTViewportAnchor != "" {
c = append(c, bytesSpace...)
c = append(c, []byte("viewportanchor="+s.Regions[id].Style.InlineStyle.WebVTTViewportAnchor)...)
}
if s.Regions[id].InlineStyle.WebVTTWidth != "" {
c = append(c, bytesSpace...)
c = append(c, []byte("width="+s.Regions[id].InlineStyle.WebVTTWidth)...)
} else if s.Regions[id].Style != nil && s.Regions[id].Style.InlineStyle != nil && s.Regions[id].Style.InlineStyle.WebVTTWidth != "" {
c = append(c, bytesSpace...)
c = append(c, []byte("width="+s.Regions[id].Style.InlineStyle.WebVTTWidth)...)
}
c = append(c, bytesLineSeparator...)
}
if len(s.Regions) > 0 {
c = append(c, bytesLineSeparator...)
}
// Loop through subtitles
for index, item := range s.Items {
// Add comments
if len(item.Comments) > 0 {
c = append(c, []byte("NOTE ")...)
for _, comment := range item.Comments {
c = append(c, []byte(comment)...)
c = append(c, bytesLineSeparator...)
}
c = append(c, bytesLineSeparator...)
}
// Add time boundaries
c = append(c, []byte(strconv.Itoa(index+1))...)
c = append(c, bytesLineSeparator...)
c = append(c, []byte(formatDurationWebVTT(item.StartAt))...)
c = append(c, bytesWebVTTTimeBoundariesSeparator...)
c = append(c, []byte(formatDurationWebVTT(item.EndAt))...)
// Add styles
if item.InlineStyle != nil {
if item.InlineStyle.WebVTTAlign != "" {
c = append(c, bytesSpace...)
c = append(c, []byte("align:"+item.InlineStyle.WebVTTAlign)...)
} else if item.Style != nil && item.Style.InlineStyle != nil && item.Style.InlineStyle.WebVTTAlign != "" {
c = append(c, bytesSpace...)
c = append(c, []byte("align:"+item.Style.InlineStyle.WebVTTAlign)...)
}
if item.InlineStyle.WebVTTLine != "" {
c = append(c, bytesSpace...)
c = append(c, []byte("line:"+item.InlineStyle.WebVTTLine)...)
} else if item.Style != nil && item.Style.InlineStyle != nil && item.Style.InlineStyle.WebVTTLine != "" {
c = append(c, bytesSpace...)
c = append(c, []byte("line:"+item.Style.InlineStyle.WebVTTLine)...)
}
if item.InlineStyle.WebVTTPosition != "" {
c = append(c, bytesSpace...)
c = append(c, []byte("position:"+item.InlineStyle.WebVTTPosition)...)
} else if item.Style != nil && item.Style.InlineStyle != nil && item.Style.InlineStyle.WebVTTPosition != "" {
c = append(c, bytesSpace...)
c = append(c, []byte("position:"+item.Style.InlineStyle.WebVTTPosition)...)
}
if item.Region != nil {
c = append(c, bytesSpace...)
c = append(c, []byte("region:"+item.Region.ID)...)
}
if item.InlineStyle.WebVTTSize != "" {
c = append(c, bytesSpace...)
c = append(c, []byte("size:"+item.InlineStyle.WebVTTSize)...)
} else if item.Style != nil && item.Style.InlineStyle != nil && item.Style.InlineStyle.WebVTTSize != "" {
c = append(c, bytesSpace...)
c = append(c, []byte("size:"+item.Style.InlineStyle.WebVTTSize)...)
}
if item.InlineStyle.WebVTTVertical != "" {
c = append(c, bytesSpace...)
c = append(c, []byte("vertical:"+item.InlineStyle.WebVTTVertical)...)
} else if item.Style != nil && item.Style.InlineStyle != nil && item.Style.InlineStyle.WebVTTVertical != "" {
c = append(c, bytesSpace...)
c = append(c, []byte("vertical:"+item.Style.InlineStyle.WebVTTVertical)...)
}
}
// Add new line
c = append(c, bytesLineSeparator...)
// Loop through lines
for _, l := range item.Lines {
c = append(c, l.webVTTBytes()...)
}
// Add new line
c = append(c, bytesLineSeparator...)
}
// Remove last new line
c = c[:len(c)-1]
// Write
if _, err = o.Write(c); err != nil {
err = fmt.Errorf("astisub: writing failed: %w", err)
return
}
return
}
func (l Line) webVTTBytes() (c []byte) {
if l.VoiceName != "" {
c = append(c, []byte("<v "+l.VoiceName+">")...)
}
for idx, li := range l.Items {
c = append(c, li.webVTTBytes()...)
// condition to avoid adding space as the last character.
if idx < len(l.Items)-1 {
c = append(c, []byte(" ")...)
}
}
c = append(c, bytesLineSeparator...)
return
}
func (li LineItem) webVTTBytes() (c []byte) {
// Get color
var color string
if li.InlineStyle != nil && li.InlineStyle.TTMLColor != nil {
color = cssColor(*li.InlineStyle.TTMLColor)
}
// Get italics
i := li.InlineStyle != nil && li.InlineStyle.WebVTTItalics
// Append
if color != "" {
c = append(c, []byte("<c."+color+">")...)
}
if i {
c = append(c, []byte("<i>")...)
}
c = append(c, []byte(li.Text)...)
if i {
c = append(c, []byte("</i>")...)
}
if color != "" {
c = append(c, []byte("</c>")...)
}
return
}
func cssColor(rgb string) string {
colors := map[string]string{
"#00ffff": "cyan", // narrator, thought
"#ffff00": "yellow", // out of vision
"#ff0000": "red", // noises
"#ff00ff": "magenta", // song
"#00ff00": "lime", // foreign speak
}
return colors[strings.ToLower(rgb)] // returning the empty string is ok
}