From 8d7844d0cb74f52090d176ea05a10b623780e2b2 Mon Sep 17 00:00:00 2001 From: David Le Corfec Date: Thu, 30 Oct 2025 23:20:35 +0100 Subject: [PATCH 1/4] improve WebVTT to TTML conversion --- subtitles.go | 88 +++++++++++++++++++++++++++++++++++++ testdata/example-in.vtt | 2 +- testdata/example-out.vtt | 2 +- webvtt.go | 44 +++++++++++++++++-- webvtt_test.go | 94 +++++++++++++++++++++++++++++++++++++++- 5 files changed, 224 insertions(+), 6 deletions(-) diff --git a/subtitles.go b/subtitles.go index c200e17..6581da5 100644 --- a/subtitles.go +++ b/subtitles.go @@ -428,6 +428,94 @@ func (sa *StyleAttributes) propagateWebVTTAttributes() { sa.SRTBold = sa.WebVTTBold sa.SRTItalics = sa.WebVTTItalics sa.SRTUnderline = sa.WebVTTUnderline + + // may be overridden by position parsing later + switch sa.WebVTTAlign { + case "left", "right", "center", "start", "end": + sa.TTMLTextAlign = astikit.StrPtr(sa.WebVTTAlign) + } + + for _, tag := range sa.WebVTTTags { + switch tag.Name { + case "c": + if len(tag.Classes) > 0 { + for _, color := range tag.Classes { + if strings.HasPrefix(color, "bg_") && len(color) > 3 { + if bgColor, err := parseWebVTTColorClass(color[3:]); err == nil { + sa.TTMLBackgroundColor = astikit.StrPtr("#" + bgColor.TTMLString()) + } + } else { + if fgColor, err := parseWebVTTColorClass(color); err == nil { + sa.TTMLColor = astikit.StrPtr("#" + fgColor.TTMLString()) + } + } + } + } + } + } + + // Handle WebVTT position and alignment conversion + sa.handleWebVTTPositioning() + + if sa.WebVTTSize != "" { + sa.TTMLExtent = astikit.StrPtr(fmt.Sprintf("%s 10%%", sa.WebVTTSize)) + } +} + +// parseWebVTTPosition parses WebVTT position string like "10%,line-left". +// Returns (xPosition, alignment) +func (sa *StyleAttributes) parseWebVTTPosition() (string, string) { + if sa.WebVTTPosition == "" { + return "", "" + } + + parts := strings.Split(sa.WebVTTPosition, ",") + if len(parts) != 2 { + return sa.WebVTTPosition, "" + } + + return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]) +} + +// handleWebVTTPositioning handles WebVTT position and line attributes conversion +func (sa *StyleAttributes) handleWebVTTPositioning() { + var xPos, yPos string + var hasPosition, hasLine bool + + // Parse position if available + if sa.WebVTTPosition != "" { + hasPosition = true + var alignment string + xPos, alignment = sa.parseWebVTTPosition() + + // Handle position alignment (takes precedence over WebVTTAlign) + switch alignment { + case "line-left": + sa.TTMLTextAlign = astikit.StrPtr("left") + case "center": + sa.TTMLTextAlign = astikit.StrPtr("center") + case "line-right": + sa.TTMLTextAlign = astikit.StrPtr("right") + } + } + + // Handle line if available + if sa.WebVTTLine != "" { + hasLine = true + yPos = sa.WebVTTLine + } + + // Set TTMLOrigin based on available position and line data + if hasPosition && hasLine { + // Both position and line are available + sa.TTMLOrigin = astikit.StrPtr(fmt.Sprintf("%s %s", xPos, yPos)) + } else if hasPosition { + // Only position is available, use default Y position (80% for bottom) + sa.TTMLOrigin = astikit.StrPtr(fmt.Sprintf("%s 80%%", xPos)) + } else if hasLine { + // Only line is available, use default X position (10% for left) + sa.TTMLOrigin = astikit.StrPtr(fmt.Sprintf("10%% %s", yPos)) + } } // merge - base on parent, override style attributes if defined in child diff --git a/testdata/example-in.vtt b/testdata/example-in.vtt index 9a95149..ea8bdba 100644 --- a/testdata/example-in.vtt +++ b/testdata/example-in.vtt @@ -33,7 +33,7 @@ NOTE This a comment inside the VTT and this is the second line 2 -00:02:04.08 --> 00:02:07.12 region:fred position:10%,start align:left size:35% +00:02:04.08 --> 00:02:07.12 region:fred position:10%,line-left align:left size:35% MAN: How did we end up here? diff --git a/testdata/example-out.vtt b/testdata/example-out.vtt index 0eb8974..734a533 100644 --- a/testdata/example-out.vtt +++ b/testdata/example-out.vtt @@ -29,7 +29,7 @@ NOTE This a comment inside the VTT and this is the second line 2 -00:02:04.080 --> 00:02:07.120 align:left position:10%,start region:fred size:35% +00:02:04.080 --> 00:02:07.120 align:left position:10%,line-left region:fred size:35% MAN: How did we end up here? diff --git a/webvtt.go b/webvtt.go index 18e020a..ad7020c 100644 --- a/webvtt.go +++ b/webvtt.go @@ -648,10 +648,21 @@ func (li LineItem) webVTTBytes(previous, next *LineItem) (c []byte) { c = append(c, []byte("<"+formatDurationWebVTT(li.StartAt)+">")...) } - // Get color + // Get color - only add TTMLColor-based tag if there are no WebVTT color tags var color string - if li.InlineStyle != nil && li.InlineStyle.TTMLColor != nil { - color = cssColor(*li.InlineStyle.TTMLColor) + var hasColorTags bool + if li.InlineStyle != nil { + // Check if we have WebVTT color tags + for _, tag := range li.InlineStyle.WebVTTTags { + if tag.Name == "c" { + hasColorTags = true + break + } + } + // Only use TTMLColor if we don't have WebVTT color tags + if !hasColorTags && li.InlineStyle.TTMLColor != nil { + color = cssColor(*li.InlineStyle.TTMLColor) + } } // Append @@ -692,3 +703,30 @@ func cssColor(rgb string) string { } return colors[strings.ToLower(rgb)] // returning the empty string is ok } + +// webVTTColorMap maps WebVTT color class names to Color instances +var webVTTColorMap = map[string]*Color{ + "black": ColorBlack, + "red": ColorRed, + "green": ColorGreen, + "yellow": ColorYellow, + "blue": ColorBlue, + "magenta": ColorMagenta, + "cyan": ColorCyan, + "white": ColorWhite, + "silver": ColorSilver, + "gray": ColorGray, + "maroon": ColorMaroon, + "olive": ColorOlive, + "lime": ColorLime, + "teal": ColorTeal, + "navy": ColorNavy, + "purple": ColorPurple, +} + +func parseWebVTTColorClass(color string) (*Color, error) { + if c, ok := webVTTColorMap[color]; ok { + return c, nil + } + return nil, fmt.Errorf("unknown color class %s", color) +} diff --git a/webvtt_test.go b/webvtt_test.go index 3bcf2e6..9b46bbe 100644 --- a/webvtt_test.go +++ b/webvtt_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/asticode/go-astikit" "github.com/asticode/go-astisub" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -27,7 +28,16 @@ func TestWebVTT(t *testing.T) { assert.Equal(t, s.Regions["bill"], s.Items[0].Region) assert.Equal(t, s.Regions["fred"], s.Items[1].Region) // Styles - assert.Equal(t, astisub.StyleAttributes{WebVTTAlign: "left", WebVTTPosition: "10%,start", WebVTTSize: "35%"}, *s.Items[1].InlineStyle) + expected := astisub.StyleAttributes{ + WebVTTAlign: "left", + WebVTTPosition: "10%,line-left", + WebVTTSize: "35%", + } + // propagateWebVTTAttributes() sets these based on WebVTT attributes + expected.TTMLTextAlign = astikit.StrPtr("left") // From WebVTTAlign since "start" is not a valid position alignment + expected.TTMLOrigin = astikit.StrPtr("10% 80%") // From position: 10% X, 80% Y (default when no line specified) + expected.TTMLExtent = astikit.StrPtr("35% 10%") // From size: 35% width, 10% height (default when no line specified) + assert.Equal(t, expected, *s.Items[1].InlineStyle) // No subtitles to write w := &bytes.Buffer{} @@ -255,3 +265,85 @@ func TestWebVTTParseDuration(t *testing.T) { assert.NotNil(t, s.Items[1].InlineStyle) assert.Equal(t, s.Items[1].InlineStyle.WebVTTAlign, "middle") } + +func TestWebVTTColorToTTML(t *testing.T) { + testData := `WEBVTT + +1 +00:00:01.000 --> 00:00:03.000 +Red text and blue text on yellow background + +2 +00:00:04.000 --> 00:00:06.000 +Green text with text on cyan background + +3 +00:00:07.000 --> 00:00:09.000 +Normal text with magenta and unknown color` + + s, err := astisub.ReadFromWebVTT(strings.NewReader(testData)) + require.NoError(t, err) + require.Len(t, s.Items, 3) + + // Test item 1: Red text and blue text on yellow background + item1 := s.Items[0] + require.Len(t, item1.Lines, 1) + require.Len(t, item1.Lines[0].Items, 3) // "Red text", " and ", "blue text on yellow background" + + // Check red text + redItem := item1.Lines[0].Items[0] + assert.Equal(t, "Red text", redItem.Text) + require.NotNil(t, redItem.InlineStyle) + require.NotNil(t, redItem.InlineStyle.TTMLColor) + assert.Equal(t, "#ff0000", *redItem.InlineStyle.TTMLColor) // Red color + assert.Nil(t, redItem.InlineStyle.TTMLBackgroundColor) + + // Check blue text on yellow background + blueYellowItem := item1.Lines[0].Items[2] + assert.Equal(t, "blue text on yellow background", blueYellowItem.Text) + require.NotNil(t, blueYellowItem.InlineStyle) + require.NotNil(t, blueYellowItem.InlineStyle.TTMLColor) + require.NotNil(t, blueYellowItem.InlineStyle.TTMLBackgroundColor) + assert.Equal(t, "#0000ff", *blueYellowItem.InlineStyle.TTMLColor) // Blue color + assert.Equal(t, "#ffff00", *blueYellowItem.InlineStyle.TTMLBackgroundColor) // Yellow background + + // Test item 2: Green text and background color only + item2 := s.Items[1] + require.Len(t, item2.Lines, 1) + require.Len(t, item2.Lines[0].Items, 3) // "Green text", " with ", "text on cyan background" + + // Check green text + greenItem := item2.Lines[0].Items[0] + assert.Equal(t, "Green text", greenItem.Text) + require.NotNil(t, greenItem.InlineStyle) + require.NotNil(t, greenItem.InlineStyle.TTMLColor) + assert.Equal(t, "#008000", *greenItem.InlineStyle.TTMLColor) // Green color + assert.Nil(t, greenItem.InlineStyle.TTMLBackgroundColor) + + // Check text with cyan background only + cyanBgItem := item2.Lines[0].Items[2] + assert.Equal(t, "text on cyan background", cyanBgItem.Text) + require.NotNil(t, cyanBgItem.InlineStyle) + assert.Nil(t, cyanBgItem.InlineStyle.TTMLColor) // No foreground color specified + require.NotNil(t, cyanBgItem.InlineStyle.TTMLBackgroundColor) + assert.Equal(t, "#00ffff", *cyanBgItem.InlineStyle.TTMLBackgroundColor) // Cyan background + + // Test item 3: Known and unknown colors + item3 := s.Items[2] + require.Len(t, item3.Lines, 1) + require.Len(t, item3.Lines[0].Items, 4) // "Normal text with ", "magenta", " and ", "unknown color" + + // Check magenta text (known color) + magentaItem := item3.Lines[0].Items[1] + assert.Equal(t, "magenta", magentaItem.Text) + require.NotNil(t, magentaItem.InlineStyle) + require.NotNil(t, magentaItem.InlineStyle.TTMLColor) + assert.Equal(t, "#ff00ff", *magentaItem.InlineStyle.TTMLColor) // Magenta color + + // Check unknown color (should not set TTMLColor because "orange" is not in webVTTColorMap) + unknownColorItem := item3.Lines[0].Items[3] + assert.Equal(t, "unknown color", unknownColorItem.Text) + require.NotNil(t, unknownColorItem.InlineStyle) + assert.Nil(t, unknownColorItem.InlineStyle.TTMLColor) // Unknown color should not be converted + assert.Nil(t, unknownColorItem.InlineStyle.TTMLBackgroundColor) +} From e6a64ebb337f9cad834fc715fbd16f183b4106d6 Mon Sep 17 00:00:00 2001 From: David Le Corfec Date: Tue, 4 Nov 2025 14:05:04 +0100 Subject: [PATCH 2/4] introduce a WebVTTPosition struct to store x-position and optional alignment --- subtitles.go | 61 +++++++++++++------------------------ ttml_test.go | 6 ++-- webvtt.go | 83 ++++++++++++++++++++++++++++++++++++++++++++------ webvtt_test.go | 2 +- 4 files changed, 100 insertions(+), 52 deletions(-) diff --git a/subtitles.go b/subtitles.go index 6581da5..4bdb062 100644 --- a/subtitles.go +++ b/subtitles.go @@ -255,7 +255,7 @@ type StyleAttributes struct { WebVTTItalics bool WebVTTLine string WebVTTLines int - WebVTTPosition string + WebVTTPosition *WebVTTPosition WebVTTRegionAnchor string WebVTTScroll string WebVTTSize string @@ -307,28 +307,28 @@ func (sa *StyleAttributes) propagateSRTAttributes() { switch sa.SRTPosition { case 7: // top-left sa.WebVTTAlign = "left" - sa.WebVTTPosition = "10%" + sa.WebVTTPosition = newWebVTTPosition("10%") case 8: // top-center - sa.WebVTTPosition = "10%" + sa.WebVTTPosition = newWebVTTPosition("10%") case 9: // top-right sa.WebVTTAlign = "right" - sa.WebVTTPosition = "10%" + sa.WebVTTPosition = newWebVTTPosition("10%") case 4: // middle-left sa.WebVTTAlign = "left" - sa.WebVTTPosition = "50%" + sa.WebVTTPosition = newWebVTTPosition("50%") case 5: // middle-center - sa.WebVTTPosition = "50%" + sa.WebVTTPosition = newWebVTTPosition("50%") case 6: // middle-right sa.WebVTTAlign = "right" - sa.WebVTTPosition = "50%" + sa.WebVTTPosition = newWebVTTPosition("50%") case 1: // bottom-left sa.WebVTTAlign = "left" - sa.WebVTTPosition = "90%" + sa.WebVTTPosition = newWebVTTPosition("90%") case 2: // bottom-center - sa.WebVTTPosition = "90%" + sa.WebVTTPosition = newWebVTTPosition("90%") case 3: // bottom-right sa.WebVTTAlign = "right" - sa.WebVTTPosition = "90%" + sa.WebVTTPosition = newWebVTTPosition("90%") } sa.WebVTTBold = sa.SRTBold @@ -411,10 +411,10 @@ func (sa *StyleAttributes) propagateTTMLAttributes() { coordinates := strings.Split(*sa.TTMLOrigin, " ") if len(coordinates) > 1 { sa.WebVTTLine = coordinates[0] - sa.WebVTTPosition = coordinates[1] + sa.WebVTTPosition = newWebVTTPosition(coordinates[1]) if sa.TTMLWritingMode != nil && strings.HasPrefix(*sa.TTMLWritingMode, "tb") { sa.WebVTTLine = coordinates[1] - sa.WebVTTPosition = coordinates[0] + sa.WebVTTPosition = newWebVTTPosition(coordinates[0]) } } } @@ -441,11 +441,11 @@ func (sa *StyleAttributes) propagateWebVTTAttributes() { if len(tag.Classes) > 0 { for _, color := range tag.Classes { if strings.HasPrefix(color, "bg_") && len(color) > 3 { - if bgColor, err := parseWebVTTColorClass(color[3:]); err == nil { + if bgColor, err := newColorFromWebVTTString(color[3:]); err == nil { sa.TTMLBackgroundColor = astikit.StrPtr("#" + bgColor.TTMLString()) } } else { - if fgColor, err := parseWebVTTColorClass(color); err == nil { + if fgColor, err := newColorFromWebVTTString(color); err == nil { sa.TTMLColor = astikit.StrPtr("#" + fgColor.TTMLString()) } } @@ -455,41 +455,24 @@ func (sa *StyleAttributes) propagateWebVTTAttributes() { } // Handle WebVTT position and alignment conversion - sa.handleWebVTTPositioning() + sa.propagateWebVTTPosition() if sa.WebVTTSize != "" { sa.TTMLExtent = astikit.StrPtr(fmt.Sprintf("%s 10%%", sa.WebVTTSize)) } } -// parseWebVTTPosition parses WebVTT position string like "10%,line-left". -// Returns (xPosition, alignment) -func (sa *StyleAttributes) parseWebVTTPosition() (string, string) { - if sa.WebVTTPosition == "" { - return "", "" - } - - parts := strings.Split(sa.WebVTTPosition, ",") - if len(parts) != 2 { - return sa.WebVTTPosition, "" - } - - return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]) -} - -// handleWebVTTPositioning handles WebVTT position and line attributes conversion -func (sa *StyleAttributes) handleWebVTTPositioning() { - var xPos, yPos string +// propagateWebVTTPosition handles WebVTT position and line attributes conversion +func (sa *StyleAttributes) propagateWebVTTPosition() { + var yPos string var hasPosition, hasLine bool // Parse position if available - if sa.WebVTTPosition != "" { + if sa.WebVTTPosition != nil { hasPosition = true - var alignment string - xPos, alignment = sa.parseWebVTTPosition() // Handle position alignment (takes precedence over WebVTTAlign) - switch alignment { + switch sa.WebVTTPosition.Alignment { case "line-left": sa.TTMLTextAlign = astikit.StrPtr("left") case "center": @@ -508,10 +491,10 @@ func (sa *StyleAttributes) handleWebVTTPositioning() { // Set TTMLOrigin based on available position and line data if hasPosition && hasLine { // Both position and line are available - sa.TTMLOrigin = astikit.StrPtr(fmt.Sprintf("%s %s", xPos, yPos)) + sa.TTMLOrigin = astikit.StrPtr(fmt.Sprintf("%s %s", sa.WebVTTPosition.XPosition, yPos)) } else if hasPosition { // Only position is available, use default Y position (80% for bottom) - sa.TTMLOrigin = astikit.StrPtr(fmt.Sprintf("%s 80%%", xPos)) + sa.TTMLOrigin = astikit.StrPtr(fmt.Sprintf("%s 80%%", sa.WebVTTPosition.XPosition)) } else if hasLine { // Only line is available, use default X position (10% for left) sa.TTMLOrigin = astikit.StrPtr(fmt.Sprintf("10%% %s", yPos)) diff --git a/ttml_test.go b/ttml_test.go index e7a23db..83a3829 100644 --- a/ttml_test.go +++ b/ttml_test.go @@ -21,9 +21,9 @@ func TestTTML(t *testing.T) { assert.Equal(t, &astisub.Metadata{Framerate: 25, Language: astisub.LanguageFrench, Title: "Title test", TTMLCopyright: "Copyright test"}, s.Metadata) // Styles assert.Equal(t, 3, len(s.Styles)) - 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"]) - 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"]) - 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"]) + 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"]) + 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"]) + 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"]) // Regions assert.Equal(t, 3, len(s.Regions)) assert.Equal(t, astisub.Region{ID: "region_0", Style: s.Styles["style_0"], InlineStyle: &astisub.StyleAttributes{TTMLColor: astikit.StrPtr("blue")}}, *s.Regions["region_0"]) diff --git a/webvtt.go b/webvtt.go index ad7020c..dd13efe 100644 --- a/webvtt.go +++ b/webvtt.go @@ -37,6 +37,39 @@ var ( webVTTRegexpTag = regexp.MustCompile(`()`) ) +type WebVTTPosition struct { + XPosition string + Alignment string +} + +// newWebVTTPosition creates a new WebVTTPosition from a string. +// The string can be in the format "XPosition,Alignment" or just "XPosition". +func newWebVTTPosition(s string) *WebVTTPosition { + if s == "" { + return nil + } + + parts := strings.Split(s, ",") + if len(parts) != 2 { + return &WebVTTPosition{XPosition: strings.TrimSpace(s)} + } + + return &WebVTTPosition{ + XPosition: strings.TrimSpace(parts[0]), + Alignment: strings.TrimSpace(parts[1]), + } +} + +func (p *WebVTTPosition) String() string { + if p == nil { + return "" + } + if p.Alignment != "" { + return fmt.Sprintf("%s,%s", p.XPosition, p.Alignment) + } + return p.XPosition +} + // parseDurationWebVTT parses a .vtt duration func parseDurationWebVTT(i string) (time.Duration, error) { return parseDuration(i, ".", 3) @@ -272,7 +305,7 @@ func ReadFromWebVTT(i io.Reader) (o *Subtitles, err error) { case "line": item.InlineStyle.WebVTTLine = split[1] case "position": - item.InlineStyle.WebVTTPosition = split[1] + item.InlineStyle.WebVTTPosition = newWebVTTPosition(split[1]) case "region": if _, ok := o.Regions[split[1]]; !ok { 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) { c = append(c, bytesSpace...) c = append(c, []byte("line:"+item.Style.InlineStyle.WebVTTLine)...) } - if item.InlineStyle.WebVTTPosition != "" { + if item.InlineStyle.WebVTTPosition != nil { 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, []byte("position:"+item.InlineStyle.WebVTTPosition.String())...) + } else if item.Style != nil && item.Style.InlineStyle != nil && item.Style.InlineStyle.WebVTTPosition != nil { c = append(c, bytesSpace...) - c = append(c, []byte("position:"+item.Style.InlineStyle.WebVTTPosition)...) + c = append(c, []byte("position:"+item.Style.InlineStyle.WebVTTPosition.String())...) } if item.Region != nil { c = append(c, bytesSpace...) @@ -724,9 +757,41 @@ var webVTTColorMap = map[string]*Color{ "purple": ColorPurple, } -func parseWebVTTColorClass(color string) (*Color, error) { - if c, ok := webVTTColorMap[color]; ok { - return c, nil +func newColorFromWebVTTString(color string) (*Color, error) { + switch color { + case "black": + return ColorBlack, nil + case "red": + return ColorRed, nil + case "green": + return ColorGreen, nil + case "yellow": + return ColorYellow, nil + case "blue": + return ColorBlue, nil + case "magenta": + return ColorMagenta, nil + case "cyan": + return ColorCyan, nil + case "white": + return ColorWhite, nil + case "silver": + return ColorSilver, nil + case "gray": + return ColorGray, nil + case "maroon": + return ColorMaroon, nil + case "olive": + return ColorOlive, nil + case "lime": + return ColorLime, nil + case "teal": + return ColorTeal, nil + case "navy": + return ColorNavy, nil + case "purple": + return ColorPurple, nil + default: + return nil, fmt.Errorf("unknown color class %s", color) } - return nil, fmt.Errorf("unknown color class %s", color) } diff --git a/webvtt_test.go b/webvtt_test.go index 9b46bbe..8dce41c 100644 --- a/webvtt_test.go +++ b/webvtt_test.go @@ -30,7 +30,7 @@ func TestWebVTT(t *testing.T) { // Styles expected := astisub.StyleAttributes{ WebVTTAlign: "left", - WebVTTPosition: "10%,line-left", + WebVTTPosition: &astisub.WebVTTPosition{XPosition: "10%", Alignment: "line-left"}, WebVTTSize: "35%", } // propagateWebVTTAttributes() sets these based on WebVTT attributes From 484d47389b13a4a182454221afc392aa116cf48a Mon Sep 17 00:00:00 2001 From: David Le Corfec Date: Tue, 4 Nov 2025 15:45:21 +0100 Subject: [PATCH 3/4] remove unused map --- webvtt.go | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/webvtt.go b/webvtt.go index dd13efe..f59a052 100644 --- a/webvtt.go +++ b/webvtt.go @@ -737,26 +737,6 @@ func cssColor(rgb string) string { return colors[strings.ToLower(rgb)] // returning the empty string is ok } -// webVTTColorMap maps WebVTT color class names to Color instances -var webVTTColorMap = map[string]*Color{ - "black": ColorBlack, - "red": ColorRed, - "green": ColorGreen, - "yellow": ColorYellow, - "blue": ColorBlue, - "magenta": ColorMagenta, - "cyan": ColorCyan, - "white": ColorWhite, - "silver": ColorSilver, - "gray": ColorGray, - "maroon": ColorMaroon, - "olive": ColorOlive, - "lime": ColorLime, - "teal": ColorTeal, - "navy": ColorNavy, - "purple": ColorPurple, -} - func newColorFromWebVTTString(color string) (*Color, error) { switch color { case "black": From 939c3e5c88be929bb1e39376a9594695555bc67c Mon Sep 17 00:00:00 2001 From: David Le Corfec Date: Tue, 4 Nov 2025 15:45:56 +0100 Subject: [PATCH 4/4] include propagateWebVTTPosition into propagateWebVTTAttributes --- subtitles.go | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/subtitles.go b/subtitles.go index 4bdb062..1619f8e 100644 --- a/subtitles.go +++ b/subtitles.go @@ -455,19 +455,9 @@ func (sa *StyleAttributes) propagateWebVTTAttributes() { } // Handle WebVTT position and alignment conversion - sa.propagateWebVTTPosition() - - if sa.WebVTTSize != "" { - sa.TTMLExtent = astikit.StrPtr(fmt.Sprintf("%s 10%%", sa.WebVTTSize)) - } -} - -// propagateWebVTTPosition handles WebVTT position and line attributes conversion -func (sa *StyleAttributes) propagateWebVTTPosition() { - var yPos string - var hasPosition, hasLine bool // Parse position if available + var hasPosition bool if sa.WebVTTPosition != nil { hasPosition = true @@ -483,6 +473,8 @@ func (sa *StyleAttributes) propagateWebVTTPosition() { } // Handle line if available + var hasLine bool + var yPos string if sa.WebVTTLine != "" { hasLine = true yPos = sa.WebVTTLine @@ -499,6 +491,10 @@ func (sa *StyleAttributes) propagateWebVTTPosition() { // Only line is available, use default X position (10% for left) sa.TTMLOrigin = astikit.StrPtr(fmt.Sprintf("10%% %s", yPos)) } + + if sa.WebVTTSize != "" { + sa.TTMLExtent = astikit.StrPtr(fmt.Sprintf("%s 10%%", sa.WebVTTSize)) + } } // merge - base on parent, override style attributes if defined in child