Recently Written · git

subread.koplugin

KOReader plugin: the book follows the narration of an audiobook, from an .srt made by subread.space

git clone https://github.com/equwal/subread.koplugin

Log | Files | Refs


subread/srt.lua (3693 bytes)

1 --[[--
2 SubRip (.srt) parser for SubRead.
3 
4 Pure Lua. No KOReader dependency, so it can be unit tested on a PC.
5 
6 The parser accepts:
7   * a UTF-8 byte order mark,
8   * CRLF, LF and CR line ends,
9   * "," or "." between the seconds and the fraction,
10   * a missing or wrong cue number,
11   * cue text on more than one line.
12 Blocks without a time stamp line are skipped.
13 The result is sorted by start time.
14 --]]--
15 
16 local Text = require("subread.text")
17 
18 local Srt = {}
19 
20 -- One time stamp: hours, minutes, seconds, fraction.
21 local STAMP = "(%d+):(%d+):(%d+)[,%.](%d+)"
22 
23 --- Converts one time stamp to seconds.
24 -- The fraction keeps its own scale, so "5" means 0.5 s and "500" means 0.5 s.
25 local function stampToSeconds(h, m, s, frac)
26     local value = tonumber(h) * 3600 + tonumber(m) * 60 + tonumber(s)
27     local denominator = 10 ^ #frac
28     return value + tonumber(frac) / denominator
29 end
30 
31 --- Reads a "start --> stop" line.
32 -- @return start seconds, stop seconds, or nil when the line is not a time line
33 function Srt.parseTimeLine(line)
34     local h1, m1, s1, f1, h2, m2, s2, f2 =
35         line:match(STAMP .. "%s*%-%-+>%s*" .. STAMP)
36     if not h1 then return nil end
37     return stampToSeconds(h1, m1, s1, f1), stampToSeconds(h2, m2, s2, f2)
38 end
39 
40 --- Splits text into lines. CRLF and CR both become a line end.
41 function Srt.splitLines(s)
42     s = s:gsub("\r\n", "\n"):gsub("\r", "\n")
43     local lines = {}
44     local from = 1
45     while true do
46         local at = s:find("\n", from, true)
47         if not at then
48             lines[#lines + 1] = s:sub(from)
49             break
50         end
51         lines[#lines + 1] = s:sub(from, at - 1)
52         from = at + 1
53     end
54     return lines
55 end
56 
57 --- Parses the content of a .srt file.
58 -- @string data the whole file
59 -- @return array of cues { start, stop, text, norm, no_place }, sorted by start
60 function Srt.parse(data)
61     local cues = {}
62     if not data or data == "" then return cues end
63     local lines = Srt.splitLines(Text.stripBOM(data))
64 
65     local i, count = 1, #lines
66     while i <= count do
67         local start, stop = Srt.parseTimeLine(lines[i])
68         if start then
69             i = i + 1
70             local parts = {}
71             while i <= count and lines[i]:match("%S") do
72                 -- A new time line ends the cue as well: it means the file has
73                 -- no blank line between the blocks.
74                 if Srt.parseTimeLine(lines[i]) then break end
75                 parts[#parts + 1] = lines[i]
76                 i = i + 1
77             end
78             local text = table.concat(parts, "\n")
79             local norm = Text.normalize(text)
80             if norm ~= "" then
81                 cues[#cues + 1] = {
82                     start = start,
83                     stop = stop,
84                     text = text,
85                     norm = norm,
86                     no_place = Text.hasNoPlace(norm),
87                 }
88             end
89         else
90             i = i + 1
91         end
92     end
93 
94     -- A stable sort by start time. table.sort is not stable, so the original
95     -- order is the second key.
96     for index, cue in ipairs(cues) do
97         cue.order = index
98     end
99     table.sort(cues, function(a, b)
100         if a.start ~= b.start then return a.start < b.start end
101         return a.order < b.order
102     end)
103     for _, cue in ipairs(cues) do
104         cue.order = nil
105     end
106     return cues
107 end
108 
109 --- Reads and parses a .srt file.
110 -- @return array of cues, or nil plus an error message
111 function Srt.parseFile(path)
112     local file, err = io.open(path, "rb")
113     if not file then return nil, err end
114     local data = file:read("*a")
115     file:close()
116     if not data then return nil, "empty file" end
117     return Srt.parse(data)
118 end
119 
120 return Srt