mirror of
https://github.com/zoriya/go-astisub.git
synced 2026-08-16 02:33:21 +00:00
321 lines
7.0 KiB
Go
321 lines
7.0 KiB
Go
package astisub
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"golang.org/x/net/html"
|
|
)
|
|
|
|
// Constants
|
|
const (
|
|
srtTimeBoundariesSeparator = "-->"
|
|
)
|
|
|
|
// Vars
|
|
var (
|
|
bytesSRTTimeBoundariesSeparator = []byte(" " + srtTimeBoundariesSeparator + " ")
|
|
)
|
|
|
|
// parseDurationSRT parses an .srt duration
|
|
func parseDurationSRT(i string) (d time.Duration, err error) {
|
|
for _, s := range []string{",", ".", ":"} {
|
|
if d, err = parseDuration(i, s, 3); err == nil {
|
|
return
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
// ReadFromSRT parses an .srt content
|
|
func ReadFromSRT(i io.Reader) (o *Subtitles, err error) {
|
|
// Init
|
|
o = NewSubtitles()
|
|
var scanner = newScanner(i)
|
|
|
|
// Scan
|
|
var line string
|
|
var lineNum int
|
|
var s = &Item{}
|
|
var sa = &StyleAttributes{}
|
|
for scanner.Scan() {
|
|
// Fetch line
|
|
line = strings.TrimSpace(scanner.Text())
|
|
lineNum++
|
|
if !utf8.ValidString(line) {
|
|
err = fmt.Errorf("astisub: line %d is not valid utf-8", lineNum)
|
|
return
|
|
}
|
|
|
|
// Remove BOM header
|
|
if lineNum == 1 {
|
|
line = strings.TrimPrefix(line, string(BytesBOM))
|
|
}
|
|
|
|
// Line contains time boundaries
|
|
if strings.Contains(line, srtTimeBoundariesSeparator) {
|
|
// Reset style attributes
|
|
sa = &StyleAttributes{}
|
|
|
|
// Remove last item of previous subtitle since it should be the index.
|
|
// If the last line is empty then the item is missing an index.
|
|
var index string
|
|
if len(s.Lines) != 0 {
|
|
index = s.Lines[len(s.Lines)-1].String()
|
|
if index != "" {
|
|
s.Lines = s.Lines[:len(s.Lines)-1]
|
|
}
|
|
}
|
|
|
|
// Remove trailing empty lines
|
|
if len(s.Lines) > 0 {
|
|
for i := len(s.Lines) - 1; i >= 0; i-- {
|
|
if len(s.Lines[i].Items) > 0 {
|
|
for j := len(s.Lines[i].Items) - 1; j >= 0; j-- {
|
|
if len(s.Lines[i].Items[j].Text) == 0 {
|
|
s.Lines[i].Items = s.Lines[i].Items[:j]
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
if len(s.Lines[i].Items) == 0 {
|
|
s.Lines = s.Lines[:i]
|
|
}
|
|
|
|
}
|
|
}
|
|
}
|
|
|
|
// Init subtitle
|
|
s = &Item{}
|
|
|
|
// Fetch Index
|
|
if index != "" {
|
|
s.Index, _ = strconv.Atoi(index)
|
|
}
|
|
|
|
// Extract time boundaries
|
|
s1 := strings.Split(line, srtTimeBoundariesSeparator)
|
|
if l := len(s1); l < 2 {
|
|
err = fmt.Errorf("astisub: line %d: time boundaries has only %d element(s)", lineNum, l)
|
|
return
|
|
}
|
|
// We do this to eliminate extra stuff like positions which are not documented anywhere
|
|
s2 := strings.Fields(s1[1])
|
|
if len(s2) == 0 {
|
|
err = fmt.Errorf("astisub: line %d: missing srt end time boundary", lineNum)
|
|
return
|
|
}
|
|
|
|
// Parse time boundaries
|
|
if s.StartAt, err = parseDurationSRT(s1[0]); err != nil {
|
|
err = fmt.Errorf("astisub: line %d: parsing srt duration %s failed: %w", lineNum, s1[0], err)
|
|
return
|
|
}
|
|
if s.EndAt, err = parseDurationSRT(s2[0]); err != nil {
|
|
err = fmt.Errorf("astisub: line %d: parsing srt duration %s failed: %w", lineNum, s2[0], err)
|
|
return
|
|
}
|
|
|
|
// Append subtitle
|
|
o.Items = append(o.Items, s)
|
|
} else {
|
|
// Add text
|
|
if l := parseTextSrt(line, sa); len(l.Items) > 0 {
|
|
s.Lines = append(s.Lines, l)
|
|
}
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
// parseTextSrt parses the input line to fill the Line
|
|
func parseTextSrt(i string, sa *StyleAttributes) (o Line) {
|
|
// special handling needed for empty line
|
|
if strings.TrimSpace(i) == "" {
|
|
o.Items = []LineItem{{Text: ""}}
|
|
return
|
|
}
|
|
|
|
// Create tokenizer
|
|
tr := html.NewTokenizer(strings.NewReader(i))
|
|
|
|
// Loop
|
|
for {
|
|
// Get next tag
|
|
t := tr.Next()
|
|
|
|
// Process error
|
|
if err := tr.Err(); err != nil {
|
|
break
|
|
}
|
|
|
|
// Get unmodified text
|
|
raw := string(tr.Raw())
|
|
// Get current token
|
|
token := tr.Token()
|
|
|
|
switch t {
|
|
case html.EndTagToken:
|
|
// Parse italic/bold/underline
|
|
switch token.Data {
|
|
case "b":
|
|
sa.SRTBold = false
|
|
case "i":
|
|
sa.SRTItalics = false
|
|
case "u":
|
|
sa.SRTUnderline = false
|
|
case "font":
|
|
sa.SRTColor = nil
|
|
}
|
|
case html.StartTagToken:
|
|
// Parse italic/bold/underline
|
|
switch token.Data {
|
|
case "b":
|
|
sa.SRTBold = true
|
|
case "i":
|
|
sa.SRTItalics = true
|
|
case "u":
|
|
sa.SRTUnderline = true
|
|
case "font":
|
|
if c := htmlTokenAttribute(&token, "color"); c != nil {
|
|
// Parse the color string into a Color struct
|
|
if color, err := newColorFromHTMLString(*c); err == nil {
|
|
sa.SRTColor = color
|
|
}
|
|
}
|
|
}
|
|
case html.TextToken:
|
|
if s := strings.TrimSpace(raw); s != "" {
|
|
// Get style attribute
|
|
var styleAttributes *StyleAttributes
|
|
if sa.SRTBold || sa.SRTColor != nil || sa.SRTItalics || sa.SRTUnderline {
|
|
styleAttributes = &StyleAttributes{
|
|
SRTBold: sa.SRTBold,
|
|
SRTColor: sa.SRTColor,
|
|
SRTItalics: sa.SRTItalics,
|
|
SRTUnderline: sa.SRTUnderline,
|
|
}
|
|
styleAttributes.propagateSRTAttributes()
|
|
}
|
|
|
|
// Append item
|
|
o.Items = append(o.Items, LineItem{
|
|
InlineStyle: styleAttributes,
|
|
Text: unescapeHTML(raw),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
// formatDurationSRT formats an .srt duration
|
|
func formatDurationSRT(i time.Duration) string {
|
|
return formatDuration(i, ",", 3)
|
|
}
|
|
|
|
// WriteToSRT writes subtitles in .srt format
|
|
func (s Subtitles) WriteToSRT(o io.Writer) (err error) {
|
|
// Do not write anything if no subtitles
|
|
if len(s.Items) == 0 {
|
|
err = ErrNoSubtitlesToWrite
|
|
return
|
|
}
|
|
|
|
// Add BOM header
|
|
var c []byte
|
|
c = append(c, BytesBOM...)
|
|
|
|
// Loop through subtitles
|
|
for k, v := range s.Items {
|
|
// Add time boundaries
|
|
c = append(c, []byte(strconv.Itoa(k+1))...)
|
|
c = append(c, bytesLineSeparator...)
|
|
c = append(c, []byte(formatDurationSRT(v.StartAt))...)
|
|
c = append(c, bytesSRTTimeBoundariesSeparator...)
|
|
c = append(c, []byte(formatDurationSRT(v.EndAt))...)
|
|
c = append(c, bytesLineSeparator...)
|
|
|
|
// Loop through lines
|
|
for _, l := range v.Lines {
|
|
c = append(c, []byte(l.srtBytes())...)
|
|
}
|
|
|
|
// Add new line
|
|
c = append(c, bytesLineSeparator...)
|
|
}
|
|
|
|
// Remove last new line
|
|
c = c[:len(c)-1]
|
|
|
|
// Write
|
|
if _, err = o.Write(c); err != nil {
|
|
err = fmt.Errorf("astisub: writing failed: %w", err)
|
|
return
|
|
}
|
|
return
|
|
}
|
|
|
|
func (l Line) srtBytes() (c []byte) {
|
|
for _, li := range l.Items {
|
|
c = append(c, li.srtBytes()...)
|
|
}
|
|
c = append(c, bytesLineSeparator...)
|
|
return
|
|
}
|
|
|
|
func (li LineItem) srtBytes() (c []byte) {
|
|
// Get color
|
|
var color string
|
|
if li.InlineStyle != nil && li.InlineStyle.SRTColor != nil {
|
|
color = li.InlineStyle.SRTColor.HTMLString()
|
|
}
|
|
|
|
// Get bold/italics/underline
|
|
b := li.InlineStyle != nil && li.InlineStyle.SRTBold
|
|
i := li.InlineStyle != nil && li.InlineStyle.SRTItalics
|
|
u := li.InlineStyle != nil && li.InlineStyle.SRTUnderline
|
|
|
|
// Get position
|
|
var pos byte
|
|
if li.InlineStyle != nil {
|
|
pos = li.InlineStyle.SRTPosition
|
|
}
|
|
|
|
// Append
|
|
if color != "" {
|
|
c = append(c, []byte("<font color=\""+color+"\">")...)
|
|
}
|
|
if b {
|
|
c = append(c, []byte("<b>")...)
|
|
}
|
|
if i {
|
|
c = append(c, []byte("<i>")...)
|
|
}
|
|
if u {
|
|
c = append(c, []byte("<u>")...)
|
|
}
|
|
if pos != 0 {
|
|
c = append(c, []byte(fmt.Sprintf(`{\an%d}`, pos))...)
|
|
}
|
|
c = append(c, []byte(escapeHTML(li.Text))...)
|
|
if u {
|
|
c = append(c, []byte("</u>")...)
|
|
}
|
|
if i {
|
|
c = append(c, []byte("</i>")...)
|
|
}
|
|
if b {
|
|
c = append(c, []byte("</b>")...)
|
|
}
|
|
if color != "" {
|
|
c = append(c, []byte("</font>")...)
|
|
}
|
|
return
|
|
}
|