Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 565
0.00% covered (danger)
0.00%
0 / 24
CRAP
0.00% covered (danger)
0.00%
0 / 1
Score
0.00% covered (danger)
0.00%
0 / 565
0.00% covered (danger)
0.00%
0 / 24
23256
0.00% covered (danger)
0.00%
0 / 1
 throwCallException
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 getLilypondVersion
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
12
 fetchLilypondVersion
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
12
 boxedCommand
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 createDirectory
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 getBaseUrl
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getBackend
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
20
 render
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 renderScore
0.00% covered (danger)
0.00%
0 / 92
0.00% covered (danger)
0.00%
0 / 1
756
 generateHTML
0.00% covered (danger)
0.00%
0 / 137
0.00% covered (danger)
0.00%
0 / 1
1560
 generatePngAndMidi
0.00% covered (danger)
0.00%
0 / 132
0.00% covered (danger)
0.00%
0 / 1
992
 addScript
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 throwCompileException
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
30
 throwSynthException
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 extractMessage
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
42
 imageSize
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getPaperCode
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 embedLilypondCode
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
12
 generateAudio
0.00% covered (danger)
0.00%
0 / 50
0.00% covered (danger)
0.00%
0 / 1
42
 getDurationFromScriptOutput
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 recordShellout
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 recordError
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 eraseDirectory
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 debug
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/*
3    Score, a MediaWiki extension for rendering musical scores with LilyPond.
4    Copyright © 2011 Alexander Klauer
5
6    This program is free software: you can redistribute it and/or modify
7    it under the terms of the GNU General Public License as published by
8    the Free Software Foundation, either version 3 of the License, or
9    (at your option) any later version.
10
11    This program is distributed in the hope that it will be useful,
12    but WITHOUT ANY WARRANTY; without even the implied warranty of
13    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14    GNU General Public License for more details.
15
16    You should have received a copy of the GNU General Public License
17    along with this program.  If not, see <http://www.gnu.org/licenses/>.
18
19    To contact the author:
20    <Graf.Zahl@gmx.net>
21    http://en.wikisource.org/wiki/User_talk:GrafZahl
22    https://github.com/TheCount/score
23
24 */
25
26namespace MediaWiki\Extension\Score;
27
28use Exception;
29use FileBackend;
30use FormatJson;
31use FSFileBackend;
32use MediaWiki\Html\Html;
33use MediaWiki\Logger\LoggerFactory;
34use MediaWiki\MediaWikiServices;
35use MediaWiki\Message\Message;
36use MediaWiki\Parser\Parser;
37use MediaWiki\Title\Title;
38use MediaWiki\WikiMap\WikiMap;
39use NullLockManager;
40use PPFrame;
41use Shellbox\Command\BoxedCommand;
42use Wikimedia\ScopedCallback;
43
44/**
45 * Score class.
46 */
47class Score {
48    /**
49     * Version for cache invalidation.
50     */
51    private const CACHE_VERSION = 1;
52
53    /**
54     * Cache expiry time for the LilyPond version
55     */
56    private const VERSION_TTL = 3600;
57
58    /**
59     * Supported score languages.
60     */
61    private const SUPPORTED_LANGS = [ 'lilypond', 'ABC' ];
62
63    /**
64     * Supported note languages.
65     * Key is LilyPond filename. Value is language code
66     */
67    public const SUPPORTED_NOTE_LANGUAGES = [
68        'arabic' => 'ar',
69        'catalan' => 'ca',
70        'deutsch' => 'de',
71        'english' => 'en',
72        'espanol' => 'es',
73        'italiano' => 'it',
74        'nederlands' => 'nl',
75        'norsk' => 'no',
76        'portugues' => 'pt',
77        'suomi' => 'fi',
78        'svenska' => 'sv',
79        'vlaams' => 'vls',
80    ];
81
82    /**
83     * Default language used for notes.
84     */
85    private const DEFAULT_NOTE_LANGUAGE = 'nederlands';
86
87    /**
88     * LilyPond version string.
89     * It defaults to null and is set the first time it is required.
90     * @var string|null
91     */
92    private static $lilypondVersion = null;
93
94    /**
95     * FileBackend instance cache
96     * @var FileBackend|null
97     */
98    private static $backend;
99
100    /**
101     * Throws proper ScoreException in case of failed shell executions.
102     *
103     * @param string $message Message key to display
104     * @param array $params Message parameters
105     * @param string $output collected output from stderr.
106     * @param string|bool $factoryDir The factory directory to replace with "..."
107     *
108     * @throws ScoreException always.
109     * @return never
110     */
111    private static function throwCallException( $message, array $params, $output, $factoryDir = false ) {
112        /* clean up the output a bit */
113        if ( $factoryDir ) {
114            $output = str_replace( $factoryDir, '...', $output );
115        }
116        $params[] = Html::rawElement( 'pre',
117            // Error messages from LilyPond & abc2ly are always English
118            [ 'lang' => 'en', 'dir' => 'ltr' ],
119            htmlspecialchars( $output )
120        );
121        throw new ScoreException( $message, $params );
122    }
123
124    /**
125     * @return string
126     * @throws ScoreException if LilyPond could not be executed properly.
127     */
128    public static function getLilypondVersion() {
129        global $wgScoreLilyPondFakeVersion;
130
131        if ( strlen( $wgScoreLilyPondFakeVersion ) ) {
132            return $wgScoreLilyPondFakeVersion;
133        }
134        if ( self::$lilypondVersion === null ) {
135            // In case fetchLilypondVersion() throws an exception
136            self::$lilypondVersion = 'disabled';
137
138            $cache = MediaWikiServices::getInstance()->getLocalServerObjectCache();
139            self::$lilypondVersion = $cache->getWithSetCallback(
140                $cache->makeGlobalKey( __CLASS__, 'lilypond-version' ),
141                self::VERSION_TTL,
142                function () {
143                    return self::fetchLilypondVersion();
144                }
145            );
146        }
147
148        return self::$lilypondVersion;
149    }
150
151    /**
152     * Determines the version of LilyPond in use without caching
153     *
154     * @throws ScoreException if LilyPond could not be executed properly.
155     * @return string
156     */
157    private static function fetchLilypondVersion() {
158        global $wgScoreLilyPond, $wgScoreEnvironment;
159
160        $result = self::boxedCommand()
161            ->routeName( 'score-lilypond' )
162            ->params( $wgScoreLilyPond, '--version' )
163            ->environment( $wgScoreEnvironment )
164            ->includeStderr()
165            ->execute();
166        self::recordShellout( 'lilypond_version' );
167
168        $output = $result->getStdout();
169        if ( $result->getExitCode() != 0 ) {
170            self::throwCallException( 'score-versionerr', [], $output );
171        }
172
173        if ( !preg_match( '/^GNU LilyPond (\S+)/', $output, $m ) ) {
174            self::throwCallException( 'score-versionerr', [], $output );
175        }
176        return $m[1];
177    }
178
179    /**
180     * Return a BoxedCommand object, or throw an exception if shell execution is
181     * disabled.
182     *
183     * The check for $wgScoreDisableExec should be redundant with checks in the
184     * callers, since the callers generally need to avoid writing input files.
185     * We check twice to be safe.
186     *
187     * @return BoxedCommand
188     * @throws ScoreDisabledException
189     */
190    private static function boxedCommand() {
191        global $wgScoreDisableExec;
192
193        if ( $wgScoreDisableExec ) {
194            throw new ScoreDisabledException();
195        }
196
197        return MediaWikiServices::getInstance()->getShellCommandFactory()
198            ->createBoxed( 'score' )
199            ->disableNetwork()
200            ->firejailDefaultSeccomp();
201    }
202
203    /**
204     * Creates the specified local directory if it does not exist yet.
205     * Otherwise does nothing.
206     *
207     * @param string $path Local path to directory to be created.
208     * @param int|null $mode Chmod value of the new directory.
209     *
210     * @throws ScoreException if the directory does not exist and could not
211     *     be created.
212     */
213    private static function createDirectory( $path, $mode = null ) {
214        if ( !is_dir( $path ) ) {
215            $rc = wfMkdirParents( $path, $mode, __METHOD__ );
216            if ( !$rc ) {
217                throw new ScoreException( 'score-nooutput', [ $path ] );
218            }
219        }
220    }
221
222    /**
223     * @return bool|string
224     */
225    private static function getBaseUrl() {
226        global $wgScorePath, $wgUploadPath;
227        if ( $wgScorePath === false ) {
228            return "{$wgUploadPath}/lilypond";
229        }
230
231        return $wgScorePath;
232    }
233
234    /**
235     * @return FileBackend
236     */
237    public static function getBackend() {
238        global $wgScoreFileBackend;
239
240        if ( $wgScoreFileBackend ) {
241            return MediaWikiServices::getInstance()->getFileBackendGroup()
242                ->get( $wgScoreFileBackend );
243        }
244
245        if ( !self::$backend ) {
246            global $wgScoreDirectory, $wgUploadDirectory;
247            if ( $wgScoreDirectory === false ) {
248                $dir = "{$wgUploadDirectory}/lilypond";
249            } else {
250                $dir = $wgScoreDirectory;
251            }
252            self::$backend = new FSFileBackend( [
253                'name' => 'score-backend',
254                'wikiId' => WikiMap::getCurrentWikiId(),
255                'lockManager' => new NullLockManager( [] ),
256                'containerPaths' => [ 'score-render' => $dir ],
257                'fileMode' => 0777,
258                'obResetFunc' => 'wfResetOutputBuffers',
259                'streamMimeFunc' => [ 'StreamFile', 'contentTypeFromPath' ],
260                'statusWrapper' => [ 'Status', 'wrap' ],
261                'logger' => LoggerFactory::getInstance( 'score' ),
262            ] );
263        }
264
265        return self::$backend;
266    }
267
268    /**
269     * Callback for Parser's hook on 'score' tags. Renders the score code.
270     *
271     * @param string $code Score code.
272     * @param array $args Array of score tag attributes.
273     * @param Parser $parser
274     * @param PPFrame $frame Expansion frame, not used by this extension.
275     *
276     * @throws ScoreException
277     * @return string Image link HTML, and possibly anchor to MIDI file.
278     */
279    public static function render( $code, array $args, Parser $parser, PPFrame $frame ) {
280        return self::renderScore( $code, $args, $parser );
281    }
282
283    /**
284     * Renders the score code (LilyPond, ABC, etc.) in a <score>…</score> tag.
285     *
286     * @param string $code Score code.
287     * @param array $args Array of score tag attributes.
288     * @param Parser|null $parser Parser must be set when called during a wiki page parse.
289     *
290     * @throws ScoreException
291     * @return string Image link HTML, and possibly anchor to MIDI file.
292     */
293    public static function renderScore( $code, array $args, ?Parser $parser = null ) {
294        global $wgTmpDirectory;
295
296        try {
297            $baseUrl = self::getBaseUrl();
298            $baseStoragePath = self::getBackend()->getRootStoragePath() . '/score-render';
299
300            // options to self::generateHTML()
301            $options = [];
302
303            if ( isset( $args['line_width_inches'] ) ) {
304                $lineWidthInches = abs( (float)$args[ 'line_width_inches' ] );
305                if ( $lineWidthInches > 0 ) {
306                    $options['line_width_inches'] = $lineWidthInches;
307                }
308            }
309
310            /* temporary working directory to use */
311            $fuzz = md5( (string)mt_rand() );
312            $options['factory_directory'] = $wgTmpDirectory . "/MWLP.$fuzz";
313
314            /* Score language selection */
315            if ( array_key_exists( 'lang', $args ) ) {
316                $options['lang'] = $args['lang'];
317            } else {
318                $options['lang'] = 'lilypond';
319            }
320            if ( !in_array( $options['lang'], self::SUPPORTED_LANGS, true ) ) {
321                throw new ScoreException( 'score-invalidlang',
322                    [ htmlspecialchars( $options['lang'] ) ] );
323            }
324
325            /* Override MIDI file? */
326            if ( array_key_exists( 'override_midi', $args ) ) {
327                $file = MediaWikiServices::getInstance()->getRepoGroup()
328                    ->findFile( $args['override_midi'] );
329                if ( $file === false ) {
330                    throw new ScoreException( 'score-midioverridenotfound',
331                        [ htmlspecialchars( $args['override_midi'] ) ] );
332                }
333                if ( $parser && $parser->getOutput() !== null ) {
334                    $parser->getOutput()->addImage( $file->getName() );
335                }
336
337                $options['override_midi'] = true;
338                $options['midi_file'] = $file;
339                /* Set output stuff in case audio rendering is requested */
340                $sha1 = $file->getSha1();
341                $audioRelDir = "override-midi/{$sha1[0]}/{$sha1[1]}";
342                $audioRel = "$audioRelDir/$sha1.mp3";
343                $options['audio_storage_dir'] = "$baseStoragePath/$audioRelDir";
344                $options['audio_storage_path'] = "$baseStoragePath/$audioRel";
345                $options['audio_url'] = "$baseUrl/$audioRel";
346                $options['audio_sha_name'] = "$sha1.mp3";
347                if ( $parser ) {
348                    $parser->addTrackingCategory( 'score-deprecated-category' );
349                }
350            } else {
351                $options['override_midi'] = false;
352            }
353
354            // Raw rendering?
355            $options['raw'] = array_key_exists( 'raw', $args );
356
357            /* Note language selection */
358            if ( array_key_exists( 'note-language', $args ) ) {
359                if ( !$options['raw'] ) {
360                    $options['note-language'] = $args['note-language'];
361                } else {
362                    throw new ScoreException( 'score-notelanguagewithraw' );
363                }
364            } else {
365                $options['note-language'] = self::DEFAULT_NOTE_LANGUAGE;
366            }
367            if ( !isset( self::SUPPORTED_NOTE_LANGUAGES[$options['note-language']] ) ) {
368                throw new ScoreException(
369                    'score-invalidnotelanguage', [
370                        Message::plaintextParam( $options['note-language'] ),
371                        Message::plaintextParam( implode( ', ', array_keys( self::SUPPORTED_NOTE_LANGUAGES ) ) )
372                    ]
373                );
374            }
375
376            /* Override audio file? */
377            if ( array_key_exists( 'override_audio', $args )
378                || array_key_exists( 'override_ogg', $args ) ) {
379                $overrideAudio = $args['override_ogg'] ?? $args['override_audio'];
380                $t = Title::newFromText( $overrideAudio, NS_FILE );
381                if ( $t === null ) {
382                    throw new ScoreException( 'score-invalidaudiooverride',
383                        [ htmlspecialchars( $overrideAudio ) ] );
384                }
385                if ( !$t->isKnown() ) {
386                    throw new ScoreException( 'score-audiooverridenotfound',
387                        [ htmlspecialchars( $overrideAudio ) ] );
388                }
389                $options['override_audio'] = true;
390                $options['audio_name'] = $overrideAudio;
391                if ( $parser ) {
392                    $parser->addTrackingCategory( 'score-deprecated-category' );
393                }
394            } else {
395                $options['override_audio'] = false;
396            }
397
398            /* Audio rendering? */
399            $options['generate_audio'] = array_key_exists( 'sound', $args )
400                || array_key_exists( 'vorbis', $args );
401
402            if ( $options['generate_audio'] && $options['override_audio'] ) {
403                throw new ScoreException( 'score-convertoverrideaudio' );
404            }
405
406            // Input for cache key
407            $cacheOptions = [
408                'code' => $code,
409                'lang' => $options['lang'],
410                'note-language' => $options['note-language'],
411                'raw' => $options['raw'],
412                'ExtVersion' => self::CACHE_VERSION,
413                'LyVersion' => self::getLilypondVersion(),
414            ];
415
416            /* image file path and URL prefixes */
417            $imageCacheName = \Wikimedia\base_convert( sha1( serialize( $cacheOptions ) ), 16, 36, 31 );
418            $imagePrefixEnd = "{$imageCacheName[0]}/" .
419                "{$imageCacheName[1]}/$imageCacheName";
420            $options['dest_storage_path'] = "$baseStoragePath/$imagePrefixEnd";
421            $options['dest_url'] = "$baseUrl/$imagePrefixEnd";
422            $options['file_name_prefix'] = substr( $imageCacheName, 0, 8 );
423
424            $html = self::generateHTML( $parser, $code, $options );
425        } catch ( ScoreException $e ) {
426            if ( $parser && $parser->getOutput() !== null ) {
427                if ( $e->isTracked() ) {
428                    $parser->addTrackingCategory( 'score-error-category' );
429                }
430                self::recordError( $e );
431            }
432            $html = $e->getHtml();
433        }
434
435        // Mark the page as using the score extension, it makes easier
436        // to track all those pages.
437        if ( $parser && $parser->getOutput() !== null ) {
438            $parser->addTrackingCategory( 'score-use-category' );
439        }
440
441        return $html;
442    }
443
444    /**
445     * Generates the HTML code for a score tag.
446     *
447     * @param Parser|null $parser MediaWiki parser, provide when inside parse of wiki page
448     * @param string $code Score code.
449     * @param array $options array of rendering options.
450     *     The options keys are:
451     *     - factory_directory: string Path to directory in which files
452     *         may be generated without stepping on someone else's
453     *         toes. The directory may not exist yet. Required.
454     *     - generate_audio: bool Whether to create an audio file in
455     *         TimedMediaHandler. If set to true, the override_audio option
456     *         must be set to false. Required.
457     *  - dest_storage_path: The path of the destination directory relative to
458     *      the current backend. Required.
459     *  - dest_url: The default destination URL. Required.
460     *  - file_name_prefix: The filename prefix used for all files
461     *      in the default destination directory. Required.
462     *     - lang: string Score language. Required.
463     *     - override_midi: bool Whether to use a user-provided MIDI file.
464     *         Required.
465     *     - midi_file: If override_midi is true, MIDI file object.
466     *     - audio_storage_dir: If override_midi and generate_audio are true, the
467     *         backend directory in which the audio file is to be stored.
468     *     - audio_storage_path: string If override_midi and generate_audio are true,
469     *         the backend path at which the generated audio file is to be
470     *         stored.
471     *     - audio_url: string If override_midi and generate_audio is true,
472     *         the URL corresponding to audio_storage_path
473     *  - audio_sha_name: string If override_midi, generated audio file name.
474     *     - override_audio: bool Whether to generate a wikilink to a
475     *         user-provided audio file. If set to true, the sound
476     *         option must be set to false. Required.
477     *     - audio_name: string If override_audio is true, the audio file name
478     *     - raw: bool Whether to assume raw LilyPond code. Ignored if the
479     *         language is not lilypond, required otherwise.
480     *    - note-language: language to use for notes (one of supported by LilyPond)
481     *
482     * @return string HTML.
483     *
484     * @throws Exception
485     * @throws ScoreException if an error occurs.
486     */
487    private static function generateHTML( ?Parser $parser, $code, $options ) {
488        global $wgScoreOfferSourceDownload, $wgScoreUseSvg;
489
490        $cleanup = new ScopedCallback( function () use ( $options ) {
491            self::eraseDirectory( $options['factory_directory'] );
492        } );
493        if ( $parser && $parser->getOutput() !== null ) {
494            $parser->getOutput()->addModuleStyles( [ 'ext.score.styles' ] );
495            $parser->getOutput()->addModules( [ 'ext.score.popup' ] );
496        }
497
498        $backend = self::getBackend();
499        $fileIter = $backend->getFileList(
500            [ 'dir' => $options['dest_storage_path'], 'topOnly' => true ] );
501        if ( $fileIter === null ) {
502            throw new ScoreException( 'score-file-list-error' );
503        }
504        $existingFiles = [];
505        foreach ( $fileIter as $file ) {
506            $existingFiles[$file] = true;
507        }
508
509        /* Generate SVG, PNG and MIDI files if necessary */
510        $imageFileName = "{$options['file_name_prefix']}.png";
511        $imageSvgFileName = "{$options['file_name_prefix']}.svg";
512        $multi1FileName = "{$options['file_name_prefix']}-page1.png";
513        $multi1SvgFileName = "{$options['file_name_prefix']}-1.svg";
514        $midiFileName = "{$options['file_name_prefix']}.midi";
515        $metaDataFileName = "{$options['file_name_prefix']}.json";
516        $audioUrl = '';
517
518        if ( isset( $existingFiles[$metaDataFileName] ) ) {
519            $metaDataFile = $backend->getFileContents(
520                [ 'src' => "{$options['dest_storage_path']}/$metaDataFileName" ] );
521            if ( $metaDataFile === false ) {
522                throw new ScoreException( 'score-nocontent', [ $metaDataFileName ] );
523            }
524            $metaData = FormatJson::decode( $metaDataFile, true );
525        } else {
526            $metaData = [];
527        }
528
529        if (
530            !isset( $existingFiles[$metaDataFileName] )
531            || (
532                !isset( $existingFiles[$imageFileName] )
533                && !isset( $existingFiles[$multi1FileName] )
534            )
535            || (
536                $wgScoreUseSvg
537                && !isset( $existingFiles[$multi1SvgFileName] )
538                && !isset( $existingFiles[$imageSvgFileName] )
539            )
540            || (
541                !isset( $metaData[$imageFileName]['size'] )
542                && !isset( $metaData[$multi1FileName]['size'] )
543            )
544            || !isset( $existingFiles[$midiFileName] ) ) {
545            $existingFiles += self::generatePngAndMidi( $code, $options, $metaData );
546        }
547
548        /* Generate audio file if necessary */
549        if ( $options['generate_audio'] ) {
550            $audioFileName = "{$options['file_name_prefix']}.mp3";
551            if ( $options['override_midi'] ) {
552                $audioUrl = $options['audio_url'];
553                $audioPath = $options['audio_storage_path'];
554                $exists = $backend->fileExists( [ 'src' => $options['audio_storage_path'] ] );
555                if (
556                    !$exists ||
557                    !isset( $metaData[ $options['audio_sha_name'] ]['length'] ) ||
558                    !$metaData[ $options['audio_sha_name'] ]['length']
559                ) {
560                    $backend->prepare( [ 'dir' => $options['audio_storage_dir'] ] );
561                    $sourcePath = $options['midi_file']->getLocalRefPath();
562                    self::generateAudio( $sourcePath, $options, $audioPath, $metaData );
563                }
564            } else {
565                $audioUrl = "{$options['dest_url']}/$audioFileName";
566                $audioPath = "{$options['dest_storage_path']}/$audioFileName";
567                if (
568                    !isset( $existingFiles[$audioFileName] ) ||
569                    !isset( $metaData[$audioFileName]['length'] ) ||
570                    !$metaData[$audioFileName]['length']
571                ) {
572                    // Maybe we just generated it
573                    $sourcePath = "{$options['factory_directory']}/file.midi";
574                    if ( !file_exists( $sourcePath ) ) {
575                        // No, need to fetch it from the backend
576                        $sourceFileRef = $backend->getLocalReference(
577                            [ 'src' => "{$options['dest_storage_path']}/$midiFileName" ] );
578                        $sourcePath = $sourceFileRef->getPath();
579                    }
580                    self::generateAudio( $sourcePath, $options, $audioPath, $metaData );
581                }
582            }
583        }
584
585        /* return output link(s) */
586        if ( isset( $existingFiles[$imageFileName] ) ) {
587            [ $width, $height ] = $metaData[$imageFileName]['size'];
588            $attribs = [
589                'src' => "{$options['dest_url']}/$imageFileName",
590                'width' => $width,
591                'height' => $height,
592                'alt' => $code,
593            ];
594            if ( $wgScoreUseSvg ) {
595                $attribs['srcset'] = "{$options['dest_url']}/$imageSvgFileName 1x";
596            }
597            $link = Html::rawElement( 'img', $attribs );
598        } elseif ( isset( $existingFiles[$multi1FileName] ) ) {
599            $link = '';
600            for ( $i = 1; ; ++$i ) {
601                $fileName = "{$options['file_name_prefix']}-page$i.png";
602                if ( !isset( $existingFiles[$fileName] ) ) {
603                    break;
604                }
605                $pageNumb = wfMessage( 'score-page' )
606                    ->inContentLanguage()
607                    ->numParams( $i )
608                    ->plain();
609                [ $width, $height ] = $metaData[$fileName]['size'];
610                $attribs = [
611                    'src' => "{$options['dest_url']}/$fileName",
612                    'width' => $width,
613                    'height' => $height,
614                    'alt' => $pageNumb,
615                    'title' => $pageNumb,
616                    'style' => "margin-bottom:1em"
617                ];
618                if ( $wgScoreUseSvg ) {
619                    $svgFileName = "{$options['file_name_prefix']}-$i.svg";
620                    $attribs['srcset'] = "{$options['dest_url']}/$svgFileName 1x";
621                }
622                $link .= Html::rawElement( 'img', $attribs );
623            }
624        } else {
625            $link = '';
626        }
627        if ( $options['generate_audio'] ) {
628            $link .= '<div style="margin-top: 3px;">' .
629                Html::rawElement(
630                    'audio',
631                    [
632                        'controls' => true
633                    ],
634                    Html::openElement(
635                        'source',
636                        [
637                            'src' => $audioUrl,
638                            'type' => 'audio/mpeg',
639                        ]
640                    ) .
641                    "<div>" .
642                    wfMessage( 'score-audio-alt' )
643                        ->rawParams(
644                            Html::element( 'a', [ 'href' => $audioUrl ],
645                                wfMessage( 'score-audio-alt-link' )->text()
646                            )
647                        )
648                        ->escaped() .
649                    '</div>'
650                ) .
651                '</div>';
652        }
653        if ( $parser && $options['override_audio'] !== false ) {
654            $link .= $parser->recursiveTagParse( "[[File:{$options['audio_name']}]]" );
655        }
656
657        // Clean up the factory directory now
658        ScopedCallback::consume( $cleanup );
659
660        $attributes = [
661            'class' => 'mw-ext-score noresize'
662        ];
663
664        if ( $options['override_midi']
665            || isset( $existingFiles["{$options['file_name_prefix']}.midi"] ) ) {
666            $attributes['data-midi'] = $options['override_midi'] ?
667                $options['midi_file']->getUrl()
668                : "{$options['dest_url']}/{$options['file_name_prefix']}.midi";
669        }
670
671        if ( $wgScoreOfferSourceDownload
672            && isset( $existingFiles["{$options['file_name_prefix']}.ly"] )
673        ) {
674            $attributes['data-source'] = "{$options['dest_url']}/{$options['file_name_prefix']}.ly";
675        }
676
677        // Wrap score in div container.
678        return Html::rawElement( 'div', $attributes, $link );
679    }
680
681    /**
682     * Generates score PNG file(s) and a MIDI file. Stores lilypond file.
683     *
684     * @param string $code Score code.
685     * @param array $options Rendering options. They are the same as for
686     *     Score::generateHTML().
687     * @param array &$metaData array to hold information about images
688     *
689     * @return array of file names placed in the remote dest dir, with the
690     *     file names in each key.
691     *
692     * @throws ScoreException on error.
693     */
694    private static function generatePngAndMidi( $code, $options, &$metaData ) {
695        global $wgScoreLilyPond, $wgScoreTrim, $wgScoreSafeMode, $wgScoreDisableExec,
696            $wgScoreGhostscript, $wgScoreAbc2Ly, $wgImageMagickConvertCommand, $wgScoreUseSvg,
697            $wgShellboxShell, $wgPhpCli, $wgScoreEnvironment, $wgScoreImageMagickConvert;
698
699        if ( $wgScoreDisableExec ) {
700            throw new ScoreDisabledException();
701        }
702
703        if ( $wgScoreSafeMode
704            && version_compare( self::getLilypondVersion(), '2.23.12', '>=' )
705        ) {
706            throw new ScoreException( 'score-safe-mode' );
707        }
708
709        /* Create the working environment */
710        $factoryDirectory = $options['factory_directory'];
711        self::createDirectory( $factoryDirectory, 0700 );
712        $factoryMidi = "$factoryDirectory/file.midi";
713
714        $command = self::boxedCommand()
715            ->routeName( 'score-lilypond' )
716            ->params(
717                $wgShellboxShell,
718                'scripts/generatePngAndMidi.sh' )
719            ->outputFileToFile( 'file.midi', $factoryMidi )
720            ->outputGlobToFile( 'file', 'png', $factoryDirectory )
721            ->outputGlobToFile( 'file', 'svg', $factoryDirectory )
722            ->includeStderr()
723            ->environment( [
724                'SCORE_ABC2LY' => $wgScoreAbc2Ly,
725                'SCORE_LILYPOND' => $wgScoreLilyPond,
726                'SCORE_USESVG' => $wgScoreUseSvg ? 'yes' : 'no',
727                'SCORE_SAFE' => $wgScoreSafeMode ? 'yes' : 'no',
728                'SCORE_GHOSTSCRIPT' => $wgScoreGhostscript,
729                'SCORE_CONVERT' => $wgScoreImageMagickConvert ?: $wgImageMagickConvertCommand,
730                'SCORE_TRIM' => $wgScoreTrim ? 'yes' : 'no',
731                'SCORE_PHP' => $wgPhpCli
732            ] + $wgScoreEnvironment );
733        self::addScript( $command, 'generatePngAndMidi.sh' );
734        if ( !$wgScoreUseSvg ) {
735            self::addScript( $command, 'extractPostScriptPageSize.php' );
736        }
737        if ( $options['lang'] === 'lilypond' ) {
738            if ( $options['raw'] ) {
739                $lilypondCode = $code;
740            } else {
741                $paperConfig = [];
742                if ( isset( $options['line_width_inches'] ) ) {
743                    $paperConfig['line-width'] = $options['line_width_inches'] . "\in";
744                }
745                $paperCode = self::getPaperCode( $paperConfig );
746
747                $lilypondCode = self::embedLilypondCode( $code, $options['note-language'], $paperCode );
748            }
749            $command->inputFileFromString( 'file.ly', $lilypondCode );
750        } else {
751            self::addScript( $command, 'removeTagline.php' );
752            $command->inputFileFromString( 'file.abc', $code );
753            $command->outputFileToString( 'file.ly' );
754            $lilypondCode = '';
755        }
756        $result = $command->execute();
757        self::recordShellout( 'generate_png_and_midi' );
758
759        if ( $result->getExitCode() != 0 ) {
760            self::throwCompileException( $result->getStdout(), $options );
761        }
762
763        if ( $result->wasReceived( 'file.ly' ) ) {
764            $lilypondCode = $result->getFileContents( 'file.ly' );
765        }
766
767        $numPages = 0;
768        for ( $i = 1; ; $i++ ) {
769            if ( !$result->wasReceived( "file-page$i.png" ) ) {
770                $numPages = $i - 1;
771                break;
772            }
773        }
774
775        # LilyPond 2.24+ generates file.png and file.svg if there is only one page
776        if ( $wgScoreUseSvg && $result->wasReceived( 'file.svg' ) ) {
777            $numPages = 1;
778        }
779
780        if ( $numPages === 0 ) {
781            throw new ScoreException( 'score-noimages' );
782        }
783
784        $needMidi = false;
785        $haveMidi = $result->wasReceived( 'file.midi' );
786        if ( !$options['raw'] || ( $options['generate_audio'] && !$options['override_midi'] ) ) {
787            $needMidi = true;
788            if ( !$haveMidi ) {
789                throw new ScoreException( 'score-nomidi' );
790            }
791        }
792
793        // Create the destination directory if it doesn't exist
794        $backend = self::getBackend();
795        $status = $backend->prepare( [ 'dir' => $options['dest_storage_path'] ] );
796        if ( !$status->isOK() ) {
797            throw new ScoreBackendException( $status );
798        }
799
800        // File names of generated files
801        $newFiles = [];
802        // Backend operation batch
803        $ops = [];
804
805        // Add LY source to its file
806        $ops[] = [
807            'op' => 'create',
808            'content' => $lilypondCode,
809            'dst' => "{$options['dest_storage_path']}/{$options['file_name_prefix']}.ly",
810            'headers' => [
811                'Content-Type' => 'text/x-lilypond; charset=utf-8'
812            ]
813        ];
814        $newFiles["{$options['file_name_prefix']}.ly"] = true;
815
816        if ( $needMidi ) {
817            // Add the MIDI file to the batch
818            $ops[] = [
819                'op' => 'store',
820                'src' => $factoryMidi,
821                'dst' => "{$options['dest_storage_path']}/{$options['file_name_prefix']}.midi" ];
822            $newFiles["{$options['file_name_prefix']}.midi"] = true;
823            if ( !$status->isOK() ) {
824                throw new ScoreBackendException( $status );
825            }
826        }
827
828        // Add the PNG and SVG image files
829        for ( $i = 1; $i <= $numPages; ++$i ) {
830            $srcPng = "$factoryDirectory/file-page$i.png";
831            $srcSvg = "$factoryDirectory/file-$i.svg";
832            $dstPngFileName = "{$options['file_name_prefix']}-page$i.png";
833            $dstSvgFileName = "{$options['file_name_prefix']}-$i.svg";
834            if ( $numPages === 1 ) {
835                $dstPngFileName = "{$options['file_name_prefix']}.png";
836                if ( $wgScoreUseSvg ) {
837                    $srcPng = "$factoryDirectory/file.png";
838                    $srcSvg = "$factoryDirectory/file.svg";
839                    $dstSvgFileName = "{$options['file_name_prefix']}.svg";
840                }
841            }
842            $destPng = "{$options['dest_storage_path']}/$dstPngFileName";
843            $ops[] = [
844                'op' => 'store',
845                'src' => $srcPng,
846                'dst' => $destPng
847            ];
848            [ $width, $height ] = self::imageSize( $srcPng );
849            $metaData[$dstPngFileName]['size'] = [ $width, $height ];
850            $newFiles[$dstPngFileName] = true;
851
852            if ( $wgScoreUseSvg ) {
853                $destSvg = "{$options['dest_storage_path']}/$dstSvgFileName";
854                $ops[] = [
855                    'op' => 'store',
856                    'src' => $srcSvg,
857                    'dst' => $destSvg,
858                    'headers' => [
859                        'Content-Type' => 'image/svg+xml'
860                    ]
861                ];
862                $newFiles[$dstSvgFileName] = true;
863            }
864        }
865
866        $dstFileName = "{$options['file_name_prefix']}.json";
867        $dest = "{$options['dest_storage_path']}/$dstFileName";
868        $ops[] = [
869            'op' => 'create',
870            'content' => FormatJson::encode( $metaData ),
871            'dst' => $dest ];
872
873        $newFiles[$dstFileName] = true;
874
875        // Execute the batch
876        $status = $backend->doQuickOperations( $ops );
877        if ( !$status->isOK() ) {
878            throw new ScoreBackendException( $status );
879        }
880        return $newFiles;
881    }
882
883    /**
884     * Add an input file from the scripts directory
885     *
886     * @param BoxedCommand $command
887     * @param string $script
888     */
889    private static function addScript( BoxedCommand $command, string $script ) {
890        $command->inputFileFromFile( "scripts/$script",
891            __DIR__ . "/../scripts/$script" );
892    }
893
894    /**
895     * Get error information from the output returned by scripts/generatePngAndMidi.sh
896     * and throw a relevant error.
897     *
898     * @param string $stdout
899     * @param array $options
900     * @throws ScoreException
901     */
902    private static function throwCompileException( $stdout, $options ) {
903        global $wgScoreDebugOutput;
904
905        $message = self::extractMessage( $stdout );
906        if ( !$message ) {
907            $message = [ 'score-compilererr', [] ];
908        } elseif ( !$wgScoreDebugOutput && $message[0] === 'score-compilererr' ) {
909            // when input is not raw, we build the final lilypond file content
910            // in self::embedLilypondCode. The user input then is not inserted
911            // on the first line in the file we pass to lilypond and so we need
912            // to offset error messages back.
913            $scoreFirstLineOffset = $options['raw'] ? 0 : 7;
914            $errMsgBeautifier = new LilypondErrorMessageBeautifier( $scoreFirstLineOffset );
915
916            $stdout = $errMsgBeautifier->beautifyMessage( $stdout );
917        }
918        self::throwCallException(
919            $message[0],
920            $message[1],
921            $stdout
922        );
923    }
924
925    /**
926     * Get error information from the output returned by scripts/synth.sh
927     * and throw a relevant error.
928     *
929     * @param string $stdout
930     * @throws ScoreException
931     */
932    private static function throwSynthException( $stdout ) {
933        $message = self::extractMessage( $stdout );
934        if ( !$message ) {
935            $message = [ 'score-audioconversionerr', [] ];
936        }
937        self::throwCallException(
938            $message[0],
939            $message[1],
940            $stdout
941        );
942    }
943
944    /**
945     * Parse the script return value and extract any mw-msg lines. Modify the
946     * text to remove the lines. Return the first mw-msg line as a message
947     * key and parameters. If there was no mw-msg line, return null.
948     *
949     * @param string &$stdout
950     * @return array|null
951     */
952    private static function extractMessage( &$stdout ) {
953        $filteredStdout = '';
954        $messageParams = [];
955        foreach ( explode( "\n", $stdout ) as $line ) {
956            if ( preg_match( '/^mw-msg:\t/', $line ) ) {
957                if ( !$messageParams ) {
958                    $messageParams = array_slice( explode( "\t", $line ), 1 );
959                }
960            } else {
961                if ( $filteredStdout !== '' ) {
962                    $filteredStdout .= "\n";
963                }
964                $filteredStdout .= $line;
965            }
966        }
967        $stdout = $filteredStdout;
968        if ( $messageParams ) {
969            $messageName = array_shift( $messageParams );
970            // Used messages:
971            // - score-abc2lynotexecutable
972            // - score-abcconversionerr
973            // - score-notexecutable
974            // - score-compilererr
975            // - score-nops
976            // - score-scripterr
977            // - score-gs-error
978            // - score-trimerr
979            // - score-readerr
980            // - score-pregreplaceerr
981            // - score-audioconversionerr
982            // - score-soundfontnotexists
983            // - score-fallbacknotexecutable
984            // - score-lamenotexecutable
985            return [ $messageName, $messageParams ];
986        } else {
987            return null;
988        }
989    }
990
991    /**
992     * Extract the size of one of our generated PNG images
993     *
994     * @param string $filename
995     * @return array of ints (width, height)
996     */
997    private static function imageSize( $filename ) {
998        [ $width, $height ] = getimagesize( $filename );
999        return [ $width, $height ];
1000    }
1001
1002    /**
1003     * @param array $paperConfig
1004     * @return string
1005     */
1006    private static function getPaperCode( $paperConfig = [] ) {
1007        $config = array_merge( [
1008            "indent" => "0\\mm",
1009        ], $paperConfig );
1010
1011        $paperCode = "\\paper {\n";
1012        foreach ( $config as $key => $value ) {
1013            $paperCode .= "\t$key = $value\n";
1014        }
1015        $paperCode .= "}";
1016
1017        return $paperCode;
1018    }
1019
1020    /**
1021     * Embeds simple LilyPond code in a score block.
1022     *
1023     * @param string $lilypondCode Simple LilyPond code.
1024     * @param string $noteLanguage Language of notes.
1025     * @param string $paperCode
1026     *
1027     * @return string Raw lilypond code.
1028     *
1029     * @throws ScoreException if determining the LilyPond version fails.
1030     */
1031    private static function embedLilypondCode( $lilypondCode, $noteLanguage, $paperCode ) {
1032        $version = self::getLilypondVersion();
1033
1034        // Check if parameters have already been supplied (hybrid-raw mode)
1035        $options = "";
1036        if ( strpos( $lilypondCode, "\\layout" ) === false ) {
1037            $options .= "\\layout { }\n";
1038        }
1039        if ( strpos( $lilypondCode, "\\midi" ) === false ) {
1040            $options .= <<<LY
1041    \\midi {
1042        \\context { \Score tempoWholesPerMinute = #(ly:make-moment 100 4) }
1043    }
1044LY;
1045        }
1046
1047        /* Raw code. In Scheme, ##f is false and ##t is true. */
1048        /* Set the default MIDI tempo to 100, 60 is a bit too slow */
1049        $raw = <<<LILYPOND
1050\\header {
1051    tagline = ##f
1052}
1053\\version "$version"
1054\\language "$noteLanguage"
1055\\score {
1056
1057$lilypondCode
1058$options
1059
1060}
1061$paperCode
1062LILYPOND;
1063
1064        return $raw;
1065    }
1066
1067    /**
1068     * Generates an audio file from a MIDI file using fluidsynth with TiMidity as fallback.
1069     *
1070     * @param string $sourceFile The local filename of the MIDI file
1071     * @param array $options array of rendering options.
1072     * @param string $remoteDest The backend storage path to upload the audio file to
1073     * @param array &$metaData Array with metadata information
1074     *
1075     * @throws ScoreException if an error occurs.
1076     */
1077    private static function generateAudio( $sourceFile, $options, $remoteDest, &$metaData ) {
1078        global $wgScoreFluidsynth, $wgScoreSoundfont, $wgScoreLame, $wgScoreDisableExec,
1079            $wgScoreEnvironment, $wgShellboxShell, $wgPhpCli;
1080
1081        if ( $wgScoreDisableExec ) {
1082            throw new ScoreDisabledException();
1083        }
1084
1085        // Working environment
1086        $factoryDir = $options['factory_directory'];
1087        self::createDirectory( $factoryDir, 0700 );
1088        $factoryFile = "$factoryDir/file.mp3";
1089
1090        // Run FluidSynth and LAME
1091        $command = self::boxedCommand()
1092            ->routeName( 'score-fluidsynth' )
1093            ->params(
1094                $wgShellboxShell,
1095                'scripts/synth.sh'
1096            )
1097            ->environment( [
1098                'SCORE_FLUIDSYNTH' => $wgScoreFluidsynth,
1099                'SCORE_SOUNDFONT' => $wgScoreSoundfont,
1100                'SCORE_LAME' => $wgScoreLame,
1101                'SCORE_PHP' => $wgPhpCli
1102            ] + $wgScoreEnvironment )
1103            ->inputFileFromFile( 'file.midi', $sourceFile )
1104            ->outputFileToFile( 'file.mp3', $factoryFile )
1105            ->includeStderr()
1106            // 150 MB max. filesize (for large MIDIs)
1107            ->fileSizeLimit( 150 * 1024 * 1024 );
1108
1109        self::addScript( $command, 'synth.sh' );
1110        self::addScript( $command, 'getWavDuration.php' );
1111
1112        $result = $command->execute();
1113        self::recordShellout( 'generate_audio' );
1114
1115        if ( ( $result->getExitCode() != 0 ) || !$result->wasReceived( 'file.mp3' ) ) {
1116            self::throwSynthException( $result->getStdout() );
1117        }
1118
1119        // Move file to the final destination
1120        $backend = self::getBackend();
1121        $status = $backend->doQuickOperation( [
1122            'op' => 'store',
1123            'src' => $factoryFile,
1124            'dst' => $remoteDest
1125        ] );
1126
1127        if ( !$status->isOK() ) {
1128            throw new ScoreBackendException( $status );
1129        }
1130
1131        // Create metadata json
1132        $metaData[basename( $remoteDest )]['length'] = self::getDurationFromScriptOutput(
1133            $result->getStdout() );
1134        $dstFileName = "{$options['file_name_prefix']}.json";
1135        $dest = "{$options['dest_storage_path']}/$dstFileName";
1136
1137        // Store metadata in backend
1138        $backend = self::getBackend();
1139        $status = $backend->doQuickOperation( [
1140            'op' => 'create',
1141            'content' => FormatJson::encode( $metaData ),
1142            'dst' => $dest
1143        ] );
1144
1145        if ( !$status->isOK() ) {
1146            throw new ScoreBackendException( $status );
1147        }
1148    }
1149
1150    /**
1151     * Get the duration of the audio file from the script stdout
1152     *
1153     * @param string $stdout The script output
1154     * @return float duration in seconds
1155     */
1156    private static function getDurationFromScriptOutput( $stdout ) {
1157        if ( preg_match( '/^wavDuration: ([0-9.]+)$/m', $stdout, $m ) ) {
1158            return (float)$m[1];
1159        } else {
1160            return 0.0;
1161        }
1162    }
1163
1164    /**
1165     * Track how often we do each type of shellout in statsd
1166     *
1167     * @param string $type Type of shellout
1168     */
1169    private static function recordShellout( $type ) {
1170        $statsd = MediaWikiServices::getInstance()->getStatsdDataFactory();
1171        $statsd->increment( "score.$type" );
1172    }
1173
1174    /**
1175     * Track how often each error occurs in statsd
1176     *
1177     * @param ScoreException $ex
1178     */
1179    private static function recordError( ScoreException $ex ) {
1180        $key = $ex->getStatsdKey();
1181        if ( $key === false ) {
1182            return;
1183        }
1184        $statsd = MediaWikiServices::getInstance()->getStatsdDataFactory();
1185        $statsd->increment( "score_error.$key" );
1186    }
1187
1188    /**
1189     * Deletes a local directory with no subdirectories with all files in it.
1190     *
1191     * @param string $dir Local path to the directory that is to be deleted.
1192     *
1193     * @return bool true on success, false on error
1194     */
1195    private static function eraseDirectory( $dir ) {
1196        if ( file_exists( $dir ) ) {
1197            // @phan-suppress-next-line PhanPluginUseReturnValueInternalKnown
1198            array_map( 'unlink', glob( "$dir/*", GLOB_NOSORT ) );
1199            $rc = rmdir( $dir );
1200            if ( !$rc ) {
1201                self::debug( "Unable to remove directory $dir\n." );
1202            }
1203            return $rc;
1204        }
1205
1206        /* Nothing to do */
1207        return true;
1208    }
1209
1210    /**
1211     * Writes the specified message to the Score debug log.
1212     *
1213     * @param string $msg message to log.
1214     */
1215    private static function debug( $msg ) {
1216        wfDebugLog( 'Score', $msg );
1217    }
1218
1219}