/** * 行を解析し、text、answer、descriptionフィールドに対応する文字列を取り出します。 * @param Dictionary $dictionary * @param string $line */ protected function parseLine(Dictionary $dictionary, string $line) { // コメントの分離 $textAndDescription = preg_split('#(/+|;+|\\[)#u', $line, 2, PREG_SPLIT_DELIM_CAPTURE); if ($textAndDescription[0] !== '' && str_replace(' ', '', $textAndDescription[0]) === '') { // 空行でなく、半角スペースのみで構成されている行なら throw new SyntaxException(_('半角スペースとコメントのみの行は作ることができません。')); } $answers = array_filter(array_map(function ($answer) { // 正規表現文字列扱いを抑止 return (new \esperecyan\dictionary_php\validator\AnswerValidator())->isRegExp($answer) ? trim($answer, '/') : $answer; }, explode(',', rtrim($textAndDescription[0], ' ')))); if ($answers) { if (count($answers) === 1) { $fieldsAsMultiDimensionalArray['text'] = $answers; } else { $fieldsAsMultiDimensionalArray = ['text' => [$answers[0]], 'answer' => $answers]; } if (isset($textAndDescription[1])) { $comment = trim($textAndDescription[1] === '[' ? rtrim($textAndDescription[2], ']') : $textAndDescription[2]); if ($comment !== '') { $fieldsAsMultiDimensionalArray['description'][] = $comment; } } try { $dictionary->addWord($fieldsAsMultiDimensionalArray); } catch (SyntaxException $e) { $this->logInconvertibleError($line, $e); } } }
/** * @param string[][][] $fieldsAsMultiDimensionalArrays * @param string[] $metadata * @param string[] $files * @return Dictionary */ protected function generateDictionary(array $fieldsAsMultiDimensionalArrays, array $metadata, array $files) : Dictionary { if ($files) { $tempDirectory = (new \esperecyan\dictionary_php\parser\GenericDictionaryParser())->generateTempDirectory(); foreach ($files as $filename => $file) { file_put_contents("{$tempDirectory}/{$filename}", $file); } } $dictionary = new Dictionary(isset($tempDirectory) ? new \FilesystemIterator($tempDirectory) : null); foreach ($fieldsAsMultiDimensionalArrays as $fieldsAsMultiDimensionalArray) { $dictionary->addWord($fieldsAsMultiDimensionalArray); } $dictionary->setMetadata($metadata); return $dictionary; }
/** * @param \SplFileInfo $file * @param string|null $filename * @param string|null $title * @throws SyntaxException お題の数、辞書全体の文字数が制限範囲外であるとき。 * @return Dictionary */ public function parse(\SplFileInfo $file, string $filename = null, string $title = null) : Dictionary { $dictionary = new Dictionary(); $words = []; if (!$file instanceof \SplFileObject) { $file = $file->openFile(); } else { $file->rewind(); } $file->setFlags(\SplFileObject::DROP_NEW_LINE | \SplFileObject::READ_AHEAD | \SplFileObject::SKIP_EMPTY); foreach ($file as $line) { $word = $this->parseLine($line); if (!in_array($word, $words)) { $dictionary->addWord(['text' => [str_replace('ヴ', 'ゔ', $line)]]); $words[] = $word; } } $wordsLength = count($words); if ($wordsLength < self::WORDS_MIN) { throw new SyntaxException(sprintf(_('お題が%1$d個しかありません。%2$d個以上必要です。'), $wordsLength, self::WORDS_MIN)); } if ($wordsLength > self::WORDS_MAX) { throw new SyntaxException(sprintf(_('お題が%1$d個あります。%2$d個以内にする必要があります。'), $wordsLength, self::WORDS_MAX)); } $dictionaryCodePoints = mb_strlen(implode('', $words), 'UTF-8'); if ($dictionaryCodePoints > self::DICTIONARY_CODE_POINTS_MAX) { throw new SyntaxException(sprintf(_('辞書全体で%1$d文字あります。%2$d文字以内にする必要があります。'), $dictionaryCodePoints, self::DICTIONARY_CODE_POINTS_MAX)); } if (!is_null($title) || !is_null($filename)) { if (!is_null($title) && $title !== '') { $trimedTitle = preg_replace('/^[ \\t]+|[ \\t]+$/u', '', $title); } if (!is_null($filename) && (!isset($trimedTitle) || $trimedTitle === '')) { $trimedTitle = preg_replace('/^[ \\t]+|[ \\t]+$/u', '', (new GenericDictionaryParser())->getTitleFromFilename($filename)); } if (isset($trimedTitle) && $trimedTitle !== '') { $dictionary->setMetadata(['@title' => $trimedTitle]); $titleLength = $this->getLengthAs16BitCodeUnits($trimedTitle); if ($titleLength > self::TITLE_MAX) { $this->logger->error(sprintf(_('辞書名が%1$d文字 (補助文字は2文字扱い) あります。ピクトセンスにおける辞書名の最大文字数は%2$d文字です。'), $titleLength, self::TITLE_MAX)); } } else { $this->logger->error(_('辞書名が空です。先頭末尾の空白は取り除かれます。')); } } return $dictionary; }
/** * 行を解析し、textフィールドとdescriptionフィールドに対応する文字列を取り出します。 * @param Dictionary $dictionary * @param string $line */ protected function parseLine(Dictionary $dictionary, string $line) { // コメントの分離 $textAndDescription = preg_split('#[\\t ]*//#u', $line, 2); if (str_replace(["\t", ' ', ' '], '', $textAndDescription[0]) === '') { throw new SyntaxException(_('空行 (スペース、コメントのみの行) があります。')); } else { $fieldsAsMultiDimensionalArray['text'] = [(new AnswerValidator())->isRegExp($textAndDescription[0]) ? trim($textAndDescription[0], '/') : $textAndDescription[0]]; if (isset($textAndDescription[1])) { // コメントが存在すれば $description = trim($textAndDescription[1], " \t"); if ($description !== '') { $fieldsAsMultiDimensionalArray['description'] = [$description]; } } try { $dictionary->addWord($fieldsAsMultiDimensionalArray); } catch (SyntaxException $e) { $this->logInconvertibleError($line, $e); } } }
/** * Q&Aを解析します。 * @param Dictionary $dictioanry * @param string $question * @param string $answer */ protected function parseQuizLines(Dictionary $dictioanry, string $question, string $answer) { $line = "{$question}\n{$answer}"; $specifics = new URLSearchParams(); $questionFields = explode(',', $question, 5); $answerFields = explode(',', $answer); if (!$this->isNumeric($questionFields[1])) { throw new SyntaxException(sprintf(_('出題の種類「%s」は数値として認識できません。'), $questionFields[1])); } elseif (!$this->isNumeric($answerFields[1])) { throw new SyntaxException(sprintf(_('解答の種類「%s」は数値として認識できません。'), $answerFields[1])); } /** @var int 出題の種類。 */ $questionType = $this->convertToInt($questionFields[1]); switch ($questionType) { case 1: // 音声ファイルを再生 // 音声ファイルを再生 case 2: // 画像ファイルを表示 if (empty($questionFields[3])) { throw new SyntaxException(_('ファイルが指定されていません。')); } // ファイル名 $fieldsAsMultiDimensionalArray[$questionType === 1 ? 'audio' : 'image'][] = $questionFields[3]; // 問題オプション if (isset($questionFields[4])) { foreach (explode(',', $questionFields[4]) as $option) { $option = explode('=', $option, 2); $name = $option[0]; $value = $option[1] ?? ''; if (!$this->isNumeric($value)) { throw new SyntaxException(sprintf(_('問題オプション %1$s の値「%2$s」は数値として認識できません。'), $name, $value)); } $int = $this->convertToInt($value); switch ($name) { case 'start': case 'media_start': $specifics->set('start', $int / self::SECONDS_TO_MILISECONDS); break; case 'repeat': $specifics->set('repeat', $int); break; case 'length': $specifics->set('length', $int / self::SECONDS_TO_MILISECONDS); break; case 'speed': $specifics->set('speed', $int / self::DECIMAL_TO_PERCENT); break; case 'zoom_start': $specifics->set('magnification', $int); break; case 'zoom_end': $specifics->set('last-magnification', $int); break; case 'mozaic': if ($int === 1) { $specifics->set('pixelization', ''); } else { $specifics->delete('pixelization'); } break; case 'score': $specifics->set('score', $int); break; case 'finalscore': $specifics->set('last-score', $int); break; } } } break; case 3: // Wikipediaクイズ // Wikipediaクイズ case 4: // アンサイクロペディアクイズ $this->logInconvertibleError("{$question}\n{$answer}"); return; } // 問題文 if (isset($questionFields[2])) { $fieldsAsMultiDimensionalArray['question'][] = $this->parseQuestionSentence($questionFields[2]); } /** @var int 解答の種類。 */ $answerType = $this->convertToInt($answerFields[1]); if (in_array($answerType, [1, 2, 3])) { $fieldsAsMultiDimensionalArray['type'][] = 'selection'; if ($answerType === 3) { $specifics->set('require-all-right', ''); } } else { $answerType = 0; } $mode = '|'; $answerValidator = new \esperecyan\dictionary_php\validator\AnswerValidator(); foreach (array_slice($answerFields, 2) as $i => $field) { if (isset($field[0]) && $field[0] === '\\' && isset($field[1]) && $field[1] !== '\\') { // 解答オプション if ($field === '\\norandom') { // 選択肢をシャッフルしない if (in_array($answerType, [1, 3])) { $specifics->set('no-random', ''); } } elseif ($field === '\\seikai') { // 直前の選択肢を正解扱いに if (in_array($answerType, [1, 3])) { if (empty($fieldsAsMultiDimensionalArray['option'])) { throw new SyntaxException(_('\\seikai の前には選択肢が必要です。')); } $fieldsAsMultiDimensionalArray['answer'][] = end($fieldsAsMultiDimensionalArray['option']); } } elseif (preg_match('/^\\\\explain=(.+)$/u', $field, $matches) === 1) { // 解説 $fieldsAsMultiDimensionalArray['description'][] = $this->parseQuestionSentence($matches[1]); } elseif ($answerType === 0 && preg_match('/^\\\\bonus=(.*)$/u', $field, $matches) === 1) { // ボーナスポイント if (!$this->isNumeric($matches[1])) { throw new SyntaxException(sprintf(_('解答オプション \\bonus の値「%s」は数値として認識できません。'), $matches[1])); } if (empty($answerPattenAndBonuses['|'])) { throw new SyntaxException(sprintf(_('解答オプション「%s」の前には解答本体が必要です。'), $field)); } $bonus = $this->convertToInt($matches[1]); if ($bonus !== 0) { $answerPattenAndBonuses['|'][count($answerPattenAndBonuses['|']) - 1]['bonus'] = $bonus; } } continue; } switch ($answerType) { case 0: // 記述形式 if ($field === '[[' || $field === '||') { // 正規表現 if (empty($answerPattenAndBonuses['|'])) { throw new SyntaxException(sprintf(_('「%s」の前には解答本体が必要です。'), $field)); } $end = count($answerPattenAndBonuses['|']) - 1; $answerPattenAndBonuses['|'][$end]['regexp'] = ($field === '||' ? '.*' : '') . preg_quote($answerPattenAndBonuses['|'][$end]['body'], '/') . '.*'; } elseif (in_array($field, ['[', '|', ']'])) { // モード変更 $mode = $field; } elseif ($mode === '|') { $answerPattenAndBonuses['|'][]['body'] = $field; } else { $answerPattenAndBonuses[$mode][] = $field; } break; case 1: // 選択形式 // 選択形式 case 3: // 全選択形式 $fieldsAsMultiDimensionalArray['option'][] = $answerValidator->isRegExp($field) ? trim($field, '/') : $field; break; case 2: // 並べ替え形式 if ($i % 2 === 0) { $numbersAndOptions[] = [$answerValidator->isRegExp($field) ? trim($field, '/') : $field]; } else { $number = $this->isNumeric($field) ? $this->convertToInt($field) : 0; if ($number > 0) { array_unshift($numbersAndOptions[count($numbersAndOptions) - 1], $number); } else { // 順番が0以下であれば選択肢自体を削除 array_pop($numbersAndOptions); } } break; } } switch ($answerType) { case 0: // 記述形式 $noRegExpAnswerExisted = false; foreach ($answerPattenAndBonuses['|'] as $infix) { // 接頭辞の付加 if (isset($answerPattenAndBonuses['['])) { foreach ($answerPattenAndBonuses['['] as $prefix) { $tmpInfix = $infix; $tmpInfix['body'] = $prefix . $tmpInfix['body']; if (isset($tmpInfix['regexp'])) { $tmpInfix['regexp'] = preg_quote($prefix, '/') . $tmpInfix['regexp']; } $answersAndBonusesWithPrefix[] = $tmpInfix; } } else { $answersAndBonusesWithPrefix[] = $infix; } // 接尾辞の付加 if (isset($answerPattenAndBonuses[']'])) { foreach ($answerPattenAndBonuses[']'] as $suffix) { foreach ($answersAndBonusesWithPrefix as $answer) { $answer['body'] .= $suffix; if (isset($answer['regexp'])) { $answer['regexp'] .= preg_quote($prefix, '/'); } $answersAndBonusesWithPrefixAndSuffix[] = $answer; } } } else { $answersAndBonusesWithPrefixAndSuffix = $answersAndBonusesWithPrefix; } unset($answersAndBonusesWithPrefix); foreach ($answersAndBonusesWithPrefixAndSuffix as &$answer) { if ($answerValidator->isRegExp($answer['body'])) { $answer['body'] = trim($answer['body'], '/'); if ($answer['body'] === '') { unset($answer['body']); } } if (isset($answer['regexp']) || isset($answer['body'])) { $fieldsAsMultiDimensionalArray['answer'][] = isset($answer['regexp']) ? "/{$answer['regexp']}/" : $answer['body']; if (isset($answer['regexp'])) { if (!isset($noRegExpAnswerAndBonus)) { $noRegExpAnswerAndBonus = ['answer' => $answer['body'], 'bonus' => isset($answer['bonus']) ? (string) $answer['bonus'] : '']; } } else { $noRegExpAnswerExisted = true; } $bonuses[] = isset($answer['bonus']) ? (string) $answer['bonus'] : ''; } } unset($answersAndBonusesWithPrefixAndSuffix); } if (!$noRegExpAnswerExisted) { // answerフィールドがすべて正規表現なら if (isset($noRegExpAnswerAndBonus)) { array_unshift($fieldsAsMultiDimensionalArray['answer'], $noRegExpAnswerAndBonus['answer']); array_unshift($bonuses, $noRegExpAnswerAndBonus['bonus']); } else { $this->logInconvertibleError($line); return; } } // specificsフィールドのbonusの設定 if (isset($bonuses)) { foreach (array_reverse($bonuses, true) as $i => $bonus) { if ($bonus) { $lastBonusPosition = $i; break; } } } if (isset($lastBonusPosition)) { foreach (array_slice($bonuses, 0, $lastBonusPosition + 1) as $bonus) { $specifics->append('bonus', $bonus); } } $fieldsAsMultiDimensionalArray['text'][] = $fieldsAsMultiDimensionalArray['answer'][0]; if (count($fieldsAsMultiDimensionalArray['answer']) === 1) { unset($fieldsAsMultiDimensionalArray['answer']); } break; case 1: // 選択形式 // 選択形式 case 3: // 全選択形式 if (empty($fieldsAsMultiDimensionalArray['answer'])) { throw new SyntaxException(_('\\seikai が設定されていません。')); } $fieldsAsMultiDimensionalArray['text'][] = count($fieldsAsMultiDimensionalArray['answer']) === 1 ? $fieldsAsMultiDimensionalArray['answer'][0] : '「' . implode('」' . ($answerType === 1 ? 'か' : 'と') . '「', $fieldsAsMultiDimensionalArray['answer']) . '」'; break; case 2: // 並べ替え形式 if (isset($numbersAndOptions)) { if (count(end($numbersAndOptions)) === 1) { // 順番が指定されていない選択肢を削除 array_pop($numbersAndOptions); } sort($numbersAndOptions); $fieldsAsMultiDimensionalArray['option'] = array_column($numbersAndOptions, 1); $fieldsAsMultiDimensionalArray['text'][] = implode(' → ', $fieldsAsMultiDimensionalArray['option']); } break; } $encoded = (string) $specifics; if ($encoded !== '') { $fieldsAsMultiDimensionalArray['specifics'][] = $encoded; } try { $dictioanry->addWord($fieldsAsMultiDimensionalArray); if ($answerType === 0) { // 記述形式 $words = $dictioanry->getWords(); $word = $words[count($words) - 1]; if (isset($word['answer'])) { foreach ($word['answer'] as $answer) { $this->wholeText .= $answerValidator->isRegExp($answer) ? preg_replace('#^/|\\.\\*|/$#u', '', $answer) : $answer; } } else { $this->wholeText .= $word['text'][0]; } } } catch (SyntaxException $e) { $this->logInconvertibleError($line, $e); } }
/** * CSVの一レコードを表す2つの配列によってお題を追加します。 * @param Dictionary $dictionary * @param string[] $fieldNames * @param string[] $fields * @param bool $first ヘッダ行を除く最初のレコードであれば真。 * @throws SyntaxException */ protected function addRecord(Dictionary $dictionary, array $fieldNames, array $fields, bool $first) { if (!in_array('text', $fieldNames)) { throw new SyntaxException(sprintf(_('ヘッダ行「%s」にフィールド名「text」が存在しません'), $this->convertToCSVRecord($fieldNames))); } if (count($fields) > count($fieldNames)) { throw new SyntaxException(sprintf(_('「%s」のフィールド数は、ヘッダ行のフィールド名の数を超えています。'), $this->convertToCSVRecord($fields))); } foreach ($fields as $i => $field) { if ($field !== '') { if ($fieldNames[$i][0] === '@') { if ($first) { $metaFields[$fieldNames[$i]] = $field; } else { $this->logger->error(sprintf(_('メタフィールド%sの内容は、最初のレコードにのみ記述可能です。'), $fieldNames[$i])); } } else { $fieldsAsMultiDimensionalArray[$fieldNames[$i]][] = $field; } } } if (!isset($fieldsAsMultiDimensionalArray['text'][0])) { throw new SyntaxException(sprintf(_('「%s」にはtextフィールドが存在しません。'), $this->convertToCSVRecord($fields))); } $dictionary->addWord($fieldsAsMultiDimensionalArray); if (isset($metaFields)) { $dictionary->setMetadata($metaFields); } }