1<?php
2
3// This file is part of Moodle - http://moodle.org/
4//
5// Moodle is free software: you can redistribute it and/or modify
6// it under the terms of the GNU General Public License as published by
7// the Free Software Foundation, either version 3 of the License, or
8// (at your option) any later version.
9//
10// Moodle is distributed in the hope that it will be useful,
11// but WITHOUT ANY WARRANTY; without even the implied warranty of
12// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13// GNU General Public License for more details.
14//
15// You should have received a copy of the GNU General Public License
16// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
17
18/**
19 * @package moodlecore
20 * @subpackage backup-xml
21 * @copyright 2010 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com}
22 * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23 */
24
25/**
26 * Class implementing one (more or less complete) UTF-8 XML writer
27 *
28 * General purpose class used to output UTF-8 XML contents easily. Can be customized
29 * using implementations of @xml_output (to define where to send the xml) and
30 * and @xml_contenttransformer (to perform any transformation in contents before
31 * outputting the XML).
32 *
33 * Has support for attributes, basic w3c xml schemas declaration,
34 * and performs some content cleaning to avoid potential incorret UTF-8
35 * mess and has complete exception support.
36 *
37 * TODO: Provide UTF-8 safe strtoupper() function if using casefolding and non-ascii tags/attrs names
38 * TODO: Finish phpdocs
39 */
40class xml_writer {
41
42    protected $output;     // @xml_output that defines how to output XML
43    protected $contenttransformer; // @xml_contenttransformer to modify contents before output
44
45    protected $prologue;   // Complete string prologue we want to use
46    protected $xmlschema;  // URI to nonamespaceschema to be added to main tag
47
48    protected $casefolding; // To define if xml tags must be uppercase (true) or not (false)
49
50    protected $level;      // current number of open tags, useful for indent text
51    protected $opentags;   // open tags accumulator, to check for errors
52    protected $lastwastext;// to know when we are writing after text content
53    protected $nullcontent;// to know if we are going to write one tag with null content
54
55    protected $running; // To know if writer is running
56
57    public function __construct($output, $contenttransformer = null, $casefolding = false) {
58        if (!$output instanceof xml_output) {
59            throw new xml_writer_exception('invalid_xml_output');
60        }
61        if (!is_null($contenttransformer) && !$contenttransformer instanceof xml_contenttransformer) {
62            throw new xml_writer_exception('invalid_xml_contenttransformer');
63        }
64
65        $this->output = $output;
66        $this->contenttransformer = $contenttransformer;
67
68        $this->prologue  = null;
69        $this->xmlschema = null;
70
71        $this->casefolding = $casefolding;
72
73        $this->level    = 0;
74        $this->opentags = array();
75        $this->lastwastext = false;
76        $this->nullcontent = false;
77
78        $this->running = null;
79    }
80
81    /**
82     * Initializes the XML writer, preparing it to accept instructions, also
83     * invoking the underlying @xml_output init method to be ready for operation
84     */
85    public function start() {
86        if ($this->running === true) {
87            throw new xml_writer_exception('xml_writer_already_started');
88        }
89        if ($this->running === false) {
90            throw new xml_writer_exception('xml_writer_already_stopped');
91        }
92        $this->output->start(); // Initialize whatever we need in output
93        if (!is_null($this->prologue)) { // Output prologue
94            $this->write($this->prologue);
95        } else {
96            $this->write($this->get_default_prologue());
97        }
98        $this->running = true;
99    }
100
101    /**
102     * Finishes the XML writer, not accepting instructions any more, also
103     * invoking the underlying @xml_output finish method to close/flush everything as needed
104     */
105    public function stop() {
106        if (is_null($this->running)) {
107            throw new xml_writer_exception('xml_writer_not_started');
108        }
109        if ($this->running === false) {
110            throw new xml_writer_exception('xml_writer_already_stopped');
111        }
112        if ($this->level > 0) { // Cannot stop if not at level 0, remaining open tags
113            throw new xml_writer_exception('xml_writer_open_tags_remaining');
114        }
115        $this->output->stop();
116        $this->running = false;
117    }
118
119    /**
120     * Set the URI location for the *nonamespace* schema to be used by the (whole) XML document
121     */
122    public function set_nonamespace_schema($uri) {
123        if ($this->running) {
124            throw new xml_writer_exception('xml_writer_already_started');
125        }
126        $this->xmlschema = $uri;
127    }
128
129    /**
130     * Define the complete prologue to be used, replacing the simple, default one
131     */
132    public function set_prologue($prologue) {
133        if ($this->running) {
134            throw new xml_writer_exception('xml_writer_already_started');
135        }
136        $this->prologue = $prologue;
137    }
138
139    /**
140     * Outputs one XML start tag with optional attributes (name => value array)
141     */
142    public function begin_tag($tag, $attributes = null) {
143        // TODO: chek the tag name is valid
144        $pre = $this->level ? "\n" . str_repeat(' ', $this->level * 2) : ''; // Indent
145        $tag = $this->casefolding ? strtoupper($tag) : $tag; // Follow casefolding
146        $end = $this->nullcontent ? ' /' : ''; // Tag without content, close it
147
148        // Build attributes output
149        $attrstring = '';
150        if (!empty($attributes) && is_array($attributes)) {
151            // TODO: check the attr name is valid
152            foreach ($attributes as $name => $value) {
153                $name = $this->casefolding ? strtoupper($name) : $name; // Follow casefolding
154                $attrstring .= ' ' . $name . '="'.
155                    $this->xml_safe_attr_content($value) . '"';
156            }
157        }
158
159        // Optional xml schema definition (level 0 only)
160        $schemastring = '';
161        if ($this->level == 0 && !empty($this->xmlschema)) {
162            $schemastring .= "\n    " . 'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"' .
163                             "\n    " . 'xsi:noNamespaceSchemaLocation="' . $this->xml_safe_attr_content($this->xmlschema) . '"';
164        }
165
166        // Send to xml_output
167        $this->write($pre . '<' . $tag . $attrstring . $schemastring . $end . '>');
168
169        // Acumulate the tag and inc level
170        if (!$this->nullcontent) {
171            array_push($this->opentags, $tag);
172            $this->level++;
173        }
174        $this->lastwastext = false;
175    }
176
177    /**
178     * Outputs one XML end tag
179     */
180    public function end_tag($tag) {
181        // TODO: check the tag name is valid
182
183        if ($this->level == 0) { // Nothing to end, already at level 0
184            throw new xml_writer_exception('xml_writer_end_tag_no_match');
185        }
186
187        $pre = $this->lastwastext ? '' : "\n" . str_repeat(' ', ($this->level - 1) * 2); // Indent
188        $tag = $this->casefolding ? strtoupper($tag) : $tag; // Follow casefolding
189
190        $lastopentag = array_pop($this->opentags);
191
192        if ($tag != $lastopentag) {
193            $a = new stdclass();
194            $a->lastopen = $lastopentag;
195            $a->tag = $tag;
196            throw new xml_writer_exception('xml_writer_end_tag_no_match', $a);
197        }
198
199        // Send to xml_output
200        $this->write($pre . '</' . $tag . '>');
201
202        $this->level--;
203        $this->lastwastext = false;
204    }
205
206
207    /**
208     * Outputs one tag completely (open, contents and close)
209     */
210    public function full_tag($tag, $content = null, $attributes = null) {
211        $content = $this->text_content($content); // First of all, apply transformations
212        $this->nullcontent = is_null($content) ? true : false; // Is it null content
213        $this->begin_tag($tag, $attributes);
214        if (!$this->nullcontent) {
215            $this->write($content);
216            $this->lastwastext = true;
217            $this->end_tag($tag);
218        }
219    }
220
221
222// Protected API starts here
223
224    /**
225     * Send some XML formatted chunk to output.
226     */
227    protected function write($output) {
228        $this->output->write($output);
229    }
230
231    /**
232     * Get default prologue contents for this writer if there isn't a custom one
233     */
234    protected function get_default_prologue() {
235        return "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n";
236    }
237
238    /**
239     * Clean attribute content and encode needed chars
240     * (&, <, >, ") - single quotes not needed in this class
241     * as far as we are enclosing with "
242     */
243    protected function xml_safe_attr_content($content) {
244        return htmlspecialchars($this->xml_safe_utf8($content), ENT_COMPAT);
245    }
246
247    /**
248     * Clean text content and encode needed chars
249     * (&, <, >)
250     */
251    protected function xml_safe_text_content($content) {
252        return htmlspecialchars($this->xml_safe_utf8($content), ENT_NOQUOTES);
253    }
254
255    /**
256     * Perform some UTF-8 cleaning, stripping the control chars (\x0-\x1f)
257     * but tabs (\x9), newlines (\xa) and returns (\xd). The delete control
258     * char (\x7f) is also included. All them are forbiden in XML 1.0 specs.
259     * The expression below seems to be UTF-8 safe too because it simply
260     * ignores the rest of characters. Also normalize linefeeds and return chars.
261     */
262    protected function xml_safe_utf8($content) {
263        $content = preg_replace('/[\x-\x8\xb-\xc\xe-\x1f\x7f]/is','', $content); // clean CTRL chars
264        $content = preg_replace("/\r\n|\r/", "\n", $content); // Normalize line&return=>line
265        return $content;
266    }
267
268    /**
269     * Returns text contents processed by the corresponding @xml_contenttransformer
270     */
271    protected function text_content($content) {
272        if (!is_null($this->contenttransformer)) { // Apply content transformation
273            $content = $this->contenttransformer->process($content);
274        }
275        return is_null($content) ? null : $this->xml_safe_text_content($content); // Safe UTF-8 and encode
276    }
277}
278
279/*
280 * Exception class used by all the @xml_writer stuff
281 */
282class xml_writer_exception extends moodle_exception {
283
284    public function __construct($errorcode, $a=NULL, $debuginfo=null) {
285        parent::__construct($errorcode, 'error', '', $a, $debuginfo);
286    }
287}
288