7 | First line
Second line
8 |
10 | Third line
Fourth line
11 |
13 | Fifth line
Sixth middle line
14 |
16 | Seventh line
Eighth middle line
17 |
21 | (deep rumbling) 22 |
23 |
24 | MAN:
25 |
29 | This place is horrible. 30 |
31 |32 | Smells like balls. 33 |
34 |
35 | We don't belong
36 |
40 | (computer playing
41 |
9 | First line
10 |
11 | Second line
12 |
14 | Third line
15 |
16 | Fourth line
17 |
19 | Fifth line
20 |
21 | Sixth
22 | middle
23 | line
24 |
26 | Seventh line
27 |
28 | Eighth
29 | middle
30 | line
31 |
(deep rumbling)
MAN:
How did we end up here?
This place is horrible.
Smells like balls.
We don't belong
in this shithole.
(computer playing
electronic melody)
21 | (deep rumbling) 22 |
23 |
24 | MAN:
25 |
26 | How did we
27 | end up
28 | here?
29 |
31 | This place is horrible. 32 |
33 |34 | Smells like balls. 35 |
36 |
37 | We don't belong
38 |
39 | in this shithole.
40 |
42 | (computer playing
43 |
44 | electronic melody)
45 |
" + s + "
")), 234 | holdingToken: nil, 235 | }, 236 | ) 237 | } 238 | 239 | // TTMLInItem represents an input TTML item 240 | type TTMLInItem struct { 241 | Style string `xml:"style,attr,omitempty"` 242 | Text string `xml:",chardata"` 243 | TTMLInStyleAttributes 244 | XMLName xml.Name 245 | } 246 | 247 | // TTMLInDuration represents an input TTML duration 248 | type TTMLInDuration struct { 249 | d time.Duration 250 | frames, framerate int // Framerate is in frame/s 251 | ticks, tickrate int // Tickrate is in ticks/s 252 | } 253 | 254 | // UnmarshalText implements the TextUnmarshaler interface 255 | // Possible formats are: 256 | // - hh:mm:ss.mmm 257 | // - hh:mm:ss:fff (fff being frames) 258 | // - [ticks]t ([ticks] being the tick amount) 259 | func (d *TTMLInDuration) UnmarshalText(i []byte) (err error) { 260 | // Reset duration 261 | d.d = time.Duration(0) 262 | d.frames = 0 263 | d.ticks = 0 264 | 265 | // Check offset time 266 | text := string(i) 267 | if matches := ttmlRegexpOffsetTime.FindStringSubmatch(text); matches != nil { 268 | // Parse value 269 | var value float64 270 | if value, err = strconv.ParseFloat(matches[1], 64); err != nil { 271 | err = fmt.Errorf("astisub: failed to parse value %s", matches[1]) 272 | return 273 | } 274 | 275 | // Parse metric 276 | metric := matches[3] 277 | 278 | // Update duration 279 | if metric == "t" { 280 | d.ticks = int(value) 281 | } else if metric == "f" { 282 | d.frames = int(value) 283 | } else { 284 | // Get timebase 285 | var timebase time.Duration 286 | switch metric { 287 | case "h": 288 | timebase = time.Hour 289 | case "m": 290 | timebase = time.Minute 291 | case "s": 292 | timebase = time.Second 293 | case "ms": 294 | timebase = time.Millisecond 295 | default: 296 | err = fmt.Errorf("astisub: invalid metric %s", metric) 297 | return 298 | } 299 | 300 | // Update duration 301 | d.d = time.Duration(value * float64(timebase.Nanoseconds())) 302 | } 303 | return 304 | } 305 | 306 | // Extract clock time frames 307 | if indexes := ttmlRegexpClockTimeFrames.FindStringIndex(text); indexes != nil { 308 | // Parse frames 309 | var s = text[indexes[0]+1 : indexes[1]] 310 | if d.frames, err = strconv.Atoi(s); err != nil { 311 | err = fmt.Errorf("astisub: atoi %s failed: %w", s, err) 312 | return 313 | } 314 | 315 | // Update text 316 | text = text[:indexes[0]] + ".000" 317 | } 318 | 319 | d.d, err = parseDuration(text, ".", 3) 320 | return 321 | } 322 | 323 | // duration returns the input TTML Duration's time.Duration 324 | func (d TTMLInDuration) duration() (o time.Duration) { 325 | if d.ticks > 0 && d.tickrate > 0 { 326 | return time.Duration(float64(d.ticks) * 1e9 / float64(d.tickrate)) 327 | } 328 | o = d.d 329 | if d.frames > 0 && d.framerate > 0 { 330 | o += time.Duration(float64(d.frames) / float64(d.framerate) * float64(time.Second.Nanoseconds())) 331 | } 332 | return 333 | } 334 | 335 | // ReadFromTTML parses a .ttml content 336 | func ReadFromTTML(i io.Reader) (o *Subtitles, err error) { 337 | // Init 338 | o = NewSubtitles() 339 | 340 | // Unmarshal XML 341 | var ttml TTMLIn 342 | if err = xml.NewDecoder(i).Decode(&ttml); err != nil { 343 | err = fmt.Errorf("astisub: xml decoding failed: %w", err) 344 | return 345 | } 346 | 347 | // Add metadata 348 | o.Metadata = ttml.metadata() 349 | 350 | // Loop through styles 351 | var parentStyles = make(map[string]*Style) 352 | for _, ts := range ttml.Styles { 353 | var s = &Style{ 354 | ID: ts.ID, 355 | InlineStyle: ts.TTMLInStyleAttributes.styleAttributes(), 356 | } 357 | o.Styles[s.ID] = s 358 | if len(ts.Style) > 0 { 359 | parentStyles[ts.Style] = s 360 | } 361 | } 362 | 363 | // Take care of parent styles 364 | for id, s := range parentStyles { 365 | if _, ok := o.Styles[id]; !ok { 366 | err = fmt.Errorf("astisub: Style %s requested by style %s doesn't exist", id, s.ID) 367 | return 368 | } 369 | s.Style = o.Styles[id] 370 | } 371 | 372 | // Loop through regions 373 | for _, tr := range ttml.Regions { 374 | var r = &Region{ 375 | ID: tr.ID, 376 | InlineStyle: tr.TTMLInStyleAttributes.styleAttributes(), 377 | } 378 | if len(tr.Style) > 0 { 379 | if _, ok := o.Styles[tr.Style]; !ok { 380 | err = fmt.Errorf("astisub: Style %s requested by region %s doesn't exist", tr.Style, r.ID) 381 | return 382 | } 383 | r.Style = o.Styles[tr.Style] 384 | } 385 | o.Regions[r.ID] = r 386 | } 387 | 388 | // Loop through subtitles 389 | for _, ts := range ttml.Subtitles { 390 | // Init item 391 | ts.Begin.framerate = ttml.Framerate 392 | ts.Begin.tickrate = ttml.Tickrate 393 | ts.End.framerate = ttml.Framerate 394 | ts.End.tickrate = ttml.Tickrate 395 | 396 | var s = &Item{ 397 | EndAt: ts.End.duration(), 398 | InlineStyle: ts.TTMLInStyleAttributes.styleAttributes(), 399 | StartAt: ts.Begin.duration(), 400 | } 401 | 402 | // Add region 403 | if len(ts.Region) > 0 { 404 | if _, ok := o.Regions[ts.Region]; !ok { 405 | err = fmt.Errorf("astisub: Region %s requested by subtitle between %s and %s doesn't exist", ts.Region, s.StartAt, s.EndAt) 406 | return 407 | } 408 | s.Region = o.Regions[ts.Region] 409 | } 410 | 411 | // Add style 412 | if len(ts.Style) > 0 { 413 | if _, ok := o.Styles[ts.Style]; !ok { 414 | err = fmt.Errorf("astisub: Style %s requested by subtitle between %s and %s doesn't exist", ts.Style, s.StartAt, s.EndAt) 415 | return 416 | } 417 | s.Style = o.Styles[ts.Style] 418 | } 419 | 420 | // Remove items identation 421 | lines := strings.Split(ts.Items, "\n") 422 | for i := 0; i < len(lines); i++ { 423 | lines[i] = strings.TrimLeftFunc(lines[i], unicode.IsSpace) 424 | } 425 | 426 | // Unmarshal items 427 | var items = TTMLInItems{} 428 | if err = newTTMLXmlDecoder(strings.Join(lines, "")).Decode(&items); err != nil { 429 | err = fmt.Errorf("astisub: unmarshaling items failed: %w", err) 430 | return 431 | } 432 | 433 | // Loop through texts 434 | var l = &Line{} 435 | for _, tt := range items { 436 | // New line specified with the "br" tag 437 | if strings.ToLower(tt.XMLName.Local) == "br" { 438 | s.Lines = append(s.Lines, *l) 439 | l = &Line{} 440 | continue 441 | } 442 | 443 | // New line decoded as a line break. This can happen if there's a "br" tag within the text since 444 | // since the go xml unmarshaler will unmarshal a "br" tag as a line break if the field has the 445 | // chardata xml tag. 446 | for idx, li := range strings.Split(tt.Text, "\n") { 447 | // New line 448 | if idx > 0 { 449 | s.Lines = append(s.Lines, *l) 450 | l = &Line{} 451 | } 452 | 453 | // Init line item 454 | var t = LineItem{ 455 | InlineStyle: tt.TTMLInStyleAttributes.styleAttributes(), 456 | Text: li, 457 | } 458 | 459 | // Add style 460 | if len(tt.Style) > 0 { 461 | if _, ok := o.Styles[tt.Style]; !ok { 462 | err = fmt.Errorf("astisub: Style %s requested by item with text %s doesn't exist", tt.Style, tt.Text) 463 | return 464 | } 465 | t.Style = o.Styles[tt.Style] 466 | } 467 | 468 | // Append items 469 | l.Items = append(l.Items, t) 470 | } 471 | 472 | } 473 | s.Lines = append(s.Lines, *l) 474 | 475 | // Append subtitle 476 | o.Items = append(o.Items, s) 477 | } 478 | return 479 | } 480 | 481 | // TTMLOut represents an output TTML that must be marshaled 482 | // We split it from the input TTML as this time we'll add strict namespaces 483 | type TTMLOut struct { 484 | Lang string `xml:"xml:lang,attr,omitempty"` 485 | Metadata *TTMLOutMetadata `xml:"head>metadata,omitempty"` 486 | Styles []TTMLOutStyle `xml:"head>styling>style,omitempty"` //!\\ Order is important! Keep Styling above Layout 487 | Regions []TTMLOutRegion `xml:"head>layout>region,omitempty"` 488 | Subtitles []TTMLOutSubtitle `xml:"body>div>p,omitempty"` 489 | XMLName xml.Name `xml:"http://www.w3.org/ns/ttml tt"` 490 | XMLNamespaceTTM string `xml:"xmlns:ttm,attr"` 491 | XMLNamespaceTTS string `xml:"xmlns:tts,attr"` 492 | } 493 | 494 | // TTMLOutMetadata represents an output TTML Metadata 495 | type TTMLOutMetadata struct { 496 | Copyright string `xml:"ttm:copyright,omitempty"` 497 | Title string `xml:"ttm:title,omitempty"` 498 | } 499 | 500 | // TTMLOutStyleAttributes represents output TTML style attributes 501 | type TTMLOutStyleAttributes struct { 502 | BackgroundColor *string `xml:"tts:backgroundColor,attr,omitempty"` 503 | Color *string `xml:"tts:color,attr,omitempty"` 504 | Direction *string `xml:"tts:direction,attr,omitempty"` 505 | Display *string `xml:"tts:display,attr,omitempty"` 506 | DisplayAlign *string `xml:"tts:displayAlign,attr,omitempty"` 507 | Extent *string `xml:"tts:extent,attr,omitempty"` 508 | FontFamily *string `xml:"tts:fontFamily,attr,omitempty"` 509 | FontSize *string `xml:"tts:fontSize,attr,omitempty"` 510 | FontStyle *string `xml:"tts:fontStyle,attr,omitempty"` 511 | FontWeight *string `xml:"tts:fontWeight,attr,omitempty"` 512 | LineHeight *string `xml:"tts:lineHeight,attr,omitempty"` 513 | Opacity *string `xml:"tts:opacity,attr,omitempty"` 514 | Origin *string `xml:"tts:origin,attr,omitempty"` 515 | Overflow *string `xml:"tts:overflow,attr,omitempty"` 516 | Padding *string `xml:"tts:padding,attr,omitempty"` 517 | ShowBackground *string `xml:"tts:showBackground,attr,omitempty"` 518 | TextAlign *string `xml:"tts:textAlign,attr,omitempty"` 519 | TextDecoration *string `xml:"tts:textDecoration,attr,omitempty"` 520 | TextOutline *string `xml:"tts:textOutline,attr,omitempty"` 521 | UnicodeBidi *string `xml:"tts:unicodeBidi,attr,omitempty"` 522 | Visibility *string `xml:"tts:visibility,attr,omitempty"` 523 | WrapOption *string `xml:"tts:wrapOption,attr,omitempty"` 524 | WritingMode *string `xml:"tts:writingMode,attr,omitempty"` 525 | ZIndex *int `xml:"tts:zIndex,attr,omitempty"` 526 | } 527 | 528 | // ttmlOutStyleAttributesFromStyleAttributes converts StyleAttributes into a TTMLOutStyleAttributes 529 | func ttmlOutStyleAttributesFromStyleAttributes(s *StyleAttributes) TTMLOutStyleAttributes { 530 | if s == nil { 531 | return TTMLOutStyleAttributes{} 532 | } 533 | return TTMLOutStyleAttributes{ 534 | BackgroundColor: s.TTMLBackgroundColor, 535 | Color: s.TTMLColor, 536 | Direction: s.TTMLDirection, 537 | Display: s.TTMLDisplay, 538 | DisplayAlign: s.TTMLDisplayAlign, 539 | Extent: s.TTMLExtent, 540 | FontFamily: s.TTMLFontFamily, 541 | FontSize: s.TTMLFontSize, 542 | FontStyle: s.TTMLFontStyle, 543 | FontWeight: s.TTMLFontWeight, 544 | LineHeight: s.TTMLLineHeight, 545 | Opacity: s.TTMLOpacity, 546 | Origin: s.TTMLOrigin, 547 | Overflow: s.TTMLOverflow, 548 | Padding: s.TTMLPadding, 549 | ShowBackground: s.TTMLShowBackground, 550 | TextAlign: s.TTMLTextAlign, 551 | TextDecoration: s.TTMLTextDecoration, 552 | TextOutline: s.TTMLTextOutline, 553 | UnicodeBidi: s.TTMLUnicodeBidi, 554 | Visibility: s.TTMLVisibility, 555 | WrapOption: s.TTMLWrapOption, 556 | WritingMode: s.TTMLWritingMode, 557 | ZIndex: s.TTMLZIndex, 558 | } 559 | } 560 | 561 | // TTMLOutHeader represents an output TTML header 562 | type TTMLOutHeader struct { 563 | ID string `xml:"xml:id,attr,omitempty"` 564 | Style string `xml:"style,attr,omitempty"` 565 | TTMLOutStyleAttributes 566 | } 567 | 568 | // TTMLOutRegion represents an output TTML region 569 | type TTMLOutRegion struct { 570 | TTMLOutHeader 571 | XMLName xml.Name `xml:"region"` 572 | } 573 | 574 | // TTMLOutStyle represents an output TTML style 575 | type TTMLOutStyle struct { 576 | TTMLOutHeader 577 | XMLName xml.Name `xml:"style"` 578 | } 579 | 580 | // TTMLOutSubtitle represents an output TTML subtitle 581 | type TTMLOutSubtitle struct { 582 | Begin TTMLOutDuration `xml:"begin,attr"` 583 | End TTMLOutDuration `xml:"end,attr"` 584 | ID string `xml:"id,attr,omitempty"` 585 | Items []TTMLOutItem 586 | Region string `xml:"region,attr,omitempty"` 587 | Style string `xml:"style,attr,omitempty"` 588 | TTMLOutStyleAttributes 589 | } 590 | 591 | // TTMLOutItem represents an output TTML Item 592 | type TTMLOutItem struct { 593 | Style string `xml:"style,attr,omitempty"` 594 | Text string `xml:",chardata"` 595 | TTMLOutStyleAttributes 596 | XMLName xml.Name 597 | } 598 | 599 | // TTMLOutDuration represents an output TTML duration 600 | type TTMLOutDuration time.Duration 601 | 602 | // MarshalText implements the TextMarshaler interface 603 | func (t TTMLOutDuration) MarshalText() ([]byte, error) { 604 | return []byte(formatDuration(time.Duration(t), ".", 3)), nil 605 | } 606 | 607 | // WriteToTTMLOptions represents TTML write options. 608 | type WriteToTTMLOptions struct { 609 | Indent string // Default is 4 spaces. 610 | } 611 | 612 | // WriteToTTMLOption represents a WriteToTTML option. 613 | type WriteToTTMLOption func(o *WriteToTTMLOptions) 614 | 615 | // WriteToTTMLWithIndentOption sets the indent option. 616 | func WriteToTTMLWithIndentOption(indent string) WriteToTTMLOption { 617 | return func(o *WriteToTTMLOptions) { 618 | o.Indent = indent 619 | } 620 | } 621 | 622 | // WriteToTTML writes subtitles in .ttml format 623 | func (s Subtitles) WriteToTTML(o io.Writer, opts ...WriteToTTMLOption) (err error) { 624 | // Create write options 625 | wo := &WriteToTTMLOptions{Indent: " "} 626 | for _, opt := range opts { 627 | opt(wo) 628 | } 629 | 630 | // Do not write anything if no subtitles 631 | if len(s.Items) == 0 { 632 | return ErrNoSubtitlesToWrite 633 | } 634 | 635 | // Init TTML 636 | var ttml = TTMLOut{ 637 | XMLNamespaceTTM: "http://www.w3.org/ns/ttml#metadata", 638 | XMLNamespaceTTS: "http://www.w3.org/ns/ttml#styling", 639 | } 640 | 641 | // Add metadata 642 | if s.Metadata != nil { 643 | if v, ok := ttmlLanguageMapping.GetInverse(s.Metadata.Language); ok { 644 | ttml.Lang = v.(string) 645 | } 646 | if len(s.Metadata.TTMLCopyright) > 0 || len(s.Metadata.Title) > 0 { 647 | ttml.Metadata = &TTMLOutMetadata{ 648 | Copyright: s.Metadata.TTMLCopyright, 649 | Title: s.Metadata.Title, 650 | } 651 | } 652 | } 653 | 654 | // Add regions 655 | var k []string 656 | for _, region := range s.Regions { 657 | k = append(k, region.ID) 658 | } 659 | sort.Strings(k) 660 | for _, id := range k { 661 | var ttmlRegion = TTMLOutRegion{TTMLOutHeader: TTMLOutHeader{ 662 | ID: s.Regions[id].ID, 663 | TTMLOutStyleAttributes: ttmlOutStyleAttributesFromStyleAttributes(s.Regions[id].InlineStyle), 664 | }} 665 | if s.Regions[id].Style != nil { 666 | ttmlRegion.Style = s.Regions[id].Style.ID 667 | } 668 | ttml.Regions = append(ttml.Regions, ttmlRegion) 669 | } 670 | 671 | // Add styles 672 | k = []string{} 673 | for _, style := range s.Styles { 674 | k = append(k, style.ID) 675 | } 676 | sort.Strings(k) 677 | for _, id := range k { 678 | var ttmlStyle = TTMLOutStyle{TTMLOutHeader: TTMLOutHeader{ 679 | ID: s.Styles[id].ID, 680 | TTMLOutStyleAttributes: ttmlOutStyleAttributesFromStyleAttributes(s.Styles[id].InlineStyle), 681 | }} 682 | if s.Styles[id].Style != nil { 683 | ttmlStyle.Style = s.Styles[id].Style.ID 684 | } 685 | ttml.Styles = append(ttml.Styles, ttmlStyle) 686 | } 687 | 688 | // Add items 689 | for _, item := range s.Items { 690 | // Init subtitle 691 | var ttmlSubtitle = TTMLOutSubtitle{ 692 | Begin: TTMLOutDuration(item.StartAt), 693 | End: TTMLOutDuration(item.EndAt), 694 | TTMLOutStyleAttributes: ttmlOutStyleAttributesFromStyleAttributes(item.InlineStyle), 695 | } 696 | 697 | // Add region 698 | if item.Region != nil { 699 | ttmlSubtitle.Region = item.Region.ID 700 | } 701 | 702 | // Add style 703 | if item.Style != nil { 704 | ttmlSubtitle.Style = item.Style.ID 705 | } 706 | 707 | // Add lines 708 | for _, line := range item.Lines { 709 | // Loop through line items 710 | for _, lineItem := range line.Items { 711 | // Init ttml item 712 | var ttmlItem = TTMLOutItem{ 713 | Text: lineItem.Text, 714 | TTMLOutStyleAttributes: ttmlOutStyleAttributesFromStyleAttributes(lineItem.InlineStyle), 715 | XMLName: xml.Name{Local: "span"}, 716 | } 717 | 718 | // Add style 719 | if lineItem.Style != nil { 720 | ttmlItem.Style = lineItem.Style.ID 721 | } 722 | 723 | // Add ttml item 724 | ttmlSubtitle.Items = append(ttmlSubtitle.Items, ttmlItem) 725 | } 726 | 727 | // Add line break 728 | ttmlSubtitle.Items = append(ttmlSubtitle.Items, TTMLOutItem{XMLName: xml.Name{Local: "br"}}) 729 | } 730 | 731 | // Remove last line break 732 | if len(ttmlSubtitle.Items) > 0 { 733 | ttmlSubtitle.Items = ttmlSubtitle.Items[:len(ttmlSubtitle.Items)-1] 734 | } 735 | 736 | // Append subtitle 737 | ttml.Subtitles = append(ttml.Subtitles, ttmlSubtitle) 738 | } 739 | 740 | // Marshal XML 741 | var e = xml.NewEncoder(o) 742 | 743 | // Set indent 744 | e.Indent("", wo.Indent) 745 | 746 | if err = e.Encode(ttml); err != nil { 747 | err = fmt.Errorf("astisub: xml encoding failed: %w", err) 748 | return 749 | } 750 | return 751 | } 752 | -------------------------------------------------------------------------------- /ttml_internal_test.go: -------------------------------------------------------------------------------- 1 | package astisub 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestTTMLDuration(t *testing.T) { 11 | // Unmarshal hh:mm:ss.mmm format - clock time 12 | var d = &TTMLInDuration{} 13 | err := d.UnmarshalText([]byte("12:34:56.789")) 14 | assert.NoError(t, err) 15 | assert.Equal(t, 12*time.Hour+34*time.Minute+56*time.Second+789*time.Millisecond, d.duration()) 16 | 17 | // Marshal 18 | b, err := TTMLOutDuration(d.duration()).MarshalText() 19 | assert.NoError(t, err) 20 | assert.Equal(t, "12:34:56.789", string(b)) 21 | 22 | // Unmarshal hh:mm:ss:fff format 23 | err = d.UnmarshalText([]byte("12:34:56:2")) 24 | assert.NoError(t, err) 25 | assert.Equal(t, 12*time.Hour+34*time.Minute+56*time.Second, d.duration()) 26 | assert.Equal(t, 2, d.frames) 27 | 28 | // Duration 29 | d.framerate = 8 30 | assert.Equal(t, 12*time.Hour+34*time.Minute+56*time.Second+250*time.Millisecond, d.duration()) 31 | 32 | // Unmarshal offset time 33 | err = d.UnmarshalText([]byte("123h")) 34 | assert.Equal(t, 123*time.Hour, d.duration()) 35 | assert.NoError(t, err) 36 | 37 | err = d.UnmarshalText([]byte("123.4h")) 38 | assert.Equal(t, 123*time.Hour+4*time.Hour/10, d.duration()) 39 | assert.NoError(t, err) 40 | 41 | err = d.UnmarshalText([]byte("123m")) 42 | assert.Equal(t, 123*time.Minute, d.duration()) 43 | assert.NoError(t, err) 44 | 45 | err = d.UnmarshalText([]byte("123.4m")) 46 | assert.Equal(t, 123*time.Minute+4*time.Minute/10, d.duration()) 47 | assert.NoError(t, err) 48 | 49 | err = d.UnmarshalText([]byte("123s")) 50 | assert.Equal(t, 123*time.Second, d.duration()) 51 | assert.NoError(t, err) 52 | 53 | err = d.UnmarshalText([]byte("123.4s")) 54 | assert.Equal(t, 123*time.Second+4*time.Second/10, d.duration()) 55 | assert.NoError(t, err) 56 | 57 | err = d.UnmarshalText([]byte("123ms")) 58 | assert.Equal(t, 123*time.Millisecond, d.duration()) 59 | assert.NoError(t, err) 60 | 61 | err = d.UnmarshalText([]byte("123.4ms")) 62 | assert.Equal(t, 123*time.Millisecond+4*time.Millisecond/10, d.duration()) 63 | assert.NoError(t, err) 64 | 65 | d.framerate = 25 66 | err = d.UnmarshalText([]byte("100f")) 67 | assert.Equal(t, 4*time.Second, d.duration()) 68 | assert.NoError(t, err) 69 | 70 | // Tick rate duration 71 | d.tickrate = 4 72 | err = d.UnmarshalText([]byte("6t")) 73 | assert.Equal(t, time.Second+500*time.Millisecond, d.duration()) 74 | assert.NoError(t, err) 75 | } 76 | -------------------------------------------------------------------------------- /ttml_test.go: -------------------------------------------------------------------------------- 1 | package astisub_test 2 | 3 | import ( 4 | "bytes" 5 | "io/ioutil" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/asticode/go-astikit" 10 | 11 | "github.com/asticode/go-astisub" 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | func TestTTML(t *testing.T) { 16 | // Open 17 | s, err := astisub.OpenFile("./testdata/example-in.ttml") 18 | assert.NoError(t, err) 19 | assertSubtitleItems(t, s) 20 | // Metadata 21 | assert.Equal(t, &astisub.Metadata{Framerate: 25, Language: astisub.LanguageFrench, Title: "Title test", TTMLCopyright: "Copyright test"}, s.Metadata) 22 | // Styles 23 | 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"]) 27 | // Regions 28 | assert.Equal(t, 3, len(s.Regions)) 29 | assert.Equal(t, astisub.Region{ID: "region_0", Style: s.Styles["style_0"], InlineStyle: &astisub.StyleAttributes{TTMLColor: astikit.StrPtr("blue")}}, *s.Regions["region_0"]) 30 | assert.Equal(t, astisub.Region{ID: "region_1", Style: s.Styles["style_1"], InlineStyle: &astisub.StyleAttributes{}}, *s.Regions["region_1"]) 31 | assert.Equal(t, astisub.Region{ID: "region_2", Style: s.Styles["style_2"], InlineStyle: &astisub.StyleAttributes{}}, *s.Regions["region_2"]) 32 | // Items 33 | assert.Equal(t, s.Regions["region_1"], s.Items[0].Region) 34 | assert.Equal(t, s.Styles["style_1"], s.Items[0].Style) 35 | assert.Equal(t, &astisub.StyleAttributes{TTMLColor: astikit.StrPtr("red")}, s.Items[0].InlineStyle) 36 | assert.Equal(t, []astisub.Line{{Items: []astisub.LineItem{{Style: s.Styles["style_1"], InlineStyle: &astisub.StyleAttributes{TTMLColor: astikit.StrPtr("black")}, Text: "(deep rumbling)"}}}}, s.Items[0].Lines) 37 | assert.Equal(t, []astisub.Line{{Items: []astisub.LineItem{{InlineStyle: &astisub.StyleAttributes{}, Text: "MAN:"}}}, {Items: []astisub.LineItem{{InlineStyle: &astisub.StyleAttributes{}, Text: "How did we "}, {InlineStyle: &astisub.StyleAttributes{TTMLColor: astikit.StrPtr("green")}, Style: s.Styles["style_1"], Text: "end up"}, {InlineStyle: &astisub.StyleAttributes{}, Text: " here?"}}}}, s.Items[1].Lines) 38 | assert.Equal(t, []astisub.Line{{Items: []astisub.LineItem{{InlineStyle: &astisub.StyleAttributes{}, Style: s.Styles["style_1"], Text: "This place is horrible."}}}}, s.Items[2].Lines) 39 | assert.Equal(t, []astisub.Line{{Items: []astisub.LineItem{{InlineStyle: &astisub.StyleAttributes{}, Style: s.Styles["style_1"], Text: "Smells like balls."}}}}, s.Items[3].Lines) 40 | assert.Equal(t, []astisub.Line{{Items: []astisub.LineItem{{InlineStyle: &astisub.StyleAttributes{}, Style: s.Styles["style_2"], Text: "We don't belong"}}}, {Items: []astisub.LineItem{{InlineStyle: &astisub.StyleAttributes{}, Style: s.Styles["style_1"], Text: "in this shithole."}}}}, s.Items[4].Lines) 41 | assert.Equal(t, []astisub.Line{{Items: []astisub.LineItem{{InlineStyle: &astisub.StyleAttributes{}, Style: s.Styles["style_2"], Text: "(computer playing"}}}, {Items: []astisub.LineItem{{InlineStyle: &astisub.StyleAttributes{}, Style: s.Styles["style_1"], Text: "electronic melody)"}}}}, s.Items[5].Lines) 42 | 43 | // No subtitles to write 44 | w := &bytes.Buffer{} 45 | err = astisub.Subtitles{}.WriteToTTML(w) 46 | assert.EqualError(t, err, astisub.ErrNoSubtitlesToWrite.Error()) 47 | 48 | // Write 49 | c, err := ioutil.ReadFile("./testdata/example-out.ttml") 50 | assert.NoError(t, err) 51 | err = s.WriteToTTML(w) 52 | assert.NoError(t, err) 53 | assert.Equal(t, string(c), w.String()) 54 | } 55 | 56 | func TestTTMLBreakLines(t *testing.T) { 57 | // Open 58 | s, err := astisub.OpenFile("./testdata/example-in-breaklines.ttml") 59 | assert.NoError(t, err) 60 | 61 | // Write 62 | w := &bytes.Buffer{} 63 | err = s.WriteToTTML(w) 64 | assert.NoError(t, err) 65 | 66 | c, err := ioutil.ReadFile("./testdata/example-out-breaklines.ttml") 67 | assert.NoError(t, err) 68 | 69 | assert.Equal(t, strings.TrimSpace(string(c)), strings.TrimSpace(w.String())) 70 | } 71 | 72 | func TestWriteToTTMLWithIndentOption(t *testing.T) { 73 | // Open 74 | s, err := astisub.OpenFile("./testdata/example-in.ttml") 75 | assert.NoError(t, err) 76 | 77 | // Write 78 | w := &bytes.Buffer{} 79 | 80 | err = s.WriteToTTML(w, astisub.WriteToTTMLWithIndentOption("")) 81 | assert.NoError(t, err) 82 | 83 | c, err := ioutil.ReadFile("./testdata/example-out-no-indent.ttml") 84 | assert.NoError(t, err) 85 | 86 | assert.Equal(t, strings.TrimSpace(string(c)), strings.TrimSpace(w.String())) 87 | } 88 | -------------------------------------------------------------------------------- /webvtt.go: -------------------------------------------------------------------------------- 1 | package astisub 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "log" 8 | "regexp" 9 | "sort" 10 | "strconv" 11 | "strings" 12 | "time" 13 | "unicode/utf8" 14 | 15 | "golang.org/x/net/html" 16 | ) 17 | 18 | // https://www.w3.org/TR/webvtt1/ 19 | 20 | // Constants 21 | const ( 22 | webvttBlockNameComment = "comment" 23 | webvttBlockNameRegion = "region" 24 | webvttBlockNameStyle = "style" 25 | webvttBlockNameText = "text" 26 | webvttDefaultStyleID = "astisub-webvtt-default-style-id" 27 | webvttTimeBoundariesSeparator = "-->" 28 | webvttTimestampMapHeader = "X-TIMESTAMP-MAP" 29 | ) 30 | 31 | // Vars 32 | var ( 33 | bytesWebVTTItalicEndTag = []byte("