diff --git a/subtitles.go b/subtitles.go index ea1e853..26ac8f8 100644 --- a/subtitles.go +++ b/subtitles.go @@ -504,19 +504,39 @@ func (sa *StyleAttributes) propagateTTMLAttributes() { } //cue settings //default TTML WritingMode is lrtb i.e. left to right, top to bottom - sa.WebVTTSize = dimensions[1] + sa.WebVTTSize = dimensions[0] if sa.TTMLWritingMode != nil && strings.HasPrefix(*sa.TTMLWritingMode, "tb") { - sa.WebVTTSize = dimensions[0] + sa.WebVTTSize = dimensions[1] } } } if sa.TTMLOrigin != nil { //region settings - sa.WebVTTRegionAnchor = "0%,0%" - sa.WebVTTViewportAnchor = strings.ReplaceAll(strings.TrimSpace(*sa.TTMLOrigin), " ", ",") - sa.WebVTTScroll = "up" - //cue settings + // Anchor at bottom-left (0%,100%) for bottom-aligned text + sa.WebVTTRegionAnchor = "0%,100%" + + // Calculate viewport anchor at bottom edge for displayAlign="after" coordinates := strings.Split(*sa.TTMLOrigin, " ") + if len(coordinates) > 1 && sa.TTMLExtent != nil { + dimensions := strings.Split(*sa.TTMLExtent, " ") + if len(dimensions) > 1 { + // Calculate bottom edge: origin Y + extent height + originY := strings.TrimSpace(coordinates[1]) + extentHeight := strings.TrimSpace(dimensions[1]) + + originYVal, _ := strconv.ParseFloat(strings.ReplaceAll(originY, "%", ""), 64) + extentHeightVal, _ := strconv.ParseFloat(strings.ReplaceAll(extentHeight, "%", ""), 64) + bottomY := originYVal + extentHeightVal + + sa.WebVTTViewportAnchor = fmt.Sprintf("%s,%.0f%%", strings.TrimSpace(coordinates[0]), bottomY) + } else { + sa.WebVTTViewportAnchor = strings.ReplaceAll(strings.TrimSpace(*sa.TTMLOrigin), " ", ",") + } + } else { + sa.WebVTTViewportAnchor = strings.ReplaceAll(strings.TrimSpace(*sa.TTMLOrigin), " ", ",") + } + + //cue settings if len(coordinates) > 1 { sa.WebVTTLine = coordinates[0] sa.WebVTTPosition = newWebVTTPosition(coordinates[1]) diff --git a/testdata/example-out.vtt b/testdata/example-out.vtt index 734a533..0155c45 100644 --- a/testdata/example-out.vtt +++ b/testdata/example-out.vtt @@ -15,8 +15,21 @@ color: red; background-image: linear-gradient(to bottom, dimgray, lightgray); } -Region: id=bill lines=3 regionanchor=100%,100% scroll=up viewportanchor=90%,90% width=40% -Region: id=fred lines=3 regionanchor=0%,100% scroll=up viewportanchor=10%,90% width=40% +REGION +id:bill +width:40% +lines:3 +viewportanchor:90%,90% +regionanchor:100%,100% +scroll:up + +REGION +id:fred +width:40% +lines:3 +viewportanchor:10%,90% +regionanchor:0%,100% +scroll:up NOTE this a nice example of a VTT diff --git a/ttml_test.go b/ttml_test.go index bc1b646..72eb3c3 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{STLColor: astisub.ColorWhite, TTMLColor: astisub.ColorWhite, 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{STLColor: astisub.ColorWhite, TTMLColor: astisub.ColorWhite, 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{STLColor: astisub.ColorWhite, TTMLColor: astisub.ColorWhite, 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"]) + assert.Equal(t, astisub.Style{ID: "style_0", InlineStyle: &astisub.StyleAttributes{STLColor: astisub.ColorWhite, TTMLColor: astisub.ColorWhite, 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%,100%", WebVTTScroll: "", WebVTTSize: "100%", WebVTTViewportAnchor: "0%,100%", WebVTTWidth: "100%"}, Style: s.Styles["style_2"]}, *s.Styles["style_0"]) + assert.Equal(t, astisub.Style{ID: "style_1", InlineStyle: &astisub.StyleAttributes{STLColor: astisub.ColorWhite, TTMLColor: astisub.ColorWhite, 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%,100%", WebVTTScroll: "", WebVTTSize: "100%", WebVTTViewportAnchor: "0%,100%", WebVTTWidth: "100%"}}, *s.Styles["style_1"]) + assert.Equal(t, astisub.Style{ID: "style_2", InlineStyle: &astisub.StyleAttributes{STLColor: astisub.ColorWhite, TTMLColor: astisub.ColorWhite, 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%,100%", WebVTTScroll: "", WebVTTSize: "100%", WebVTTViewportAnchor: "0%,100%", 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{STLColor: astisub.ColorBlue, TTMLColor: astisub.ColorBlue}}, *s.Regions["region_0"]) diff --git a/webvtt.go b/webvtt.go index 016b073..8fa4476 100644 --- a/webvtt.go +++ b/webvtt.go @@ -175,6 +175,7 @@ func ReadFromWebVTT(i io.Reader) (o *Subtitles, err error) { var comments []string var index int var sa = &StyleAttributes{} + var currentRegion *Region for scanner.Scan() { // Fetch line @@ -192,6 +193,13 @@ func ReadFromWebVTT(i io.Reader) (o *Subtitles, err error) { comments = append(comments, strings.TrimPrefix(line, "NOTE ")) // Empty line case len(line) == 0: + // If we were parsing a REGION block, finalize it + if blockName == webvttBlockNameRegion && currentRegion != nil && currentRegion.ID != "" { + currentRegion.InlineStyle.propagateWebVTTAttributes() + o.Regions[currentRegion.ID] = currentRegion + currentRegion = nil + } + // Reset block name, if we are not in the middle of CSS. // If we are in STYLE block and the CSS is empty or we meet the right brace at the end of last line, // then we are not in CSS and can switch to parse next WebVTT block. @@ -204,7 +212,12 @@ func ReadFromWebVTT(i io.Reader) (o *Subtitles, err error) { // Reset WebVTTTags sa.WebVTTTags = []WebVTTTag{} - // Region + // New REGION block format (W3C spec compliant) + case line == "REGION": + blockName = webvttBlockNameRegion + currentRegion = &Region{InlineStyle: &StyleAttributes{}} + + // Old Region: format (backward compatibility) case strings.HasPrefix(line, "Region: "): // Add region styles var r = &Region{InlineStyle: &StyleAttributes{}} @@ -354,6 +367,33 @@ func ReadFromWebVTT(i io.Reader) (o *Subtitles, err error) { switch blockName { case webvttBlockNameComment: comments = append(comments, line) + case webvttBlockNameRegion: + // Parse REGION block settings (multi-line format) + if currentRegion != nil { + var split = strings.Split(line, ":") + if len(split) > 1 { + key := strings.TrimSpace(split[0]) + value := strings.TrimSpace(split[1]) + + switch key { + case "id": + currentRegion.ID = value + case "lines": + if currentRegion.InlineStyle.WebVTTLines, err = strconv.Atoi(value); err != nil { + err = fmt.Errorf("atoi of %s failed: %w", value, err) + return + } + case "regionanchor": + currentRegion.InlineStyle.WebVTTRegionAnchor = value + case "scroll": + currentRegion.InlineStyle.WebVTTScroll = value + case "viewportanchor": + currentRegion.InlineStyle.WebVTTViewportAnchor = value + case "width": + currentRegion.InlineStyle.WebVTTWidth = value + } + } + } case webvttBlockNameStyle: sa.WebVTTStyles = append(sa.WebVTTStyles, line) case webvttBlockNameText: @@ -534,45 +574,53 @@ func (s Subtitles) WriteToWebVTT(o io.Writer) (err error) { sort.Strings(k) for _, id := range k { - c = append(c, []byte("Region: id="+s.Regions[id].ID)...) + // W3C WebVTT Spec: REGION blocks use multi-line format with colon-separated settings + c = append(c, []byte("REGION")...) + c = append(c, bytesLineSeparator...) + c = append(c, []byte("id:"+s.Regions[id].ID)...) + c = append(c, bytesLineSeparator...) + + if s.Regions[id].InlineStyle.WebVTTWidth != "" { + c = append(c, []byte("width:"+s.Regions[id].InlineStyle.WebVTTWidth)...) + c = append(c, bytesLineSeparator...) + } else if s.Regions[id].Style != nil && s.Regions[id].Style.InlineStyle != nil && s.Regions[id].Style.InlineStyle.WebVTTWidth != "" { + c = append(c, []byte("width:"+s.Regions[id].Style.InlineStyle.WebVTTWidth)...) + c = append(c, bytesLineSeparator...) + } + if s.Regions[id].InlineStyle.WebVTTLines != 0 { - c = append(c, bytesSpace...) - c = append(c, []byte("lines="+strconv.Itoa(s.Regions[id].InlineStyle.WebVTTLines))...) + c = append(c, []byte("lines:"+strconv.Itoa(s.Regions[id].InlineStyle.WebVTTLines))...) + c = append(c, bytesLineSeparator...) } 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))...) + c = append(c, []byte("lines:"+strconv.Itoa(s.Regions[id].Style.InlineStyle.WebVTTLines))...) + c = append(c, bytesLineSeparator...) + } + + if s.Regions[id].InlineStyle.WebVTTViewportAnchor != "" { + c = append(c, []byte("viewportanchor:"+s.Regions[id].InlineStyle.WebVTTViewportAnchor)...) + c = append(c, bytesLineSeparator...) + } else if s.Regions[id].Style != nil && s.Regions[id].Style.InlineStyle != nil && s.Regions[id].Style.InlineStyle.WebVTTViewportAnchor != "" { + c = append(c, []byte("viewportanchor:"+s.Regions[id].Style.InlineStyle.WebVTTViewportAnchor)...) + c = append(c, bytesLineSeparator...) } + if s.Regions[id].InlineStyle.WebVTTRegionAnchor != "" { - c = append(c, bytesSpace...) - c = append(c, []byte("regionanchor="+s.Regions[id].InlineStyle.WebVTTRegionAnchor)...) + c = append(c, []byte("regionanchor:"+s.Regions[id].InlineStyle.WebVTTRegionAnchor)...) + c = append(c, bytesLineSeparator...) } 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)...) + c = append(c, []byte("regionanchor:"+s.Regions[id].Style.InlineStyle.WebVTTRegionAnchor)...) + c = append(c, bytesLineSeparator...) } + if s.Regions[id].InlineStyle.WebVTTScroll != "" { - c = append(c, bytesSpace...) - c = append(c, []byte("scroll="+s.Regions[id].InlineStyle.WebVTTScroll)...) + c = append(c, []byte("scroll:"+s.Regions[id].InlineStyle.WebVTTScroll)...) + c = append(c, bytesLineSeparator...) } 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, []byte("scroll:"+s.Regions[id].Style.InlineStyle.WebVTTScroll)...) + c = append(c, bytesLineSeparator...) } - c = append(c, bytesLineSeparator...) - } - if len(s.Regions) > 0 { + + // Add blank line after each REGION block c = append(c, bytesLineSeparator...) } diff --git a/webvtt_test.go b/webvtt_test.go index 0896676..c597cd8 100644 --- a/webvtt_test.go +++ b/webvtt_test.go @@ -359,3 +359,72 @@ Normal text with magenta and unknown color` assert.Nil(t, unknownColorItem.InlineStyle.TTMLColor) // Unknown color should not be converted assert.Nil(t, unknownColorItem.InlineStyle.TTMLBackgroundColor) } + +func TestWebVTTRegionFormats(t *testing.T) { + // Test 1: New REGION format (W3C spec compliant) + newFormatData := `WEBVTT + +REGION +id:testRegion +width:50% +lines:3 +regionanchor:0%,100% +viewportanchor:10%,90% +scroll:up + +00:00:01.000 --> 00:00:02.000 region:testRegion +Test subtitle +` + + s1, err := astisub.ReadFromWebVTT(strings.NewReader(newFormatData)) + require.NoError(t, err) + require.Len(t, s1.Regions, 1) + r1 := s1.Regions["testRegion"] + require.NotNil(t, r1) + assert.Equal(t, "testRegion", r1.ID) + assert.Equal(t, "50%", r1.InlineStyle.WebVTTWidth) + assert.Equal(t, 3, r1.InlineStyle.WebVTTLines) + assert.Equal(t, "0%,100%", r1.InlineStyle.WebVTTRegionAnchor) + assert.Equal(t, "10%,90%", r1.InlineStyle.WebVTTViewportAnchor) + assert.Equal(t, "up", r1.InlineStyle.WebVTTScroll) + + // Test 2: Old Region: format (backward compatibility) + oldFormatData := `WEBVTT + +Region: id=testRegion width=50% lines=3 regionanchor=0%,100% viewportanchor=10%,90% scroll=up + +00:00:01.000 --> 00:00:02.000 region:testRegion +Test subtitle +` + + s2, err := astisub.ReadFromWebVTT(strings.NewReader(oldFormatData)) + require.NoError(t, err) + require.Len(t, s2.Regions, 1) + r2 := s2.Regions["testRegion"] + require.NotNil(t, r2) + assert.Equal(t, "testRegion", r2.ID) + assert.Equal(t, "50%", r2.InlineStyle.WebVTTWidth) + assert.Equal(t, 3, r2.InlineStyle.WebVTTLines) + assert.Equal(t, "0%,100%", r2.InlineStyle.WebVTTRegionAnchor) + assert.Equal(t, "10%,90%", r2.InlineStyle.WebVTTViewportAnchor) + assert.Equal(t, "up", r2.InlineStyle.WebVTTScroll) + + // Test 3: Verify both formats parse to the same result + assert.Equal(t, r1.ID, r2.ID) + assert.Equal(t, r1.InlineStyle.WebVTTWidth, r2.InlineStyle.WebVTTWidth) + assert.Equal(t, r1.InlineStyle.WebVTTLines, r2.InlineStyle.WebVTTLines) + assert.Equal(t, r1.InlineStyle.WebVTTRegionAnchor, r2.InlineStyle.WebVTTRegionAnchor) + assert.Equal(t, r1.InlineStyle.WebVTTViewportAnchor, r2.InlineStyle.WebVTTViewportAnchor) + assert.Equal(t, r1.InlineStyle.WebVTTScroll, r2.InlineStyle.WebVTTScroll) + + // Test 4: Verify writing always uses new REGION format + buf := &bytes.Buffer{} + err = s1.WriteToWebVTT(buf) + require.NoError(t, err) + output := buf.String() + assert.Contains(t, output, "REGION") + assert.Contains(t, output, "id:testRegion") + assert.Contains(t, output, "width:50%") + assert.NotContains(t, output, "Region:") // Should NOT contain old format + assert.NotContains(t, output, "id=") // Should NOT use equals signs +}