710 lines
17 KiB
PHP
710 lines
17 KiB
PHP
<?php
|
|
|
|
namespace Captioning;
|
|
|
|
abstract class File implements FileInterface, \Countable
|
|
{
|
|
const DEFAULT_ENCODING = 'UTF-8';
|
|
|
|
const UNIX_LINE_ENDING = "\n";
|
|
const MAC_LINE_ENDING = "\r";
|
|
const WINDOWS_LINE_ENDING = "\r\n";
|
|
|
|
/**
|
|
* @var array
|
|
*/
|
|
protected $cues;
|
|
|
|
/**
|
|
* @var string
|
|
*/
|
|
protected $filename;
|
|
|
|
/**
|
|
* @var string
|
|
*/
|
|
protected $encoding = self::DEFAULT_ENCODING;
|
|
|
|
/**
|
|
* @var bool
|
|
*/
|
|
protected $useIconv;
|
|
|
|
/**
|
|
* @var string
|
|
*/
|
|
protected $lineEnding;
|
|
|
|
/**
|
|
* @var string
|
|
*/
|
|
protected $fileContent;
|
|
|
|
/**
|
|
* @var array
|
|
*/
|
|
protected $stats;
|
|
|
|
/**
|
|
* File constructor.
|
|
* @param string|null $_filename
|
|
* @param string|null $_encoding
|
|
* @param bool|false $_useIconv
|
|
*/
|
|
public function __construct($_filename = null, $_encoding = null, $_useIconv = false)
|
|
{
|
|
$this->lineEnding = self::UNIX_LINE_ENDING;
|
|
|
|
if ($_filename !== null) {
|
|
$this->setFilename($_filename);
|
|
}
|
|
|
|
if ($_encoding !== null) {
|
|
$this->setEncoding($_encoding);
|
|
}
|
|
|
|
$this->useIconv = $_useIconv;
|
|
|
|
if ($this->getFilename() !== null) {
|
|
$this->loadFromFile();
|
|
}
|
|
|
|
$this->stats = [
|
|
'tooSlow' => 0,
|
|
'slowAcceptable' => 0,
|
|
'aBitSlow' => 0,
|
|
'goodSlow' => 0,
|
|
'perfect' => 0,
|
|
'goodFast' => 0,
|
|
'aBitFast' => 0,
|
|
'fastAcceptable' => 0,
|
|
'tooFast' => 0
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param string $_filename The filename
|
|
* @return $this
|
|
*/
|
|
public function setFilename(string $_filename): self
|
|
{
|
|
$this->filename = file_exists($_filename) ? $_filename : null;
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* @param string $_encoding
|
|
* @return $this
|
|
*/
|
|
public function setEncoding(string $_encoding): self
|
|
{
|
|
$this->encoding = $_encoding;
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* @param bool $_useIconv
|
|
* @return $this
|
|
*/
|
|
public function setUseIconv(bool $_useIconv): self
|
|
{
|
|
$this->useIconv = $_useIconv;
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* @param string $_lineEnding
|
|
*/
|
|
public function setLineEnding(string $_lineEnding)
|
|
{
|
|
$lineEndings = [
|
|
self::UNIX_LINE_ENDING,
|
|
self::MAC_LINE_ENDING,
|
|
self::WINDOWS_LINE_ENDING
|
|
];
|
|
if (!in_array($_lineEnding, $lineEndings)) {
|
|
return;
|
|
}
|
|
|
|
$this->lineEnding = $_lineEnding;
|
|
|
|
if ($this->getCuesCount() > 0) {
|
|
foreach ($this->cues as $cue) {
|
|
$cue->setLineEnding($this->lineEnding);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return string|null
|
|
*/
|
|
public function getFilename()
|
|
{
|
|
return $this->filename;
|
|
}
|
|
|
|
/**
|
|
* @return string|null
|
|
*/
|
|
public function getFileContent()
|
|
{
|
|
return $this->fileContent;
|
|
}
|
|
|
|
/**
|
|
* @return string
|
|
*/
|
|
public function getEncoding(): string
|
|
{
|
|
return $this->encoding;
|
|
}
|
|
|
|
/**
|
|
* @return bool|false
|
|
*/
|
|
public function getUseIconv(): bool
|
|
{
|
|
return $this->useIconv;
|
|
}
|
|
|
|
/**
|
|
* @param integer $_index
|
|
* @return Cue|null
|
|
*/
|
|
public function getCue(int $_index)
|
|
{
|
|
return $this->cues[$_index] ?? null;
|
|
}
|
|
|
|
/**
|
|
* @return Cue|null
|
|
*/
|
|
public function getFirstCue()
|
|
{
|
|
return $this->cues[0] ?? null;
|
|
}
|
|
|
|
/**
|
|
* @return Cue|null
|
|
*/
|
|
public function getLastCue()
|
|
{
|
|
$count = count($this->cues);
|
|
|
|
return ($count > 0) ? $this->cues[$count - 1] : null;
|
|
}
|
|
|
|
/**
|
|
* @return array
|
|
*/
|
|
public function getCues(): array
|
|
{
|
|
return $this->cues;
|
|
}
|
|
|
|
/**
|
|
* @return integer
|
|
*/
|
|
public function getCuesCount(): int
|
|
{
|
|
return count($this->cues);
|
|
}
|
|
|
|
/**
|
|
* @param null $_filename
|
|
* @return $this
|
|
* @throws \Exception
|
|
*/
|
|
public function loadFromFile($_filename = null): self
|
|
{
|
|
if ($_filename === null) {
|
|
$_filename = $this->filename;
|
|
} else {
|
|
$this->filename = $_filename;
|
|
}
|
|
|
|
if (!file_exists($_filename)) {
|
|
throw new \Exception('File "'.$_filename.'" not found.');
|
|
}
|
|
|
|
if (!($content = file_get_contents($this->filename))) {
|
|
throw new \Exception('Could not read file content ('.$_filename.').');
|
|
}
|
|
|
|
$this->loadFromString($content);
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* @param string $_str
|
|
* @return $this
|
|
*/
|
|
public function loadFromString(string $_str): self
|
|
{
|
|
// Clear cues from previous runs
|
|
$this->cues = [];
|
|
$this->fileContent = $_str;
|
|
|
|
$this->encode();
|
|
$this->parse();
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Searches a word/expression and returns ids of the matched entries
|
|
*
|
|
* @param string $_word
|
|
* @param boolean $_case_sensitive
|
|
* @param boolean $_strict
|
|
* @return array containing ids of entries
|
|
*/
|
|
public function search(string $_word, $_case_sensitive = false, $_strict = false): array
|
|
{
|
|
$list = [];
|
|
$pattern = preg_quote($_word, '#');
|
|
|
|
$pattern = str_replace(' ', '( |\r\n|\r|\n)', $pattern);
|
|
|
|
if ($_strict) {
|
|
$pattern = '($| |\r\n|\r|\n|\?|\!|\.|, )'.$pattern.'(^| |\r\n|\r|\n|\?|\!|\.|,)';
|
|
}
|
|
|
|
$pattern = '#'.$pattern.'#';
|
|
|
|
if (!$_case_sensitive) {
|
|
$pattern .= 'i';
|
|
}
|
|
|
|
$i = 0;
|
|
foreach ($this->cues as $cue) {
|
|
if (preg_match($pattern, $cue->getText())) {
|
|
$list[] = $i;
|
|
}
|
|
$i++;
|
|
}
|
|
|
|
return (count($list) > 0) ? $list : -1;
|
|
}
|
|
|
|
public function getCueFromStart($_start): int
|
|
{
|
|
$cueClass = self::getExpectedCueClass($this);
|
|
$start = is_int($_start) ? $_start : $cueClass::tc2ms($_start);
|
|
|
|
$prev_stop = 0;
|
|
$i = 0;
|
|
foreach ($this->cues as $cue) {
|
|
if (($start > $prev_stop && $start < $cue->getStart()) || ($start >= $cue->getStart() && $start < $cue->getStop())) {
|
|
break;
|
|
}
|
|
$prev_stop = $cue->getStop();
|
|
$i++;
|
|
}
|
|
|
|
return $i;
|
|
}
|
|
|
|
/**
|
|
* Add a cue
|
|
*
|
|
* @param mixed $_mixed An cue instance or a string representing the text
|
|
* @param string $_start A timecode
|
|
* @param string $_stop A timecode
|
|
*
|
|
* @throws \Exception
|
|
* @return File
|
|
*/
|
|
public function addCue($_mixed, $_start = null, $_stop = null): self
|
|
{
|
|
$fileFormat = self::getFormat($this);
|
|
|
|
// if $_mixed is a Cue
|
|
if (is_object($_mixed) && class_exists(get_class($_mixed)) && class_exists(__NAMESPACE__.'\Cue') && is_subclass_of($_mixed, __NAMESPACE__.'\Cue')) {
|
|
$cueFormat = Cue::getFormat($_mixed);
|
|
if ($cueFormat !== $fileFormat) {
|
|
throw new \Exception("Can't add a $cueFormat cue in a $fileFormat file.");
|
|
}
|
|
$_mixed->setLineEnding($this->lineEnding);
|
|
$this->cues[] = $_mixed;
|
|
} else {
|
|
$cueClass = self::getExpectedCueClass($this);
|
|
$cue = new $cueClass($_start, $_stop, $_mixed);
|
|
$cue->setLineEnding($this->lineEnding);
|
|
$this->cues[] = $cue;
|
|
}
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Removes a cue
|
|
*
|
|
* @param int $_index
|
|
* @return File
|
|
*/
|
|
public function removeCue(int $_index): self
|
|
{
|
|
if (isset($this->cues[$_index])) {
|
|
unset($this->cues[$_index]);
|
|
}
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Sorts cues
|
|
*/
|
|
public function sortCues(): self
|
|
{
|
|
if (count($this->cues) === 0) {
|
|
return $this;
|
|
}
|
|
|
|
$tmp = [];
|
|
|
|
$count = 0; // useful if 2 cues start at the same time code
|
|
foreach ($this->cues as $cue) {
|
|
$tmp[$cue->getStartMS().'.'.$count] = $cue;
|
|
$count++;
|
|
}
|
|
|
|
ksort($tmp);
|
|
|
|
$this->cues = [];
|
|
foreach ($tmp as $cue) {
|
|
$this->cues[] = $cue;
|
|
}
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Converts timecodes based on the specified FPS ratio
|
|
*
|
|
* @param float $_old_fps
|
|
* @param float $_new_fps
|
|
* @return File
|
|
*/
|
|
public function changeFPS(float $_old_fps, float $_new_fps): self
|
|
{
|
|
$cuesCount = $this->getCuesCount();
|
|
for ($i = 0; $i < $cuesCount; $i++) {
|
|
$cue = $this->getCue($i);
|
|
|
|
$old_start = $cue->getStart();
|
|
$old_stop = $cue->getStop();
|
|
|
|
$new_start = $old_start * ($_new_fps / $_old_fps);
|
|
$new_stop = $old_stop * ($_new_fps / $_old_fps);
|
|
|
|
$cue->setStart($new_start);
|
|
$cue->setStop($new_stop);
|
|
}
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* @param FileInterface $_file
|
|
* @return $this
|
|
* @throws \Exception
|
|
*/
|
|
public function merge(FileInterface $_file): self
|
|
{
|
|
if (!is_a($_file, get_class($this))) {
|
|
throw new \Exception('Can\'t merge! Wrong format: '.$this->getFormat($_file));
|
|
}
|
|
|
|
$this->cues = array_merge($this->cues, $_file->getCues());
|
|
$this->sortCues();
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Shifts a range of subtitles a specified amount of time.
|
|
*
|
|
* @param int $_time The time to use (ms), which can be positive or negative.
|
|
* @param int $_startIndex The subtitle index the range begins with.
|
|
* @param int $_endIndex The subtitle index the range ends with.
|
|
*/
|
|
public function shift(int $_time, $_startIndex = null, $_endIndex = null): bool
|
|
{
|
|
if ($_time === 0) {
|
|
return true;
|
|
}
|
|
|
|
if (null === $_startIndex) {
|
|
$_startIndex = 0;
|
|
}
|
|
if (null === $_endIndex) {
|
|
$_endIndex = $this->getCuesCount() - 1;
|
|
}
|
|
|
|
$startCue = $this->getCue($_startIndex);
|
|
$endCue = $this->getCue($_endIndex);
|
|
|
|
//check subtitles do exist
|
|
if (!$startCue || !$endCue) {
|
|
return false;
|
|
}
|
|
|
|
for ($i = $_startIndex; $i <= $_endIndex; $i++) {
|
|
$cue = $this->getCue($i);
|
|
$cue->shift($_time);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Auto syncs a range of subtitles given their first and last correct times.
|
|
* The subtitles are first shifted to the first subtitle's correct time, and then proportionally
|
|
* adjusted using the last subtitle's correct time.
|
|
*
|
|
* Based on gnome-subtitles (https://git.gnome.org/browse/gnome-subtitles/)
|
|
*
|
|
* @param int $_startIndex The subtitle index to start the adjustment with.
|
|
* @param int $_startTime The correct start time for the first subtitle.
|
|
* @param int $_endIndex The subtitle index to end the adjustment with.
|
|
* @param int $_endTime The correct start time for the last subtitle.
|
|
* @param bool $_syncLast Whether to sync the last subtitle.
|
|
* @return bool Whether the subtitles could be adjusted
|
|
*/
|
|
public function sync(int $_startIndex, int $_startTime, int $_endIndex, int $_endTime, $_syncLast = true): bool
|
|
{
|
|
//set first and last subtitles index
|
|
if (!$_startIndex) {
|
|
$_startIndex = 0;
|
|
}
|
|
if (!$_endIndex) {
|
|
$_endIndex = $this->getCuesCount() - 1;
|
|
}
|
|
if (!$_syncLast) {
|
|
$_endIndex--;
|
|
}
|
|
|
|
//check subtitles do exist
|
|
$startSubtitle = $this->getCue($_startIndex);
|
|
$endSubtitle = $this->getCue($_endIndex);
|
|
if (!$startSubtitle || !$endSubtitle || ($_startTime >= $_endTime)) {
|
|
return false;
|
|
}
|
|
|
|
$shift = $_startTime - $startSubtitle->getStartMS();
|
|
$factor = ($_endTime - $_startTime) / ($endSubtitle->getStartMS() - $startSubtitle->getStartMS());
|
|
|
|
/* Shift subtitles to the start point */
|
|
if ($shift) {
|
|
$this->shift($shift, $_startIndex, $_endIndex);
|
|
}
|
|
|
|
/* Sync timings with proportion */
|
|
for ($index = $_startIndex; $index <= $_endIndex; $index++) {
|
|
$cue = $this->getCue($index);
|
|
$cue->scale($_startTime, $factor);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* @return $this
|
|
*/
|
|
public function build(): FileInterface
|
|
{
|
|
$this->buildPart(0, $this->getCuesCount() - 1);
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Saves the file
|
|
*
|
|
* @param string $filename
|
|
* @param bool $writeBOM
|
|
*/
|
|
public function save($filename = null, $writeBOM = false)
|
|
{
|
|
if ($filename === null) {
|
|
$filename = $this->filename;
|
|
}
|
|
|
|
if (trim((string)$this->fileContent) === '') {
|
|
$this->build();
|
|
}
|
|
|
|
$file_content = $this->fileContent;
|
|
if (strtolower($this->encoding) !== 'utf-8') {
|
|
if ($this->useIconv) {
|
|
$file_content = iconv('UTF-8', $this->encoding, $file_content);
|
|
} else {
|
|
$file_content = mb_convert_encoding($file_content, $this->encoding, 'UTF-8');
|
|
}
|
|
}
|
|
|
|
if ($writeBOM) {
|
|
$file_content = "\xef\xbb\xbf".$file_content;
|
|
}
|
|
|
|
$res = file_put_contents($filename, $file_content);
|
|
if (!$res) {
|
|
throw new \Exception('Unable to save the file.');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Computes reading speed statistics
|
|
*/
|
|
public function getStats(): array
|
|
{
|
|
$this->stats = [
|
|
'tooSlow' => 0,
|
|
'slowAcceptable' => 0,
|
|
'aBitSlow' => 0,
|
|
'goodSlow' => 0,
|
|
'perfect' => 0,
|
|
'goodFast' => 0,
|
|
'aBitFast' => 0,
|
|
'fastAcceptable' => 0,
|
|
'tooFast' => 0
|
|
];
|
|
|
|
$cuesCount = $this->getCuesCount();
|
|
for ($i = 0; $i < $cuesCount; $i++) {
|
|
$rs = $this->getCue($i)->getReadingSpeed();
|
|
|
|
if ($rs < 5) {
|
|
$this->stats['tooSlow']++;
|
|
} elseif ($rs < 10) {
|
|
$this->stats['slowAcceptable']++;
|
|
} elseif ($rs < 13) {
|
|
$this->stats['aBitSlow']++;
|
|
} elseif ($rs < 15) {
|
|
$this->stats['goodSlow']++;
|
|
} elseif ($rs < 23) {
|
|
$this->stats['perfect']++;
|
|
} elseif ($rs < 27) {
|
|
$this->stats['goodFast']++;
|
|
} elseif ($rs < 31) {
|
|
$this->stats['aBitFast']++;
|
|
} elseif ($rs < 35) {
|
|
$this->stats['fastAcceptable']++;
|
|
} else {
|
|
$this->stats['tooFast']++;
|
|
}
|
|
}
|
|
|
|
return $this->stats;
|
|
}
|
|
|
|
/**
|
|
* @param $_file
|
|
* @return mixed
|
|
*/
|
|
public static function getFormat(FileInterface $_file)
|
|
{
|
|
if (!is_subclass_of($_file, __NAMESPACE__.'\File')) {
|
|
throw new \InvalidArgumentException('Expected subclass of File');
|
|
}
|
|
|
|
$fullNamespace = explode('\\', get_class($_file));
|
|
$tmp = explode('File', end($fullNamespace));
|
|
|
|
return $tmp[0];
|
|
}
|
|
|
|
/**
|
|
* @param FileInterface $_file
|
|
* @param bool|true $_full_namespace
|
|
* @return string
|
|
*/
|
|
public static function getExpectedCueClass(FileInterface $_file, $_full_namespace = true): string
|
|
{
|
|
$format = self::getFormat($_file).'Cue';
|
|
|
|
if ($_full_namespace) {
|
|
$tmp = explode('\\', get_class($_file));
|
|
array_pop($tmp);
|
|
$format = implode('\\', $tmp).'\\'.$format;
|
|
}
|
|
|
|
return $format;
|
|
}
|
|
|
|
/**
|
|
* @param string $_output_format
|
|
* @return mixed
|
|
*/
|
|
public function convertTo(string $_output_format): FileInterface
|
|
{
|
|
$fileFormat = self::getFormat($this);
|
|
$method = strtolower($fileFormat).'2'.strtolower(rtrim((string)$_output_format, 'File'));
|
|
|
|
if (method_exists(new Converter(), $method)) {
|
|
return Converter::$method($this);
|
|
}
|
|
return Converter::defaultConverter($this, $_output_format);
|
|
}
|
|
|
|
/**
|
|
* Encode file content
|
|
*/
|
|
protected function encode()
|
|
{
|
|
if ($this->useIconv) {
|
|
$this->fileContent = iconv($this->encoding, 'UTF-8', $this->fileContent);
|
|
} else {
|
|
$this->fileContent = mb_convert_encoding($this->fileContent, 'UTF-8', $this->encoding);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return array
|
|
* @throws \Exception
|
|
*/
|
|
protected function getFileContentAsArray(): array
|
|
{
|
|
if (empty($this->fileContent)) {
|
|
$this->loadFromFile($this->filename);
|
|
}
|
|
$fileContent = str_replace( // So we change line endings to one format
|
|
[
|
|
self::WINDOWS_LINE_ENDING,
|
|
self::MAC_LINE_ENDING,
|
|
],
|
|
self::UNIX_LINE_ENDING,
|
|
$this->fileContent
|
|
);
|
|
|
|
// Create array from file content
|
|
return explode(self::UNIX_LINE_ENDING, $fileContent);
|
|
}
|
|
|
|
/**
|
|
* @param array $array
|
|
* @return mixed
|
|
*/
|
|
protected function getNextValueFromArray(array &$array)
|
|
{
|
|
$element = current($array);
|
|
next($array);
|
|
|
|
return $element;
|
|
}
|
|
|
|
/**
|
|
* @return int
|
|
*/
|
|
#[\ReturnTypeWillChange]
|
|
public function count(): int
|
|
{
|
|
return $this->getCuesCount();
|
|
}
|
|
}
|