PHPでWord文書の数式をインポートする方法

最近、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] = '';

        // ゼロ幅文字&#8203;
        $patterns[1] = '/&#8203;/';
        $replacements[1] = '';

        // ゼロ幅文字&#8203;
        $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(
          '&#x00A8;&#x0021;&#x0022;&#x0023;&#x0026;&#x0028;&#x0029;&#x002B;&#x002C;&#x002D;&#x002E;&#x002F;&#x003A;',
          '&#x003B;&#x003C;&#x003D;&#x003E;&#x003F;&#x0040;&#x005B;&#x005C;&#x005D;&#x005E;&#x005F;&#x0060;&#x007B;',
          '&#x007C;&#x007D;&#x007E;&#x00A1;&#x00A6;&#x00AC;&#x00AF;&#x00B0;&#x00B1;&#x00B2;&#x00B3;&#x00B4;&#x00B7;&#x00B9;&#x00BF;',
          '&#x00D7;&#x007E;&#x00F7;&#x02C7;&#x02D8;&#x02D9;&#x02DC;&#x02DD;&#x0300;&#x0301;&#x0302;&#x0303;&#x0304;&#x0305;&#x0306;&#x0307;&#x0308;&#x0309;',
          '&#x030A;&#x030B;&#x030C;&#x030D;&#x030E;&#x030F;&#x0310;&#x0311;&#x0312;&#x0313;&#x0314;&#x0315;',
          '&#x0316;&#x0317;&#x0318;&#x0319;&#x031A;&#x031B;&#x031C;&#x031D;&#x031E;&#x031F;&#x0320;&#x0321;',
          '&#x0322;&#x0323;&#x0324;&#x0325;&#x0326;&#x0327;&#x0328;&#x0329;&#x032A;&#x032B;&#x032C;&#x032D;',
          '&#x032E;&#x032F;&#x0330;&#x0331;&#x0332;&#x0333;&#x0334;&#x0335;&#x0336;&#x0337;&#x0338;&#x033F;',
          '&#x2000;&#x2001;&#x2002;&#x2003;&#x2004;&#x2005;&#x2006;&#x2009;&#x200A;&#x2010;&#x2012;&#x2013;',
          '&#x2014;&#x2016;&#x2020;&#x2021;&#x2022;&#x2024;&#x2025;&#x2026;&#x2032;&#x2033;&#x2034;&#x203C;',
          '&#x2040;&#x2044;&#x204E;&#x204F;&#x2050;&#x2057;&#x2061;&#x2062;&#x2063;&#x2070;&#x2074;&#x2075;',
          '&#x2076;&#x2077;&#x2078;&#x2079;&#x207A;&#x207B;&#x207C;&#x207D;&#x207E;&#x2080;&#x2081;&#x2082;',
          '&#x2083;&#x2084;&#x2085;&#x2086;&#x2087;&#x2088;&#x2089;&#x208A;&#x208B;&#x208C;&#x208D;&#x208E;',
          '&#x20D0;&#x20D1;&#x20D2;&#x20D3;&#x20D4;&#x20D5;&#x20D6;&#x20D7;&#x20D8;&#x20D9;&#x20DA;&#x20DB;',
          '&#x20DC;&#x20DD;&#x20DE;&#x20DF;&#x20E0;&#x20E1;&#x20E4;&#x20E5;&#x20E6;&#x20E7;&#x20E8;&#x20E9;',
          '&#x20EA;&#x2140;&#x2146;&#x2190;&#x2191;&#x2192;&#x2193;&#x2194;&#x2195;&#x2196;&#x2197;&#x2198;&#x2199;',
          '&#x219A;&#x219B;&#x219C;&#x219D;&#x219E;&

タグ: PHP Word XML MathML OMML

6月9日 00:22 投稿