Support body div level style and region (#131)

* [ttml] Support style and region inheritance body>div>p

* [ttml/test] Test case for style and region inheritance

* Add more case

* Rename function and correct code
This commit is contained in:
Nhan Trong Nguyen
2025-10-14 11:05:44 +02:00
committed by GitHub
parent 2d1d91b6fa
commit 90801f85db
4 changed files with 283 additions and 80 deletions
+103
View File
@@ -430,6 +430,109 @@ func (sa *StyleAttributes) propagateWebVTTAttributes() {
sa.SRTUnderline = sa.WebVTTUnderline
}
// merge - base on parent, override style attributes if defined in child
// TODO: handle more formats than just TTML
func (sa *StyleAttributes) merge(parent *StyleAttributes) {
if parent == nil || sa == nil {
return
}
if sa.TTMLBackgroundColor == nil {
sa.TTMLBackgroundColor = parent.TTMLBackgroundColor
}
if sa.TTMLColor == nil {
sa.TTMLColor = parent.TTMLColor
}
if sa.TTMLDirection == nil {
sa.TTMLDirection = parent.TTMLDirection
}
if sa.TTMLDisplay == nil {
sa.TTMLDisplay = parent.TTMLDisplay
}
if sa.TTMLDisplayAlign == nil {
sa.TTMLDisplayAlign = parent.TTMLDisplayAlign
}
if sa.TTMLExtent == nil {
sa.TTMLExtent = parent.TTMLExtent
}
if sa.TTMLFontFamily == nil {
sa.TTMLFontFamily = parent.TTMLFontFamily
}
if sa.TTMLFontSize == nil {
sa.TTMLFontSize = parent.TTMLFontSize
}
if sa.TTMLFontStyle == nil {
sa.TTMLFontStyle = parent.TTMLFontStyle
}
if sa.TTMLFontWeight == nil {
sa.TTMLFontWeight = parent.TTMLFontWeight
}
if sa.TTMLLineHeight == nil {
sa.TTMLLineHeight = parent.TTMLLineHeight
}
if sa.TTMLOpacity == nil {
sa.TTMLOpacity = parent.TTMLOpacity
}
if sa.TTMLOrigin == nil {
sa.TTMLOrigin = parent.TTMLOrigin
}
if sa.TTMLOverflow == nil {
sa.TTMLOverflow = parent.TTMLOverflow
}
if sa.TTMLPadding == nil {
sa.TTMLPadding = parent.TTMLPadding
}
if sa.TTMLShowBackground == nil {
sa.TTMLShowBackground = parent.TTMLShowBackground
}
if sa.TTMLTextAlign == nil {
sa.TTMLTextAlign = parent.TTMLTextAlign
}
if sa.TTMLTextDecoration == nil {
sa.TTMLTextDecoration = parent.TTMLTextDecoration
}
if sa.TTMLTextOutline == nil {
sa.TTMLTextOutline = parent.TTMLTextOutline
}
if sa.TTMLUnicodeBidi == nil {
sa.TTMLUnicodeBidi = parent.TTMLUnicodeBidi
}
if sa.TTMLVisibility == nil {
sa.TTMLVisibility = parent.TTMLVisibility
}
if sa.TTMLWrapOption == nil {
sa.TTMLWrapOption = parent.TTMLWrapOption
}
if sa.TTMLWritingMode == nil {
sa.TTMLWritingMode = parent.TTMLWritingMode
}
if sa.TTMLZIndex == nil {
sa.TTMLZIndex = parent.TTMLZIndex
}
}
// Metadata represents metadata
// TODO Merge attributes
type Metadata struct {
+39
View File
@@ -0,0 +1,39 @@
<tt xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns="http://www.w3.org/2006/10/ttaf1" xmlns:tt="http://www.w3.org/2006/10/ttaf1" xmlns:tts="http://www.w3.org/2006/10/ttaf1#styling" xmlns:ttp="http://www.w3.org/2006/10/ttaf1#parameter" xmlns:ttm="http://www.w3.org/2006/10/ttaf1#metadata" xml:lang="fr-FR" ttp:timeBase="smpte" ttp:frameRate="25" ttp:frameRateMultiplier="1:1" ttp:markerMode="discontinuous">
<head>
<metadata>
<ttm:title>Title test</ttm:title>
<ttm:copyright>Copyright test</ttm:copyright>
</metadata>
<styling>
<style xml:id="style_0" tts:style="style_2" tts:fontFamily="sansSerif" tts:color="white" tts:fontStyle="normal" tts:textAlign="center" tts:origin="0% 90%" tts:extent="100% 10%"/>
<style xml:id="style_1" tts:fontFamily="sansSerif" tts:color="white" tts:fontStyle="normal" tts:textAlign="center" tts:origin="0% 87%" tts:extent="100% 13%"/>
<style xml:id="style_2" tts:fontFamily="sansSerif" tts:color="white" tts:fontStyle="normal" tts:textAlign="center" tts:origin="0% 80%" tts:extent="100% 20%"/>
</styling>
<layout>
<region xml:id="region_0" tt:style="style_0" tt:color="blue"/>
<region xml:id="region_1" tt:style="style_1"/>
<region xml:id="region_2" tt:style="style_2"/>
</layout>
</head>
<body region="region_0" style="style_0">
<div style="style_1" tts:color="yellow">
<p xml:id="sub_1" begin="00:00:00.000" end="00:01:00.000" color="red">
text1.0
<span style="style_1" color="black">text1.1</span>
</p>
</div>
<div region="region_1">
<p xml:id="sub_2" begin="00:01:00.000" end="00:02:00.000">
<span style="style_1">text2</span>
</p>
<p xml:id="sub_3" begin="00:01:00.000" end="00:02:00.000" region="region_2">
text2.1
</p>
</div>
<div tts:color="blue">
<p xml:id="sub_2" begin="00:02:00.000" end="00:03:00.000">
<span style="style_1">text3</span>
</p>
</div>
</body>
</tt>
+124 -80
View File
@@ -41,17 +41,34 @@ var (
ttmlRegexpOffsetTime = regexp.MustCompile(`^(\d+(\.\d+)?)(h|m|s|ms|f|t)$`)
)
type TTMLInBodyDiv struct {
XMLName xml.Name `xml:"div"`
Subtitles []TTMLInSubtitle `xml:"p"`
Region string `xml:"region,attr,omitempty"`
Style string `xml:"style,attr,omitempty"`
TTMLInStyleAttributes
}
type TTMLInBody struct {
XMLName xml.Name `xml:"body"`
Divs []TTMLInBodyDiv `xml:"div"`
Region string `xml:"region,attr,omitempty"`
Style string `xml:"style,attr,omitempty"`
TTMLInStyleAttributes
}
// 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"`
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"`
Body TTMLInBody `xml:"body"`
Tickrate int `xml:"tickRate,attr"`
XMLName xml.Name `xml:"tt"`
}
// metadata returns the Metadata of the TTML
@@ -386,94 +403,121 @@ func ReadFromTTML(i io.Reader) (o *Subtitles, err error) {
}
// 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
bodyInlineStyle := ttml.Body.TTMLInStyleAttributes.styleAttributes()
for _, div := range ttml.Body.Divs {
divInlineStyle := div.TTMLInStyleAttributes.styleAttributes()
var s = &Item{
EndAt: ts.End.duration(),
InlineStyle: ts.TTMLInStyleAttributes.styleAttributes(),
StartAt: ts.Begin.duration(),
// Propagate styles from Body -> Div
divInlineStyle.merge(bodyInlineStyle)
if div.Region == "" {
div.Region = ttml.Body.Region
}
if div.Style == "" {
div.Style = ttml.Body.Style
}
for _, ts := range div.Subtitles {
// Init item
ts.Begin.framerate = ttml.Framerate
ts.Begin.tickrate = ttml.Tickrate
ts.End.framerate = ttml.Framerate
ts.End.tickrate = ttml.Tickrate
// 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)
itemInlineStyle := ts.TTMLInStyleAttributes.styleAttributes()
// Propagate styles from Body -> Div -> Item.
// If the item has its own Region, Style, or InlineStyle, it overrides the Div's.
// This ensures all relevant styles are preserved at the item level,
// maintaining compatibility with existing logic that relies on the Subtitles structure.
itemInlineStyle.merge(divInlineStyle)
if ts.Region == "" {
ts.Region = div.Region
}
if ts.Style == "" {
ts.Style = div.Style
}
var s = &Item{
EndAt: ts.End.duration(),
InlineStyle: itemInlineStyle,
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]
}
// Remove items identation
lines := strings.Split(ts.Items, "\n")
for i := 0; i < len(lines); i++ {
lines[i] = strings.TrimLeftFunc(lines[i], unicode.IsSpace)
}
// Unmarshal items
var items = TTMLInItems{}
if err = newTTMLXmlDecoder(strings.Join(lines, "")).Decode(&items); err != nil {
err = fmt.Errorf("astisub: unmarshaling items failed: %w", err)
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]
}
// Remove items identation
lines := strings.Split(ts.Items, "\n")
for i := 0; i < len(lines); i++ {
lines[i] = strings.TrimLeftFunc(lines[i], unicode.IsSpace)
}
// Unmarshal items
var items = TTMLInItems{}
if err = newTTMLXmlDecoder(strings.Join(lines, "")).Decode(&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 {
// 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
}
// Init line item
var t = LineItem{
InlineStyle: tt.TTMLInStyleAttributes.styleAttributes(),
Text: 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
// 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{}
}
t.Style = o.Styles[tt.Style]
// Init line item
var t = LineItem{
InlineStyle: tt.TTMLInStyleAttributes.styleAttributes(),
Text: 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)
}
// Append items
l.Items = append(l.Items, t)
}
s.Lines = append(s.Lines, *l)
// Append subtitle
o.Items = append(o.Items, s)
}
s.Lines = append(s.Lines, *l)
// Append subtitle
o.Items = append(o.Items, s)
}
return
}
+17
View File
@@ -85,3 +85,20 @@ func TestWriteToTTMLWithIndentOption(t *testing.T) {
assert.Equal(t, strings.TrimSpace(string(c)), strings.TrimSpace(w.String()))
}
func TestTTMLMergeStyleAttributes(t *testing.T) {
// Open
s, err := astisub.OpenFile("./testdata/example-in-merging-style.ttml")
assert.NoError(t, err)
// Styles
assert.Equal(t, 4, len(s.Items))
assert.Equal(t, s.Items[0].Region.ID, "region_0")
assert.Equal(t, s.Items[0].Style.ID, "style_1")
assert.Equal(t, *s.Items[0].InlineStyle.TTMLColor, "red")
assert.Equal(t, s.Items[1].Region.ID, "region_1")
assert.Equal(t, s.Items[1].Style.ID, "style_0")
assert.Equal(t, s.Items[1].Lines[0].Items[0].Style.ID, "style_1")
assert.Equal(t, s.Items[2].Region.ID, "region_2")
assert.Equal(t, s.Items[2].Style.ID, "style_0")
assert.Equal(t, *s.Items[3].InlineStyle.TTMLColor, "blue")
}