Skip to content

Commit 995c70a

Browse files
authored
Improve WebVTT to TTML conversion (#132)
* improve WebVTT to TTML conversion * introduce a WebVTTPosition struct to store x-position and optional alignment * remove unused map * include propagateWebVTTPosition into propagateWebVTTAttributes
1 parent 1e38856 commit 995c70a

6 files changed

Lines changed: 268 additions & 26 deletions

File tree

subtitles.go

Lines changed: 79 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,7 @@ type StyleAttributes struct {
255255
WebVTTItalics bool
256256
WebVTTLine string
257257
WebVTTLines int
258-
WebVTTPosition string
258+
WebVTTPosition *WebVTTPosition
259259
WebVTTRegionAnchor string
260260
WebVTTScroll string
261261
WebVTTSize string
@@ -307,28 +307,28 @@ func (sa *StyleAttributes) propagateSRTAttributes() {
307307
switch sa.SRTPosition {
308308
case 7: // top-left
309309
sa.WebVTTAlign = "left"
310-
sa.WebVTTPosition = "10%"
310+
sa.WebVTTPosition = newWebVTTPosition("10%")
311311
case 8: // top-center
312-
sa.WebVTTPosition = "10%"
312+
sa.WebVTTPosition = newWebVTTPosition("10%")
313313
case 9: // top-right
314314
sa.WebVTTAlign = "right"
315-
sa.WebVTTPosition = "10%"
315+
sa.WebVTTPosition = newWebVTTPosition("10%")
316316
case 4: // middle-left
317317
sa.WebVTTAlign = "left"
318-
sa.WebVTTPosition = "50%"
318+
sa.WebVTTPosition = newWebVTTPosition("50%")
319319
case 5: // middle-center
320-
sa.WebVTTPosition = "50%"
320+
sa.WebVTTPosition = newWebVTTPosition("50%")
321321
case 6: // middle-right
322322
sa.WebVTTAlign = "right"
323-
sa.WebVTTPosition = "50%"
323+
sa.WebVTTPosition = newWebVTTPosition("50%")
324324
case 1: // bottom-left
325325
sa.WebVTTAlign = "left"
326-
sa.WebVTTPosition = "90%"
326+
sa.WebVTTPosition = newWebVTTPosition("90%")
327327
case 2: // bottom-center
328-
sa.WebVTTPosition = "90%"
328+
sa.WebVTTPosition = newWebVTTPosition("90%")
329329
case 3: // bottom-right
330330
sa.WebVTTAlign = "right"
331-
sa.WebVTTPosition = "90%"
331+
sa.WebVTTPosition = newWebVTTPosition("90%")
332332
}
333333

334334
sa.WebVTTBold = sa.SRTBold
@@ -411,10 +411,10 @@ func (sa *StyleAttributes) propagateTTMLAttributes() {
411411
coordinates := strings.Split(*sa.TTMLOrigin, " ")
412412
if len(coordinates) > 1 {
413413
sa.WebVTTLine = coordinates[0]
414-
sa.WebVTTPosition = coordinates[1]
414+
sa.WebVTTPosition = newWebVTTPosition(coordinates[1])
415415
if sa.TTMLWritingMode != nil && strings.HasPrefix(*sa.TTMLWritingMode, "tb") {
416416
sa.WebVTTLine = coordinates[1]
417-
sa.WebVTTPosition = coordinates[0]
417+
sa.WebVTTPosition = newWebVTTPosition(coordinates[0])
418418
}
419419
}
420420
}
@@ -428,6 +428,73 @@ func (sa *StyleAttributes) propagateWebVTTAttributes() {
428428
sa.SRTBold = sa.WebVTTBold
429429
sa.SRTItalics = sa.WebVTTItalics
430430
sa.SRTUnderline = sa.WebVTTUnderline
431+
432+
// may be overridden by position parsing later
433+
switch sa.WebVTTAlign {
434+
case "left", "right", "center", "start", "end":
435+
sa.TTMLTextAlign = astikit.StrPtr(sa.WebVTTAlign)
436+
}
437+
438+
for _, tag := range sa.WebVTTTags {
439+
switch tag.Name {
440+
case "c":
441+
if len(tag.Classes) > 0 {
442+
for _, color := range tag.Classes {
443+
if strings.HasPrefix(color, "bg_") && len(color) > 3 {
444+
if bgColor, err := newColorFromWebVTTString(color[3:]); err == nil {
445+
sa.TTMLBackgroundColor = astikit.StrPtr("#" + bgColor.TTMLString())
446+
}
447+
} else {
448+
if fgColor, err := newColorFromWebVTTString(color); err == nil {
449+
sa.TTMLColor = astikit.StrPtr("#" + fgColor.TTMLString())
450+
}
451+
}
452+
}
453+
}
454+
}
455+
}
456+
457+
// Handle WebVTT position and alignment conversion
458+
459+
// Parse position if available
460+
var hasPosition bool
461+
if sa.WebVTTPosition != nil {
462+
hasPosition = true
463+
464+
// Handle position alignment (takes precedence over WebVTTAlign)
465+
switch sa.WebVTTPosition.Alignment {
466+
case "line-left":
467+
sa.TTMLTextAlign = astikit.StrPtr("left")
468+
case "center":
469+
sa.TTMLTextAlign = astikit.StrPtr("center")
470+
case "line-right":
471+
sa.TTMLTextAlign = astikit.StrPtr("right")
472+
}
473+
}
474+
475+
// Handle line if available
476+
var hasLine bool
477+
var yPos string
478+
if sa.WebVTTLine != "" {
479+
hasLine = true
480+
yPos = sa.WebVTTLine
481+
}
482+
483+
// Set TTMLOrigin based on available position and line data
484+
if hasPosition && hasLine {
485+
// Both position and line are available
486+
sa.TTMLOrigin = astikit.StrPtr(fmt.Sprintf("%s %s", sa.WebVTTPosition.XPosition, yPos))
487+
} else if hasPosition {
488+
// Only position is available, use default Y position (80% for bottom)
489+
sa.TTMLOrigin = astikit.StrPtr(fmt.Sprintf("%s 80%%", sa.WebVTTPosition.XPosition))
490+
} else if hasLine {
491+
// Only line is available, use default X position (10% for left)
492+
sa.TTMLOrigin = astikit.StrPtr(fmt.Sprintf("10%% %s", yPos))
493+
}
494+
495+
if sa.WebVTTSize != "" {
496+
sa.TTMLExtent = astikit.StrPtr(fmt.Sprintf("%s 10%%", sa.WebVTTSize))
497+
}
431498
}
432499

433500
// merge - base on parent, override style attributes if defined in child

testdata/example-in.vtt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ NOTE This a comment inside the VTT
3333
and this is the second line
3434
3535
2
36-
00:02:04.08 --> 00:02:07.12 region:fred position:10%,start align:left size:35%
36+
00:02:04.08 --> 00:02:07.12 region:fred position:10%,line-left align:left size:35%
3737
MAN:
3838
How did we end up here?
3939

testdata/example-out.vtt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ NOTE This a comment inside the VTT
2929
and this is the second line
3030
3131
2
32-
00:02:04.080 --> 00:02:07.120 align:left position:10%,start region:fred size:35%
32+
00:02:04.080 --> 00:02:07.120 align:left position:10%,line-left region:fred size:35%
3333
MAN:
3434
How did we end up here?
3535

ttml_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,9 @@ func TestTTML(t *testing.T) {
2121
assert.Equal(t, &astisub.Metadata{Framerate: 25, Language: astisub.LanguageFrench, Title: "Title test", TTMLCopyright: "Copyright test"}, s.Metadata)
2222
// Styles
2323
assert.Equal(t, 3, len(s.Styles))
24-
assert.Equal(t, astisub.Style{ID: "style_0", InlineStyle: &astisub.StyleAttributes{TTMLColor: astikit.StrPtr("white"), TTMLExtent: astikit.StrPtr("100% 10%"), TTMLFontFamily: astikit.StrPtr("sansSerif"), TTMLFontStyle: astikit.StrPtr("normal"), TTMLOrigin: astikit.StrPtr("0% 90%"), TTMLTextAlign: astikit.StrPtr("center"), WebVTTAlign: "center", WebVTTLine: "0%", WebVTTLines: 2, WebVTTPosition: "90%", WebVTTRegionAnchor: "0%,0%", WebVTTScroll: "up", WebVTTSize: "10%", WebVTTViewportAnchor: "0%,90%", WebVTTWidth: "100%"}, Style: s.Styles["style_2"]}, *s.Styles["style_0"])
25-
assert.Equal(t, astisub.Style{ID: "style_1", InlineStyle: &astisub.StyleAttributes{TTMLColor: astikit.StrPtr("white"), TTMLExtent: astikit.StrPtr("100% 13%"), TTMLFontFamily: astikit.StrPtr("sansSerif"), TTMLFontStyle: astikit.StrPtr("normal"), TTMLOrigin: astikit.StrPtr("0% 87%"), TTMLTextAlign: astikit.StrPtr("center"), WebVTTAlign: "center", WebVTTLine: "0%", WebVTTLines: 2, WebVTTPosition: "87%", WebVTTRegionAnchor: "0%,0%", WebVTTScroll: "up", WebVTTSize: "13%", WebVTTViewportAnchor: "0%,87%", WebVTTWidth: "100%"}}, *s.Styles["style_1"])
26-
assert.Equal(t, astisub.Style{ID: "style_2", InlineStyle: &astisub.StyleAttributes{TTMLColor: astikit.StrPtr("white"), TTMLExtent: astikit.StrPtr("100% 20%"), TTMLFontFamily: astikit.StrPtr("sansSerif"), TTMLFontStyle: astikit.StrPtr("normal"), TTMLOrigin: astikit.StrPtr("0% 80%"), TTMLTextAlign: astikit.StrPtr("center"), WebVTTAlign: "center", WebVTTLine: "0%", WebVTTLines: 4, WebVTTPosition: "80%", WebVTTRegionAnchor: "0%,0%", WebVTTScroll: "up", WebVTTSize: "20%", WebVTTViewportAnchor: "0%,80%", WebVTTWidth: "100%"}}, *s.Styles["style_2"])
24+
assert.Equal(t, astisub.Style{ID: "style_0", InlineStyle: &astisub.StyleAttributes{TTMLColor: astikit.StrPtr("white"), TTMLExtent: astikit.StrPtr("100% 10%"), TTMLFontFamily: astikit.StrPtr("sansSerif"), TTMLFontStyle: astikit.StrPtr("normal"), TTMLOrigin: astikit.StrPtr("0% 90%"), TTMLTextAlign: astikit.StrPtr("center"), WebVTTAlign: "center", WebVTTLine: "0%", WebVTTLines: 2, WebVTTPosition: &astisub.WebVTTPosition{XPosition: "90%"}, WebVTTRegionAnchor: "0%,0%", WebVTTScroll: "up", WebVTTSize: "10%", WebVTTViewportAnchor: "0%,90%", WebVTTWidth: "100%"}, Style: s.Styles["style_2"]}, *s.Styles["style_0"])
25+
assert.Equal(t, astisub.Style{ID: "style_1", InlineStyle: &astisub.StyleAttributes{TTMLColor: astikit.StrPtr("white"), TTMLExtent: astikit.StrPtr("100% 13%"), TTMLFontFamily: astikit.StrPtr("sansSerif"), TTMLFontStyle: astikit.StrPtr("normal"), TTMLOrigin: astikit.StrPtr("0% 87%"), TTMLTextAlign: astikit.StrPtr("center"), WebVTTAlign: "center", WebVTTLine: "0%", WebVTTLines: 2, WebVTTPosition: &astisub.WebVTTPosition{XPosition: "87%"}, WebVTTRegionAnchor: "0%,0%", WebVTTScroll: "up", WebVTTSize: "13%", WebVTTViewportAnchor: "0%,87%", WebVTTWidth: "100%"}}, *s.Styles["style_1"])
26+
assert.Equal(t, astisub.Style{ID: "style_2", InlineStyle: &astisub.StyleAttributes{TTMLColor: astikit.StrPtr("white"), TTMLExtent: astikit.StrPtr("100% 20%"), TTMLFontFamily: astikit.StrPtr("sansSerif"), TTMLFontStyle: astikit.StrPtr("normal"), TTMLOrigin: astikit.StrPtr("0% 80%"), TTMLTextAlign: astikit.StrPtr("center"), WebVTTAlign: "center", WebVTTLine: "0%", WebVTTLines: 4, WebVTTPosition: &astisub.WebVTTPosition{XPosition: "80%"}, WebVTTRegionAnchor: "0%,0%", WebVTTScroll: "up", WebVTTSize: "20%", WebVTTViewportAnchor: "0%,80%", WebVTTWidth: "100%"}}, *s.Styles["style_2"])
2727
// Regions
2828
assert.Equal(t, 3, len(s.Regions))
2929
assert.Equal(t, astisub.Region{ID: "region_0", Style: s.Styles["style_0"], InlineStyle: &astisub.StyleAttributes{TTMLColor: astikit.StrPtr("blue")}}, *s.Regions["region_0"])

webvtt.go

Lines changed: 91 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,39 @@ var (
3737
webVTTRegexpTag = regexp.MustCompile(`(</*\s*([^\.\s]+)(\.[^\s/]*)*\s*([^/]*)\s*/*>)`)
3838
)
3939

40+
type WebVTTPosition struct {
41+
XPosition string
42+
Alignment string
43+
}
44+
45+
// newWebVTTPosition creates a new WebVTTPosition from a string.
46+
// The string can be in the format "XPosition,Alignment" or just "XPosition".
47+
func newWebVTTPosition(s string) *WebVTTPosition {
48+
if s == "" {
49+
return nil
50+
}
51+
52+
parts := strings.Split(s, ",")
53+
if len(parts) != 2 {
54+
return &WebVTTPosition{XPosition: strings.TrimSpace(s)}
55+
}
56+
57+
return &WebVTTPosition{
58+
XPosition: strings.TrimSpace(parts[0]),
59+
Alignment: strings.TrimSpace(parts[1]),
60+
}
61+
}
62+
63+
func (p *WebVTTPosition) String() string {
64+
if p == nil {
65+
return ""
66+
}
67+
if p.Alignment != "" {
68+
return fmt.Sprintf("%s,%s", p.XPosition, p.Alignment)
69+
}
70+
return p.XPosition
71+
}
72+
4073
// parseDurationWebVTT parses a .vtt duration
4174
func parseDurationWebVTT(i string) (time.Duration, error) {
4275
return parseDuration(i, ".", 3)
@@ -272,7 +305,7 @@ func ReadFromWebVTT(i io.Reader) (o *Subtitles, err error) {
272305
case "line":
273306
item.InlineStyle.WebVTTLine = split[1]
274307
case "position":
275-
item.InlineStyle.WebVTTPosition = split[1]
308+
item.InlineStyle.WebVTTPosition = newWebVTTPosition(split[1])
276309
case "region":
277310
if _, ok := o.Regions[split[1]]; !ok {
278311
err = fmt.Errorf("astisub: line %d: Unknown region %s", lineNum, split[1])
@@ -574,12 +607,12 @@ func (s Subtitles) WriteToWebVTT(o io.Writer) (err error) {
574607
c = append(c, bytesSpace...)
575608
c = append(c, []byte("line:"+item.Style.InlineStyle.WebVTTLine)...)
576609
}
577-
if item.InlineStyle.WebVTTPosition != "" {
610+
if item.InlineStyle.WebVTTPosition != nil {
578611
c = append(c, bytesSpace...)
579-
c = append(c, []byte("position:"+item.InlineStyle.WebVTTPosition)...)
580-
} else if item.Style != nil && item.Style.InlineStyle != nil && item.Style.InlineStyle.WebVTTPosition != "" {
612+
c = append(c, []byte("position:"+item.InlineStyle.WebVTTPosition.String())...)
613+
} else if item.Style != nil && item.Style.InlineStyle != nil && item.Style.InlineStyle.WebVTTPosition != nil {
581614
c = append(c, bytesSpace...)
582-
c = append(c, []byte("position:"+item.Style.InlineStyle.WebVTTPosition)...)
615+
c = append(c, []byte("position:"+item.Style.InlineStyle.WebVTTPosition.String())...)
583616
}
584617
if item.Region != nil {
585618
c = append(c, bytesSpace...)
@@ -648,10 +681,21 @@ func (li LineItem) webVTTBytes(previous, next *LineItem) (c []byte) {
648681
c = append(c, []byte("<"+formatDurationWebVTT(li.StartAt)+">")...)
649682
}
650683

651-
// Get color
684+
// Get color - only add TTMLColor-based tag if there are no WebVTT color tags
652685
var color string
653-
if li.InlineStyle != nil && li.InlineStyle.TTMLColor != nil {
654-
color = cssColor(*li.InlineStyle.TTMLColor)
686+
var hasColorTags bool
687+
if li.InlineStyle != nil {
688+
// Check if we have WebVTT color tags
689+
for _, tag := range li.InlineStyle.WebVTTTags {
690+
if tag.Name == "c" {
691+
hasColorTags = true
692+
break
693+
}
694+
}
695+
// Only use TTMLColor if we don't have WebVTT color tags
696+
if !hasColorTags && li.InlineStyle.TTMLColor != nil {
697+
color = cssColor(*li.InlineStyle.TTMLColor)
698+
}
655699
}
656700

657701
// Append
@@ -692,3 +736,42 @@ func cssColor(rgb string) string {
692736
}
693737
return colors[strings.ToLower(rgb)] // returning the empty string is ok
694738
}
739+
740+
func newColorFromWebVTTString(color string) (*Color, error) {
741+
switch color {
742+
case "black":
743+
return ColorBlack, nil
744+
case "red":
745+
return ColorRed, nil
746+
case "green":
747+
return ColorGreen, nil
748+
case "yellow":
749+
return ColorYellow, nil
750+
case "blue":
751+
return ColorBlue, nil
752+
case "magenta":
753+
return ColorMagenta, nil
754+
case "cyan":
755+
return ColorCyan, nil
756+
case "white":
757+
return ColorWhite, nil
758+
case "silver":
759+
return ColorSilver, nil
760+
case "gray":
761+
return ColorGray, nil
762+
case "maroon":
763+
return ColorMaroon, nil
764+
case "olive":
765+
return ColorOlive, nil
766+
case "lime":
767+
return ColorLime, nil
768+
case "teal":
769+
return ColorTeal, nil
770+
case "navy":
771+
return ColorNavy, nil
772+
case "purple":
773+
return ColorPurple, nil
774+
default:
775+
return nil, fmt.Errorf("unknown color class %s", color)
776+
}
777+
}

0 commit comments

Comments
 (0)