stash/vendor/github.com/asticode/go-astisub/ttml.go
cj c1a096a1a6
Caption support (#2462)
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2022-05-06 11:59:28 +10:00

686 lines
20 KiB
Go

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
}