Files
2025-07-18 16:20:14 +07:00

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();
}
}