1<?php
2/**
3 * DokuWiki Plugin ABC2 (Syntax Component)
4 *
5 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html
6 * @author  Anika Henke <anika@selfthinker.org>
7 */
8
9// must be run within Dokuwiki
10if (!defined('DOKU_INC')) {
11    die();
12}
13
14class syntax_plugin_abc2 extends DokuWiki_Syntax_Plugin
15{
16    /**
17     * @return string Syntax mode type
18     */
19    public function getType()
20    {
21        return 'protected';
22    }
23
24    /**
25     * @return string Paragraph type
26     */
27    public function getPType()
28    {
29        return 'block';
30    }
31
32    /**
33     * @return int Sort order - Low numbers go before high numbers
34     */
35    public function getSort()
36    {
37        return 190;
38    }
39
40    /**
41     * Connect lookup pattern to lexer.
42     *
43     * @param string $mode Parser mode
44     */
45    public function connectTo($mode)
46    {
47        $this->Lexer->addEntryPattern('<abc(?=.*\x3C/abc\x3E)',$mode,'plugin_abc2');
48    }
49
50    public function postConnect()
51    {
52        $this->Lexer->addExitPattern('</abc>','plugin_abc2');
53    }
54
55    /**
56     * Handle matches of the abc2 syntax
57     *
58     * @param string       $match   The match of the syntax
59     * @param int          $state   The state of the handler
60     * @param int          $pos     The position in the document
61     * @param Doku_Handler $handler The handler
62     *
63     * @return array Data for the renderer
64     */
65    public function handle($match, $state, $pos, Doku_Handler $handler)
66    {
67        if ( $state == DOKU_LEXER_UNMATCHED ) {
68            $matches = preg_split('/>/u',$match,2);
69            $matches[0] = trim($matches[0]);
70            return array($matches[1],$matches[0]);
71        }
72        return true;
73    }
74
75    /**
76     * Render xhtml output or metadata
77     *
78     * @param string        $mode     Renderer mode (supported modes: xhtml)
79     * @param Doku_Renderer $renderer The renderer
80     * @param array         $data     The data from the handler() function
81     *
82     * @return bool If rendering was successful.
83     */
84    public function render($mode, Doku_Renderer $renderer, $data)
85    {
86        if ($mode !== 'xhtml') {
87            return false;
88        }
89
90        if(strlen($data[0] ?? null) > 1){
91            $src = $data[0];
92            $transStr = $data[1];
93
94            // display just the code if 'abcok' is switched off
95            if (!$this->getConf('abcok')) {
96                $renderer->doc .= $renderer->file($src);
97                return true;
98            }
99
100            // render the main ABC block
101            $src = $this->_fixLibraryBugs($src);
102            $this->_renderAbcBlock($renderer, $src, true);
103
104            // transposition
105            // via adding `shift=xy` to key information field
106            // doesn't work with abcjs this way
107            if ($transStr && ($this->getConf('library') !== 'abcjs')) {
108                $transArray = $this->_transStringToArray($transStr);
109
110                foreach($transArray as &$trans) {
111                    $transShiftStr = $this->_transposeToShift($trans);
112                    $keyLine = $this->_getAbcLine($src, 'K');
113                    $titleLine = $this->_getAbcLine($src, 'T');
114
115                    // checking for already existing shift|score|sound not necessary
116                    // a first 'shift' parameter is ignored
117                    // 'score' or 'sound' will cause the score to be transposed further
118                    if ($keyLine && $titleLine) {
119                        $transSrc = $src;
120
121                        // add shift parameter into key information field
122                        $keyLineNew = $keyLine.' shift='.$transShiftStr;
123                        $transSrc = $this->_replace_first($transSrc, $keyLine, $keyLineNew);
124
125                        // add transposition semitone after title
126                        $titleLineNew = $titleLine.' ['.$trans.']';
127                        $transSrc = $this->_replace_first($transSrc, $titleLine, $titleLineNew);
128
129                        // render another ABC block per transposition
130                        $this->_renderAbcBlock($renderer, $transSrc, false);
131                    }
132                }
133            }
134        }
135        return true;
136
137    }
138
139    /**
140     * Get transposition parameters into reasonable array
141     *
142     * @param string   $str     ABC parameter, string of transposition numbers
143     *
144     * @return array   Array with transposition numbers
145     */
146    function _transStringToArray($str) {
147        $arr = explode(" ", $str);
148        // the semitones to transpose have to be integers
149        $arr = array_map("intval", $arr);
150        // do not transpose by the same amount of semitones more than once
151        $arr = array_unique($arr);
152        // do not transpose higher or lower than 12 semitones
153        $arr = array_filter($arr, function($t){ return($t<12 && $t >-12); });
154        // do not allow transposition into more than 8 keys
155        array_splice($arr, 8);
156        return $arr;
157    }
158
159    /**
160     * Turn transposition number into 'shift' voice modifier
161     *
162     * ABC 2.1 had 'transpose' which worked with semitones
163     * ABC 2.2 has 'shift' which works with an interval of two notes
164     *
165     * @param int      $num     transpose, number of semitones
166     *
167     * @return string  shift, string of two notes
168     */
169    function _transposeToShift($num) {
170        $arr = array(
171            0 => 'CC',
172            1 => 'Bc',
173            2 => 'CD',
174            3 => 'Bd',
175            4 => 'CE',
176            5 => 'CF',
177            6 => 'BF',
178            7 => 'CG',
179            8 => 'Bg',
180            9 => 'CA',
181            10 => 'Ba',
182            11 => 'CB',
183            12 => 'Cc',
184            -1 => 'cB',
185            -2 => 'DC',
186            -3 => 'dB',
187            -4 => 'EC',
188            -5 => 'FC',
189            -6 => 'FB',
190            -7 => 'GC',
191            -8 => 'gB',
192            -9 => 'AC',
193            -10 => 'aB',
194            -11 => 'BC',
195            -12 => 'cC',
196        );
197        return $arr[$num];
198    }
199
200    /**
201     * Calculate default unit length
202     * according to http://abcnotation.com/wiki/abc:standard:v2.1#lunit_note_length
203     *
204     * @param string   $meterLine     line of meter (M) information field
205     *
206     * @return string  string with default length
207     */
208    function _getDefaultLength($meterLine) {
209        $meter = preg_replace('/\s?M\s?:/', '', $meterLine);
210
211        // default to 1/8 if meter is empty or "none"
212        if (!$meter || $meter == 'none') return "1/8";
213
214        // replace meter symbols with standard meters
215        $meter = str_replace('C|', '2/4', $meterLine);
216        $meter = str_replace('C', '4/4', $meterLine);
217
218        // meter is usually in the form <number>/<number>
219        preg_match("/(\d)\/(\d)/", $meter, $matches);
220        // default to 1/8 if meter isn't in that form
221        if (count($matches) != 3) return "1/8";
222
223        // default unit length calculation
224        $ratio = (int) $matches[1] / (int) $matches [2];
225        if ($ratio < 0.75) {
226            $length = "1/16";
227        } else {
228            $length = "1/8";
229        }
230
231        return $length;
232    }
233
234    /**
235     * Build classes for abc container depending on chosen abc library
236     *
237     * @param bool      $orig     original source (not a transposition)
238     *
239     * @return array    CSS classes
240     */
241    function _getClasses($orig) {
242      switch($this->getConf('library')) {
243          case 'abcjs':
244              // makes the midi player bigger
245              $libClasses = 'abcjs-large';
246              break;
247
248          case 'abc2svg':
249              $libClasses = 'abc';
250              break;
251
252          case 'abc-ui':
253              // 'abc-source' is mandatory and needs to be first
254              $libClasses = 'abc-source '.$this->getConf('abcuiConfig');
255          break;
256      }
257
258      // generic class plus class identifying the chosen library
259      $containerClasses = ' abc2-plugin lib-'.$this->getConf('library');
260
261      if ($orig && $this->getConf('showSource')) {
262          $containerClasses .= ' show-source';
263      } else {
264          $containerClasses .= ' hide-source';
265      }
266
267      return array(
268          'lib-classes' => $libClasses,
269          'container-classes' => $containerClasses,
270      );
271    }
272
273    /**
274     * Fix ABC library bugs:
275     *
276     * * abc2svg doesn't render anything if there is a space after the X:
277     * * $ABC_UI messes with note lengths if L isn't set
278     *
279     * @param string   $src     ABC code source
280     *
281     * @return string  adjusted ABC code
282     */
283    function _fixLibraryBugs($src) {
284        // remove spaces after 'X:'
285        // fixes a bug in abc2svg which won't render anything with a space after X:
286        // fixed upstream, see https://chiselapp.com/user/moinejf/repository/abc2svg/tktview?name=25d793e76f
287        $xLine = $this->_getAbcLine($src, 'X');
288        $xLineNoSpaces = str_replace(' ', '', $xLine);
289        $src = $this->_replace_first($src, $xLine, $xLineNoSpaces);
290
291        // add L: line if there isn't one
292        // fixes bug in $ABC_UI which has a wrong default unit length
293        $lLine = $this->_getAbcLine($src, 'L');
294        if (!$lLine) {
295            $mLine = $this->_getAbcLine($src, 'M');
296
297            if ($mLine) {
298                $lValue = $this->_getDefaultLength($mLine);
299                $mLineAndLline = $mLine.NL.'L:'.$lValue;
300                $src = $this->_replace_first($src, $mLine, $mLineAndLline);
301            }
302        }
303        return $src;
304    }
305
306    /**
307     * Render block of ABC
308     *
309     * @param Doku_Renderer $renderer The renderer
310     * @param string        $src      ABC code source
311     * @param bool          $orig     original source (not a transposition)
312     *
313     * @return void
314     */
315    function _renderAbcBlock($renderer, $src, $orig) {
316        $classes = $this->_getClasses($orig);
317
318        // needs an extra parent div because abc2svg will otherwise break any broken rhythm
319        // see https://chiselapp.com/user/moinejf/repository/abc2svg/tktview/f632b51e4da81e3bd8292a30d078a5810488b878
320        // cannot be used for all libs because otherwise abc-ui will break
321        if ($this->getConf('library') == 'abc2svg') {
322            $renderer->doc .= '<div class="'.$classes['container-classes'].'">'.NL;
323            $renderer->doc .= '<div class="'.$classes['lib-classes'].'">';
324        } else {
325            // needs to be a div, otherwise abc-ui won't work
326            $renderer->doc .= '<div class="'.$classes['lib-classes'].$classes['container-classes'].'">';
327        }
328
329        if ($this->getConf('library') == 'abc-ui') {
330            $renderer->doc .= '%%player_top'.NL;
331        }
332        $renderer->doc .= hsc($src);
333
334        if ($this->getConf('library') == 'abc2svg') {
335            $renderer->doc .= '</div>';
336        }
337        $renderer->doc .= '</div>'.NL;
338    }
339
340    /**
341     * Get line of ABC with specific information field
342     *
343     * @param string   $src     ABC code source
344     * @param string   $field   ABC information field identifier
345     *
346     * @return string  information field, whole line
347     */
348    function _getAbcLine($src, $field) {
349        if (preg_match("/^\s?".$field."\s?:(.*?)$/m", $src, $result)) {
350            return $result[0];
351        } else {
352            return false;
353        }
354    }
355
356    /**
357     * Replace first string
358     *
359     * @author Zombat [https://stackoverflow.com/users/81205/zombat]
360     * @source https://stackoverflow.com/a/1252710/340300
361     * @license CC BY-SA 3.0 [https://creativecommons.org/licenses/by-sa/3.0/]
362     *
363     * @param string   $haystack
364     * @param string   $needle
365     * @param string   $replace
366     *
367     * @return string
368     */
369    function _replace_first($haystack, $needle, $replace) {
370        $pos = strpos($haystack, $needle);
371        if ($pos !== false) {
372            $newstring = substr_replace($haystack, $replace, $pos, strlen($needle));
373        }
374        return $newstring;
375    }
376
377}
378