最近、Word文書から試験問題をインポートする機能を実装する必要がありました。しかし、文書内に複雑な数式が含まれているため、Word文書の読み込み時に以下のエラーが発生しました。
PhpOffice\Math\Reader\OfficeMathML::getElement : The tag `m:r` has no tag `m:t` defined
最初にDeepSeekに相談したところ、以下のような回答が得られました。
ご提示のエラーメッセージ PhpOffice\Math\Reader\OfficeMathML::getElement : The tag m:rhas no tagm:t defined は、PHPOfficeライブラリがOffice MathML形式の数式を解析する際に、ある<m:r>タグ内に必須の<m:t>タグが見つからないことを示しています。
提案された解決策に従って何度も修正を試みましたが、問題は解決しませんでした。最終的に、現在のPHPWord公式バージョン(phpoffice/phpword)が、数式(Office MathML/OMML)を含むWord文書(.docx)の読み込みサポートが限られていることが原因であることが判明しました。
その後、偶然にもWord文書が実質的にリネームされたZIPアーカイブであることに気づきました。ローカルの.docxファイルを.zipにリネームして解凍すると、以下のようなディレクトリ構造のファイルパッケージが得られます:
図1 メインフォルダー 図2 サブフォルダー
図2のサブフォルダー内のdocument.xmlファイルが、Word文書の数式を含む内容そのものです。実際に読み込む必要があるのはこのXMLファイルです。
そこで、Word文書を直接読み込むのではなく、document.xmlというXMLファイルを読み込むという迂回策を試してみることにしました。
意外なことに、これは非常に効果的でした。数式が正しく抽出されました。
具体的なコード構造は以下の通りです:
1)XmlParserService.php - XML試験問題ファイルを配列形式で出力するクラスファイル
<?php
namespace app\common\service;
use DOMDocument;
use DOMXPath;
use XSLTProcessor;
class XmlParserService
{
// 名前空間定義
const NS_W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
const NS_M = 'http://schemas.openxmlformats.org/officeDocument/2006/math';
// 数式解析
const OMML_NAMESPACE = 'http://schemas.openxmlformats.org/officeDocument/2006/math';
/**
* 試験問題XMLを解析
* @param string $xmlContent XMLコンテンツ
* @return array
*/
public function parse($xmlContent)
{
// キーフィックス:XMLにm名前空間定義を追加(存在しない場合)
$xmlContent = $this->addMissingNamespace($xmlContent);
$dom = new DOMDocument();
// エラー報告を無効化(オプション、解析時に警告が出力されないようにするため)
libxml_use_internal_errors(true);
$dom->loadXML($xmlContent);
libxml_clear_errors(); // エラーキャッシュをクリア
$xpath = new DOMXPath($dom);
$xpath->registerNamespace('w', self::NS_W);
$xpath->registerNamespace('m', self::NS_M); // m名前空間を登録
// すべての段落を取得
$paragraphs = $xpath->query('//w:body/w:p');
$textContent = '';
// すべてのセクションと要素を反復処理し、テキストコンテンツを連結
foreach ($paragraphs as $p) {
// 段落テキストを抽出(数式処理を含む)
$text = $this->getParagraphText($p, $xpath);
$textContent .= $text . "\n";
}
$data = $this->processQuestionContent($textContent);
return $data;
}
/**
* XMLに欠落している名前空間定義を追加
*/
private function addMissingNamespace($xmlContent)
{
$nsDeclaration = 'xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math"';
// ルートノードにm名前空間が含まれているかチェック
if (strpos($xmlContent, 'xmlns:m=') === false) {
// ルートノード(例:<w:document>)に名前空間を追加
$xmlContent = preg_replace(
'/<w:document([^>]*?)>/',
"<w:document$1 $nsDeclaration>",
$xmlContent,
1
);
}
return $xmlContent;
}
/**
* 段落テキストを抽出し、数式を処理
* @param DOMNode $p 段落ノード
* @param DOMXPath $xpath
* @return string
*/
private function getParagraphText($p, $xpath)
{
$text = '';
// 段落内のすべての子ノードを反復処理
foreach ($p->childNodes as $node) {
if ($node->namespaceURI === self::NS_W && $node->localName === 'r') {
// テキストノードを処理
$tNode = $xpath->query('.//w:t', $node)->item(0);
if ($tNode) {
$text .= $tNode->nodeValue;
}
} elseif ($node->namespaceURI === self::NS_M && $node->localName === 'oMath') {
$omml = $node->ownerDocument->saveXML($node);
// 方法1:数式ノードをLaTeXに変換
// $turnLatex = new OmmlToLatexConverter();
// $latex = $turnLatex->convert($omml);
// $text .= $latex;
// 方法2:直接保存 - 新しいXMLファイル読み込み方法
error_reporting(E_ALL);
ini_set('display_errors', 1);
// XSLTファイルが存在することを確認
if (!file_exists('OMML2MML.XSL')) {
die("エラー: OMML2MML.XSLファイルが見つかりません!");
}
if (!$omml) {
die("数式が見つかりません!");
}
// DOMDocumentを作成してXMLを読み込む
$dom = new DOMDocument();
// 必要なすべての名前空間宣言をルート要素に追加
$dom->loadXML('<?xml version="1.0"?>
<root xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math"
xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
xmlns:wpc="http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:o="urn:schemas-microsoft-com:office:office"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:wp14="http://schemas.microsoft.com/office/word/2010/wordprocessingDrawing"
xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"
xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml"
xmlns:w10="urn:schemas-microsoft-com:office:word"
xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml"
xmlns:wpg="http://schemas.microsoft.com/office/word/2010/wordprocessingGroup"
xmlns:wpi="http://schemas.microsoft.com/office/word/2010/wordprocessingInk"
xmlns:wne="http://schemas.microsoft.com/office/word/2006/wordml"
xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape">' .
$omml . '</root>');
// XSLTを読み込む
$xsl = new DOMDocument();
$xsl->load('OMML2MML.XSL');
// 変換を実行
$proc = new XSLTProcessor();
$proc->importStyleSheet($xsl);
$mathml = $proc->transformToXML($dom);
$mathml = mb_convert_encoding($mathml, 'UTF-8', 'UTF-16');
// 出力をクリーンアップ
$mathml = str_replace(['mml:','mm:'], '', $mathml);
$mathml = trim($mathml);
$text .= $mathml;
}
}
return trim($text);
}
/**
* 質問内容を処理
* @param string $text テキストコンテンツ
*/
private function processQuestionContent(&$text)
{
$paragraphs = self::splitByQuestionTypes($text);
// 3. テキストを試験配列に解析
// 5種類の質問タイプ
$examData = [];
foreach ($paragraphs as $lineArr) {//1、質問タイプで分類
$type = $lineArr['key'];//質問タイプ
$line = $lineArr['content'];
// echo $line;
// 方法2:グローバルマッチを実行(すべての一致結果を取得)
preg_match_all('/問題文:(.*?)(問題文:|$)/s', $line, $matches);
// キャプチャグループ1(問題文コンテンツ)を抽出し、空白をクリーンアップ
$questions = [];
if (!empty($matches[1])) {
foreach ($matches[1] as $stem) {
$cleanStem = trim($stem); // 先頭と末尾の空白と改行を削除
if (!empty($cleanStem)) {
$questions[] = $cleanStem;
}
}
}
$is_choice = false;//2、選択問題/穴埋め問題/記述問題 判定条件
if(in_array($type, ['単一選択', '複数選択'])) {
$is_choice = true;
}
//3、単一の質問の下のコンテンツをループで取得
foreach ($questions as $question) {
$childQuestion = $question;//$questionArr['content'];
$childQuestions = self::parseQuestionByMarkers($childQuestion, $is_choice);
$examData[] = array_merge(['examtype_id' => $type], $childQuestions);
}
}
return $examData;
}
/**
* テキスト処理:特定の文字を削除 + 質問タイプで分割して配列に
* @param string $text 元のテキスト
* @param array $specificChars 削除する特定の文字(オプション、デフォルトでは余分な空白文字を削除)
* @return array 分割後の質問タイプブロック配列
*/
public function splitByQuestionTypes($text, $pattern= '', $shift=true)
{
// ステップ1:特定の文字を削除(デフォルトでは余分な空白文字を処理し、他の文字をカスタマイズ可能)
// $cleanText = $this->removeSpecificChars($text, $specificChars);
// $cleanText = cleanWhitespace($text);
$cleanText = self::replaceToOneLine($text);
// ステップ2:テキストを【単一選択問題】【複数選択問題】【判断問題】で分割
// 正規表現の説明:
// 【(単一選択|複数選択|判断|穴埋め|記述)】 - 3種類の質問タイプマーカーを一致させる(キャプチャグループはマーカーテキストを保持)
// PREG_SPLIT_DELIM_CAPTURE - 分割文字(質問タイプマーカー)を保持
// PREG_SPLIT_NO_EMPTY - 空の要素を除外
if(!$pattern){
$pattern = '/【(単一選択|複数選択|判断|穴埋め|記述)】/';
}
$segments = preg_split(
$pattern,
$cleanText,
-1,
PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY
);
if($shift){
array_shift($segments);
}
// ステップ3:配列を再構成し、「質問タイプマーカー」と「コンテンツ」を対応させる(例:['【単一選択問題】', '問題内容...'])
$result = [];
$total = count($segments);
for ($i = 0; $i < $total; $i += 2) {
// 質問タイプマーカー(例:"単一選択問題")
$type = $segments[$i] ?? '';
// 対応するコンテンツ(次の要素)
$content = $segments[$i + 1] ?? '';
if (!empty($type) && !empty($content)) {
$result[] = [
'key' => $type, // 完全なマーカーを復元
'content' => trim($content), // コンテンツの先頭と末尾の空白を削除,
];
}
}
return $result;
}
public function parseQuestionByMarkers($content, $is_choice=true)
{
$currentQuestion = [//examtype_id
'exambank_question' => '',// exambank_question //content
'exambank_select' => [],//exambank_select //options
'exambank_answer' => '',//exambank_answer //answer
'exambank_describe' => '',//exambank_describe //analysis
'exambank_level' => ''//exambank_level //difficulty
];
$content = trim($content); // まず先頭と末尾の空白をクリーンアップ
if($is_choice){
//1. タイトルをマッチング:contentを抽出(A. の前の内容) 1、古い正規表現:'/^(.*?)A\. /s'
if (preg_match('/^(.*?)A\./s', $content, $contentMatches)) {
// A.の前の内容を抽出(改行を含む)
$currentQuestion['exambank_question'] = self::replaceToOneLine($contentMatches[1]);
}
// これは第一問の問題文01A.第一問の選択肢01B.第一問の選択肢02C. 第一問の選択肢03D.第一問の選択肢04答え:A
// 解説:これは第一問の解説難易度:非常に簡単
// これは第一問の問題文01A.第一問の選択肢01B.第一問の選択肢02C.第一問の選択肢03D.
// print_r($currentQuestion);
// 2. options部分を抽出(A. から「答え:」の間) 2、古い正規表現:'/A\.(.*?)答え:/s'
if (preg_match('/A\.(.*?)答え:/s', $content, $optionsMatches)) {
// $optionsStr = str_replace('答え:', '', $optionsMatches[0]);
// $optionsStr = self::replaceToOneLine($optionsStr);
$optionsStr = $optionsMatches[0];
// echo $optionsStr;exit;
$pattern = '/([A-G])\.(.*?)(?=([A-G])\.|答え:)/s';
$options = [];
// グローバルマッチを実行し、すべての一致結果を取得
if (preg_match_all($pattern, $optionsStr, $matches, PREG_SET_ORDER)) {
// print_r($matches);//exit;
foreach ($matches as $match) {
$key = $match[1]; // 選択肢の文字(A-G)
$value = trim($match[2]); // 選択肢のコンテンツ(先頭と末尾の空白を削除)
$options[$key] = $value;
}
}
$currentQuestion['exambank_select'] = $options;
}
}else{//選択問題以外のタイプ 問題のみで選択肢なし
//1. タイトルをマッチング:contentを抽出(「答え:」の前の内容)
if (preg_match('/^(.*?)答え:/s', $content, $contentMatches)) {
// A.の前の内容を抽出(改行を含む)
$currentQuestion['exambank_question'] = self::replaceToOneLine($contentMatches[1]);
}
}
// 3. answerを抽出(「答え:」から「解説:」の間) [解説|難易度]
if (preg_match('/答え:(.*?)(解説:|難易度:)/s', $content, $answerMatches)) {
$currentQuestion['exambank_answer'] = trim($answerMatches[1]);
}
if(!$currentQuestion['exambank_answer'] && !$answerMatches){//答えが存在しない場合、答えの後ろに解説も難易度もない場合、再読み込み
if (preg_match('/答え:(.*)$/', $content, $answerMatches2)) {
$currentQuestion['exambank_answer'] = trim($answerMatches2[1]);
}
}
// 4. analysisを抽出(「解説:」から「難易度:」の間)
if (preg_match('/解説:(.*?)(難易度:)/s', $content, $analysisMatches)) {
$currentQuestion['exambank_describe'] = trim($analysisMatches[1]);
}
//print_r($analysisMatches);
if(!$currentQuestion['exambank_describe'] && !$analysisMatches){//存在しない場合、最後にあるので最後まで取得
if (preg_match('/解説:(.*)$/', $content, $analysisMatches2)) {
$currentQuestion['exambank_describe'] = trim($analysisMatches2[1]);
}
}
// 5. difficultyを抽出(「難易度:」以降のコンテンツ)
if (preg_match('/難易度:(.*?)(答え:|解説:)/s', $content, $difficultyMatches)) {
$currentQuestion['exambank_level'] = trim($difficultyMatches[1]);
}
if(!$currentQuestion['exambank_level'] && !$difficultyMatches){//存在しない場合、最後にあるので最後まで取得
if (preg_match('/難易度:(.*)$/', $content, $difficultyMatches2)) {
$currentQuestion['exambank_level'] = trim($difficultyMatches2[1]);
}
}
return $currentQuestion;
}
// 余分な空白と改行文字を置換
public function replaceToOneLine($content){//\r\n|\r|\n
$mergedContent = preg_replace('/[\r\n]+/', '', $content);
// 不可視文字を削除
$patterns[0] = '/®/';
$replacements[0] = '';
// ゼロ幅文字​
$patterns[1] = '/​/';
$replacements[1] = '';
// ゼロ幅文字​
$patterns[2] = '#\\\u200b#us';
$replacements[2] = '';
$patterns[3] = '#\\\u200f#us';
$replacements[3] = '';
$patterns[4] = '#\\\u200e#us';
$replacements[4] = '';
$mergedContent = preg_replace($patterns, $replacements, $mergedContent);
// 先頭と末尾の空白を削除(オプション)
return trim($mergedContent);
}
}
OMML2MML.XSL(Word数式処理ソースファイル)
<?xml version="1.0" encoding="UTF-8" ?>
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform" xmlns:mml="http://www.w3.org/1998/Math/MathML"
xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math">
<xsl:output method="xml" encoding="UTF-16" />
<!-- %% グローバル定義 -->
<!-- OMMLによって演算子として認識されるすべてのユニコード文字 -->
<xsl:variable name="sOperators"
select="concat(
'¨!"#&()+,-./:',
';<=>?@[\]^_`{',
'|}~¡¦¬¯°±²³´·¹¿',
'×~÷ˇ˘˙˜˝̀́̂̃̄̅̆̇̈̉',
'̊̋̌̍̎̏̐̑̒̓̔̕',
'̡̛̖̗̘̙̜̝̞̟̠̚',
'̢̧̨̣̤̥̦̩̪̫̬̭',
'̴̵̶̷̸̮̯̰̱̲̳̿',
'         ‐‒–',
'—‖†‡•․‥…′″‴‼',
'⁀⁄⁎⁏⁐⁗⁡⁢⁣⁰⁴⁵',
'⁶⁷⁸⁹⁺⁻⁼⁽⁾₀₁₂',
'₃₄₅₆₇₈₉₊₋₌₍₎',
'⃒⃓⃘⃙⃚⃐⃑⃔⃕⃖⃗⃛',
'⃜⃝⃞⃟⃠⃡⃤⃥⃦⃨⃧⃩',
'⃪⅀ⅆ←↑→↓↔↕↖↗↘↙',
'↚↛↜↝↞&