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