├── COPYING ├── README.md ├── RELEASE-NOTES.md └── src ├── BNodeLabeler.php ├── JsonLdRdfWriter.php ├── N3Quoter.php ├── N3RdfWriterBase.php ├── NTriplesRdfWriter.php ├── RdfWriter.php ├── RdfWriterBase.php ├── RdfWriterFactory.php ├── TurtleRdfWriter.php ├── UnicodeEscaper.php └── XmlRdfWriter.php /COPYING: -------------------------------------------------------------------------------- 1 | The license text below "----" applies to all files within this distribution, other 2 | than those that are in a directory which contains files named "LICENSE" or 3 | "COPYING", or a subdirectory thereof. For those files, the license text contained in 4 | said file overrides any license information contained in directories of smaller depth. 5 | ---- 6 | 7 | GNU GENERAL PUBLIC LICENSE 8 | Version 2, June 1991 9 | 10 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 11 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 12 | Everyone is permitted to copy and distribute verbatim copies 13 | of this license document, but changing it is not allowed. 14 | 15 | Preamble 16 | 17 | The licenses for most software are designed to take away your 18 | freedom to share and change it. By contrast, the GNU General Public 19 | License is intended to guarantee your freedom to share and change free 20 | software--to make sure the software is free for all its users. This 21 | General Public License applies to most of the Free Software 22 | Foundation's software and to any other program whose authors commit to 23 | using it. (Some other Free Software Foundation software is covered by 24 | the GNU Lesser General Public License instead.) You can apply it to 25 | your programs, too. 26 | 27 | When we speak of free software, we are referring to freedom, not 28 | price. Our General Public Licenses are designed to make sure that you 29 | have the freedom to distribute copies of free software (and charge for 30 | this service if you wish), that you receive source code or can get it 31 | if you want it, that you can change the software or use pieces of it 32 | in new free programs; and that you know you can do these things. 33 | 34 | To protect your rights, we need to make restrictions that forbid 35 | anyone to deny you these rights or to ask you to surrender the rights. 36 | These restrictions translate to certain responsibilities for you if you 37 | distribute copies of the software, or if you modify it. 38 | 39 | For example, if you distribute copies of such a program, whether 40 | gratis or for a fee, you must give the recipients all the rights that 41 | you have. You must make sure that they, too, receive or can get the 42 | source code. And you must show them these terms so they know their 43 | rights. 44 | 45 | We protect your rights with two steps: (1) copyright the software, and 46 | (2) offer you this license which gives you legal permission to copy, 47 | distribute and/or modify the software. 48 | 49 | Also, for each author's protection and ours, we want to make certain 50 | that everyone understands that there is no warranty for this free 51 | software. If the software is modified by someone else and passed on, we 52 | want its recipients to know that what they have is not the original, so 53 | that any problems introduced by others will not reflect on the original 54 | authors' reputations. 55 | 56 | Finally, any free program is threatened constantly by software 57 | patents. We wish to avoid the danger that redistributors of a free 58 | program will individually obtain patent licenses, in effect making the 59 | program proprietary. To prevent this, we have made it clear that any 60 | patent must be licensed for everyone's free use or not licensed at all. 61 | 62 | The precise terms and conditions for copying, distribution and 63 | modification follow. 64 | 65 | GNU GENERAL PUBLIC LICENSE 66 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 67 | 68 | 0. This License applies to any program or other work which contains 69 | a notice placed by the copyright holder saying it may be distributed 70 | under the terms of this General Public License. The "Program", below, 71 | refers to any such program or work, and a "work based on the Program" 72 | means either the Program or any derivative work under copyright law: 73 | that is to say, a work containing the Program or a portion of it, 74 | either verbatim or with modifications and/or translated into another 75 | language. (Hereinafter, translation is included without limitation in 76 | the term "modification".) Each licensee is addressed as "you". 77 | 78 | Activities other than copying, distribution and modification are not 79 | covered by this License; they are outside its scope. The act of 80 | running the Program is not restricted, and the output from the Program 81 | is covered only if its contents constitute a work based on the 82 | Program (independent of having been made by running the Program). 83 | Whether that is true depends on what the Program does. 84 | 85 | 1. You may copy and distribute verbatim copies of the Program's 86 | source code as you receive it, in any medium, provided that you 87 | conspicuously and appropriately publish on each copy an appropriate 88 | copyright notice and disclaimer of warranty; keep intact all the 89 | notices that refer to this License and to the absence of any warranty; 90 | and give any other recipients of the Program a copy of this License 91 | along with the Program. 92 | 93 | You may charge a fee for the physical act of transferring a copy, and 94 | you may at your option offer warranty protection in exchange for a fee. 95 | 96 | 2. You may modify your copy or copies of the Program or any portion 97 | of it, thus forming a work based on the Program, and copy and 98 | distribute such modifications or work under the terms of Section 1 99 | above, provided that you also meet all of these conditions: 100 | 101 | a) You must cause the modified files to carry prominent notices 102 | stating that you changed the files and the date of any change. 103 | 104 | b) You must cause any work that you distribute or publish, that in 105 | whole or in part contains or is derived from the Program or any 106 | part thereof, to be licensed as a whole at no charge to all third 107 | parties under the terms of this License. 108 | 109 | c) If the modified program normally reads commands interactively 110 | when run, you must cause it, when started running for such 111 | interactive use in the most ordinary way, to print or display an 112 | announcement including an appropriate copyright notice and a 113 | notice that there is no warranty (or else, saying that you provide 114 | a warranty) and that users may redistribute the program under 115 | these conditions, and telling the user how to view a copy of this 116 | License. (Exception: if the Program itself is interactive but 117 | does not normally print such an announcement, your work based on 118 | the Program is not required to print an announcement.) 119 | 120 | These requirements apply to the modified work as a whole. If 121 | identifiable sections of that work are not derived from the Program, 122 | and can be reasonably considered independent and separate works in 123 | themselves, then this License, and its terms, do not apply to those 124 | sections when you distribute them as separate works. But when you 125 | distribute the same sections as part of a whole which is a work based 126 | on the Program, the distribution of the whole must be on the terms of 127 | this License, whose permissions for other licensees extend to the 128 | entire whole, and thus to each and every part regardless of who wrote it. 129 | 130 | Thus, it is not the intent of this section to claim rights or contest 131 | your rights to work written entirely by you; rather, the intent is to 132 | exercise the right to control the distribution of derivative or 133 | collective works based on the Program. 134 | 135 | In addition, mere aggregation of another work not based on the Program 136 | with the Program (or with a work based on the Program) on a volume of 137 | a storage or distribution medium does not bring the other work under 138 | the scope of this License. 139 | 140 | 3. You may copy and distribute the Program (or a work based on it, 141 | under Section 2) in object code or executable form under the terms of 142 | Sections 1 and 2 above provided that you also do one of the following: 143 | 144 | a) Accompany it with the complete corresponding machine-readable 145 | source code, which must be distributed under the terms of Sections 146 | 1 and 2 above on a medium customarily used for software interchange; or, 147 | 148 | b) Accompany it with a written offer, valid for at least three 149 | years, to give any third party, for a charge no more than your 150 | cost of physically performing source distribution, a complete 151 | machine-readable copy of the corresponding source code, to be 152 | distributed under the terms of Sections 1 and 2 above on a medium 153 | customarily used for software interchange; or, 154 | 155 | c) Accompany it with the information you received as to the offer 156 | to distribute corresponding source code. (This alternative is 157 | allowed only for noncommercial distribution and only if you 158 | received the program in object code or executable form with such 159 | an offer, in accord with Subsection b above.) 160 | 161 | The source code for a work means the preferred form of the work for 162 | making modifications to it. For an executable work, complete source 163 | code means all the source code for all modules it contains, plus any 164 | associated interface definition files, plus the scripts used to 165 | control compilation and installation of the executable. However, as a 166 | special exception, the source code distributed need not include 167 | anything that is normally distributed (in either source or binary 168 | form) with the major components (compiler, kernel, and so on) of the 169 | operating system on which the executable runs, unless that component 170 | itself accompanies the executable. 171 | 172 | If distribution of executable or object code is made by offering 173 | access to copy from a designated place, then offering equivalent 174 | access to copy the source code from the same place counts as 175 | distribution of the source code, even though third parties are not 176 | compelled to copy the source along with the object code. 177 | 178 | 4. You may not copy, modify, sublicense, or distribute the Program 179 | except as expressly provided under this License. Any attempt 180 | otherwise to copy, modify, sublicense or distribute the Program is 181 | void, and will automatically terminate your rights under this License. 182 | However, parties who have received copies, or rights, from you under 183 | this License will not have their licenses terminated so long as such 184 | parties remain in full compliance. 185 | 186 | 5. You are not required to accept this License, since you have not 187 | signed it. However, nothing else grants you permission to modify or 188 | distribute the Program or its derivative works. These actions are 189 | prohibited by law if you do not accept this License. Therefore, by 190 | modifying or distributing the Program (or any work based on the 191 | Program), you indicate your acceptance of this License to do so, and 192 | all its terms and conditions for copying, distributing or modifying 193 | the Program or works based on it. 194 | 195 | 6. Each time you redistribute the Program (or any work based on the 196 | Program), the recipient automatically receives a license from the 197 | original licensor to copy, distribute or modify the Program subject to 198 | these terms and conditions. You may not impose any further 199 | restrictions on the recipients' exercise of the rights granted herein. 200 | You are not responsible for enforcing compliance by third parties to 201 | this License. 202 | 203 | 7. If, as a consequence of a court judgment or allegation of patent 204 | infringement or for any other reason (not limited to patent issues), 205 | conditions are imposed on you (whether by court order, agreement or 206 | otherwise) that contradict the conditions of this License, they do not 207 | excuse you from the conditions of this License. If you cannot 208 | distribute so as to satisfy simultaneously your obligations under this 209 | License and any other pertinent obligations, then as a consequence you 210 | may not distribute the Program at all. For example, if a patent 211 | license would not permit royalty-free redistribution of the Program by 212 | all those who receive copies directly or indirectly through you, then 213 | the only way you could satisfy both it and this License would be to 214 | refrain entirely from distribution of the Program. 215 | 216 | If any portion of this section is held invalid or unenforceable under 217 | any particular circumstance, the balance of the section is intended to 218 | apply and the section as a whole is intended to apply in other 219 | circumstances. 220 | 221 | It is not the purpose of this section to induce you to infringe any 222 | patents or other property right claims or to contest validity of any 223 | such claims; this section has the sole purpose of protecting the 224 | integrity of the free software distribution system, which is 225 | implemented by public license practices. Many people have made 226 | generous contributions to the wide range of software distributed 227 | through that system in reliance on consistent application of that 228 | system; it is up to the author/donor to decide if he or she is willing 229 | to distribute software through any other system and a licensee cannot 230 | impose that choice. 231 | 232 | This section is intended to make thoroughly clear what is believed to 233 | be a consequence of the rest of this License. 234 | 235 | 8. If the distribution and/or use of the Program is restricted in 236 | certain countries either by patents or by copyrighted interfaces, the 237 | original copyright holder who places the Program under this License 238 | may add an explicit geographical distribution limitation excluding 239 | those countries, so that distribution is permitted only in or among 240 | countries not thus excluded. In such case, this License incorporates 241 | the limitation as if written in the body of this License. 242 | 243 | 9. The Free Software Foundation may publish revised and/or new versions 244 | of the General Public License from time to time. Such new versions will 245 | be similar in spirit to the present version, but may differ in detail to 246 | address new problems or concerns. 247 | 248 | Each version is given a distinguishing version number. If the Program 249 | specifies a version number of this License which applies to it and "any 250 | later version", you have the option of following the terms and conditions 251 | either of that version or of any later version published by the Free 252 | Software Foundation. If the Program does not specify a version number of 253 | this License, you may choose any version ever published by the Free Software 254 | Foundation. 255 | 256 | 10. If you wish to incorporate parts of the Program into other free 257 | programs whose distribution conditions are different, write to the author 258 | to ask for permission. For software which is copyrighted by the Free 259 | Software Foundation, write to the Free Software Foundation; we sometimes 260 | make exceptions for this. Our decision will be guided by the two goals 261 | of preserving the free status of all derivatives of our free software and 262 | of promoting the sharing and reuse of software generally. 263 | 264 | NO WARRANTY 265 | 266 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 267 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 268 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 269 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 270 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 271 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 272 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 273 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 274 | REPAIR OR CORRECTION. 275 | 276 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 277 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 278 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 279 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 280 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 281 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 282 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 283 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 284 | POSSIBILITY OF SUCH DAMAGES. 285 | 286 | END OF TERMS AND CONDITIONS 287 | 288 | How to Apply These Terms to Your New Programs 289 | 290 | If you develop a new program, and you want it to be of the greatest 291 | possible use to the public, the best way to achieve this is to make it 292 | free software which everyone can redistribute and change under these terms. 293 | 294 | To do so, attach the following notices to the program. It is safest 295 | to attach them to the start of each source file to most effectively 296 | convey the exclusion of warranty; and each file should have at least 297 | the "copyright" line and a pointer to where the full notice is found. 298 | 299 | 300 | Copyright (C) 301 | 302 | This program is free software; you can redistribute it and/or modify 303 | it under the terms of the GNU General Public License as published by 304 | the Free Software Foundation; either version 2 of the License, or 305 | (at your option) any later version. 306 | 307 | This program is distributed in the hope that it will be useful, 308 | but WITHOUT ANY WARRANTY; without even the implied warranty of 309 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 310 | GNU General Public License for more details. 311 | 312 | You should have received a copy of the GNU General Public License along 313 | with this program; if not, write to the Free Software Foundation, Inc., 314 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 315 | 316 | Also add information on how to contact you by electronic and paper mail. 317 | 318 | If the program is interactive, make it output a short notice like this 319 | when it starts in an interactive mode: 320 | 321 | Gnomovision version 69, Copyright (C) year name of author 322 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 323 | This is free software, and you are welcome to redistribute it 324 | under certain conditions; type `show c' for details. 325 | 326 | The hypothetical commands `show w' and `show c' should show the appropriate 327 | parts of the General Public License. Of course, the commands you use may 328 | be called something other than `show w' and `show c'; they could even be 329 | mouse-clicks or menu items--whatever suits your program. 330 | 331 | You should also get your employer (if you work as a programmer) or your 332 | school, if any, to sign a "copyright disclaimer" for the program, if 333 | necessary. Here is a sample; alter the names: 334 | 335 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 336 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 337 | 338 | , 1 April 1989 339 | Ty Coon, President of Vice 340 | 341 | This General Public License does not permit incorporating your program into 342 | proprietary programs. If your program is a subroutine library, you may 343 | consider it more useful to permit linking proprietary applications with the 344 | library. If this is what you want to do, use the GNU Lesser General 345 | Public License instead of this License. 346 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Purtle 2 | 3 | **Purtle** is a fast, lightweight RDF generator. It provides a "fluent" interface for 4 | generating RDF output in Turtle, JSON-LD, XML/RDF or N-Triples. The fluent interface allows the 5 | resulting PHP code to be structured just like Turtle notation for RDF, hence the name: "Purtle" 6 | is a contraction of "PHP Turtle". 7 | 8 | The concrete classes implementing the common `RdfWriter` interface are: 9 | * `TurtleRdfWriter` outputs Turtle 10 | * `JsonLdRdfWriter` outputs JSON-LD 11 | * `XmlRdfWriter` outputs XML/RDF 12 | * `NTriplesRdfWriter` outputs N-Triples 13 | 14 | The PHP code would look something like this: 15 | 16 | ```php 17 | $writer = new TurtleRdfWriter(); 18 | 19 | $writer->prefix( 'acme', 'http://acme.test/terms/' ); 20 | 21 | $writer->about( 'http://quux.test/Something' ) 22 | ->a( 'acme', 'Thing' ) 23 | ->say( 'acme', 'name' )->text( 'Thingy' )->text( 'Dingsda', 'de' ) 24 | ->say( 'acme', 'owner' )->is( 'http://quux.test/' ); 25 | ``` 26 | -------------------------------------------------------------------------------- /RELEASE-NOTES.md: -------------------------------------------------------------------------------- 1 | # Purtle release notes 2 | 3 | ## Version 2.0.0 (2024-11-10) 4 | * Add phan (Reedy) 5 | * [BREAKING CHANGE] Drop PHP 7.2 and PHP 7.3 support (James D. Forrester) 6 | * build: Switch phan to special library mode (James D. Forrester) 7 | * build: Updating composer dependencies (libraryupgrader) 8 | * build: Updating composer dependencies (libraryupgrader) 9 | * build: Updating composer dependencies (libraryupgrader) 10 | * build: Updating composer dependencies (libraryupgrader) 11 | * build: Updating mediawiki/mediawiki-codesniffer to 37.0.0 (libraryupgrader) 12 | * build: Updating mediawiki/mediawiki-codesniffer to 41.0.0 (libraryupgrader) 13 | * build: Updating mediawiki/mediawiki-codesniffer to 44.0.0 (libraryupgrader) 14 | * build: Updating mediawiki/mediawiki-codesniffer to 45.0.0 (libraryupgrader) 15 | * build: Updating mediawiki/mediawiki-phan-config to 0.12.0 (Umherirrender) 16 | * build: Updating mediawiki/mediawiki-phan-config to 0.13.0 (libraryupgrader) 17 | * build: Upgrade mediawiki/mediawiki-codesniffer to v43.0.0 (Umherirrender) 18 | * build: Upgrade mediawiki/mediawiki-phan-config from 0.13.0 to 0.14.0 manually (James D. Forrester) 19 | * build: Upgrade PHPUnit from ^8.5 to 9.5.28 (James D. Forrester) 20 | * build: Upgrade phpunit to 9.6.16 (James D. Forrester) 21 | * Fix several type hints (Thiemo Kreuz) 22 | * Minor cleanup (Reedy) 23 | * Remove unused role constants (Thiemo Kreuz) 24 | * tests: Replace assertRegExp with assertMatchesRegularExpression (Umherirrender) 25 | * Use explicit nullable type on parameter arguments (Reedy) 26 | 27 | ## Version 1.0.8 (2021-06-17) 28 | * Require PHP 7.2 or later 29 | * Fix phpcs issues 30 | 31 | ## Version 1.0.7 (2018-03-20) 32 | * Add JSON-LD support 33 | * Improve speed of `N3Quoter::escapeLiteral` 34 | * Add ability to set `BNodeLabeler` to `RdfWriterFactory::getWriter` 35 | 36 | ## Version 1.0.6 (2017-06-27) 37 | * Remove code for MediaWiki framework integration 38 | * Fix phpcs issues 39 | 40 | ## Version 1.0.5 (2017-01-18) 41 | * Do not double-quote quotes in NTriples format 42 | * Fix phpcs issues 43 | 44 | ## Version 1.0.4 (2016-09-15) 45 | * Fix writing strings with bad language tags 46 | * Fix escaping \a and \v 47 | 48 | ## Version 1.0.3 (2016-04-30) 49 | * Ensure correct state when short-circuitting in `RdfWriterBase::about` and `say`. 50 | 51 | ## Version 1.0.2 (2016-04-29) 52 | * Fixed homepage URL. 53 | 54 | ## Version 1.0.1 (2016-04-29) 55 | * Fixed PSR-4 class loader for test helpers. 56 | 57 | ## Version 1.0.0 (2016-04-29) 58 | 59 | Initial release 60 | -------------------------------------------------------------------------------- /src/BNodeLabeler.php: -------------------------------------------------------------------------------- 1 | = 1' ); 41 | } 42 | 43 | $this->prefix = $prefix; 44 | $this->counter = $start; 45 | } 46 | 47 | /** 48 | * @param string|null $label node label; will be generated if not given. 49 | * 50 | * @return string 51 | */ 52 | public function getLabel( $label = null ) { 53 | if ( $label === null ) { 54 | $label = $this->prefix . $this->counter; 55 | $this->counter++; 56 | } 57 | 58 | return $label; 59 | } 60 | 61 | } 62 | -------------------------------------------------------------------------------- /src/JsonLdRdfWriter.php: -------------------------------------------------------------------------------- 1 | graph to null in 39 | * #finishJson() to ensure that the deferred callback in #finishDocument() 40 | * doesn't later emit "@graph". 41 | * 42 | * @see https://www.w3.org/TR/json-ld/#named-graphs 43 | * 44 | * @var array|null 45 | */ 46 | private $graph = []; 47 | 48 | /** 49 | * A collection of predicates about a specific subject. The 50 | * subject is identified by the "@id" key in this array; the other 51 | * keys identify JSON-LD properties. 52 | * 53 | * @see https://www.w3.org/TR/json-ld/#dfn-edge 54 | * 55 | * @var array 56 | */ 57 | private $predicates = []; 58 | 59 | /** 60 | * A sequence of zero or more IRIs, nodes, or values, which are the 61 | * destination targets of the current predicates. 62 | * 63 | * @see https://www.w3.org/TR/json-ld/#dfn-list 64 | * 65 | * @var array 66 | */ 67 | private $values = []; 68 | 69 | /** 70 | * True iff we have written the opening of the "@graph" field. 71 | * 72 | * @var bool 73 | */ 74 | private $wroteGraph = false; 75 | 76 | /** 77 | * JSON-LD objects describing a single node can omit the "@graph" field; 78 | * this variable remains false only so long as we can guarantee that 79 | * only a single node has been described. 80 | * 81 | * @var bool 82 | */ 83 | private $disableGraphOpt = false; 84 | 85 | /** 86 | * The IRI for the RDF `type` property. 87 | */ 88 | private const RDF_TYPE_IRI = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type'; 89 | 90 | /** 91 | * The type internally used for "default type", which is a string or 92 | * otherwise default-coerced type. 93 | */ 94 | private const DEFAULT_TYPE = '@purtle@default@'; 95 | 96 | /** 97 | * @param string $role 98 | * @param BNodeLabeler|null $labeler 99 | */ 100 | public function __construct( $role = parent::DOCUMENT_ROLE, ?BNodeLabeler $labeler = null ) { 101 | parent::__construct( $role, $labeler ); 102 | 103 | // The following named methods are protected, not private, so we 104 | // can invoke them directly w/o function wrappers. 105 | $this->transitionTable[self::STATE_START][self::STATE_DOCUMENT] = 106 | [ $this, 'beginJson' ]; 107 | $this->transitionTable[self::STATE_DOCUMENT][self::STATE_FINISH] = 108 | [ $this, 'finishJson' ]; 109 | $this->transitionTable[self::STATE_OBJECT][self::STATE_PREDICATE] = 110 | [ $this, 'finishPredicate' ]; 111 | $this->transitionTable[self::STATE_OBJECT][self::STATE_SUBJECT] = 112 | [ $this, 'finishSubject' ]; 113 | $this->transitionTable[self::STATE_OBJECT][self::STATE_DOCUMENT] = 114 | [ $this, 'finishDocument' ]; 115 | } 116 | 117 | /** 118 | * Emit $val as JSON, with $indent extra indentations on each line. 119 | * @param array $val 120 | * @param int $indent 121 | * @return string the JSON string for $val 122 | */ 123 | public function encode( $val, $indent ) { 124 | $str = json_encode( $val, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ); 125 | // Strip outermost open/close braces/brackets 126 | $str = preg_replace( '/^[[{]\n?|\n?[}\]]$/', '', $str ); 127 | 128 | if ( $indent > 0 ) { 129 | // add extra indentation 130 | $str = preg_replace( '/^/m', str_repeat( ' ', $indent ), $str ); 131 | } 132 | 133 | return $str; 134 | } 135 | 136 | /** 137 | * Return a "compact IRI" corresponding to the given base/local pair. 138 | * This adds entries to the "@context" key when needed to allow use 139 | * of a given prefix. 140 | * @see https://www.w3.org/TR/json-ld/#dfn-compact-iri 141 | * 142 | * @param string $base A QName prefix if $local is given, or an IRI if $local is null. 143 | * @param string|null $local A QName suffix, or null if $base is an IRI. 144 | * 145 | * @return string A compact IRI. 146 | */ 147 | private function compactify( $base, $local = null ) { 148 | $this->expandShorthand( $base, $local ); 149 | 150 | if ( $local === null ) { 151 | return $base; 152 | } else { 153 | if ( $base !== '_' && isset( $this->prefixes[ $base ] ) ) { 154 | if ( $base === '' ) { 155 | // Empty prefixes are not supported; use full IRI 156 | return $this->prefixes[ $base ] . $local; 157 | } 158 | if ( !isset( $this->context[ $base ] ) ) { 159 | $this->context[ $base ] = $this->prefixes[ $base ]; 160 | } 161 | if ( $this->context[ $base ] !== $this->prefixes[ $base ] ) { 162 | // Context name conflict; use full IRI 163 | return $this->prefixes[ $base ] . $local; 164 | } 165 | } 166 | return $base . ':' . $local; 167 | } 168 | } 169 | 170 | /** 171 | * Return an absolute IRI from the given base/local pair. 172 | * @see https://www.w3.org/TR/json-ld/#dfn-absolute-iri 173 | * 174 | * @param string $base A QName prefix if $local is given, or an IRI if $local is null. 175 | * @param string|null $local A QName suffix, or null if $base is an IRI. 176 | * 177 | * @return string|null An absolute IRI, or null if it cannot be constructed. 178 | */ 179 | private function toIRI( $base, $local ) { 180 | $this->expandShorthand( $base, $local ); 181 | $this->expandQName( $base, $local ); 182 | if ( $local !== null ) { 183 | throw new LogicException( 'Unknown prefix: ' . $base ); 184 | } 185 | return $base; 186 | } 187 | 188 | /** 189 | * Return a appropriate term for the current predicate value. 190 | * 191 | * @return string 192 | */ 193 | private function getCurrentTerm() { 194 | [ $base, $local ] = $this->currentPredicate; 195 | $predIRI = $this->toIRI( $base, $local ); 196 | if ( $predIRI === self::RDF_TYPE_IRI ) { 197 | return $predIRI; 198 | } 199 | $this->expandShorthand( $base, $local ); 200 | if ( $local === null ) { 201 | return $base; 202 | } elseif ( $base !== '_' && !isset( $this->prefixes[ $local ] ) ) { 203 | // Prefixes get priority over field names in @context 204 | $pred = $this->compactify( $base, $local ); 205 | if ( !isset( $this->context[ $local ] ) ) { 206 | $this->context[ $local ] = [ '@id' => $pred ]; 207 | } 208 | if ( $this->context[ $local ][ '@id' ] === $pred ) { 209 | return $local; 210 | } 211 | return $pred; 212 | } 213 | return $this->compactify( $base, $local ); 214 | } 215 | 216 | /** 217 | * Write document header. 218 | */ 219 | protected function beginJson() { 220 | if ( $this->role === self::DOCUMENT_ROLE ) { 221 | $this->write( "{\n" ); 222 | $this->write( function () { 223 | // If this buffer is drained early, disable @graph optimization 224 | $this->disableGraphOpt = true; 225 | return ''; 226 | } ); 227 | } 228 | } 229 | 230 | /** 231 | * Write document footer. 232 | */ 233 | protected function finishJson() { 234 | // If we haven't drained yet, and @graph has only 1 element, then we 235 | // can optimize our output and hoist the single node to top level. 236 | if ( $this->role === self::DOCUMENT_ROLE ) { 237 | if ( ( !$this->disableGraphOpt ) && count( $this->graph ) === 1 ) { 238 | $this->write( $this->encode( $this->graph[0], 0 ) ); 239 | $this->graph = null; // We're done with @graph. 240 | } else { 241 | $this->disableGraphOpt = true; 242 | $this->write( "\n ]" ); 243 | } 244 | } 245 | 246 | if ( count( $this->context ) ) { 247 | // Write @context field. 248 | $this->write( ",\n" ); 249 | $this->write( $this->encode( [ 250 | '@context' => $this->context 251 | ], 0 ) ); 252 | } 253 | 254 | $this->write( "\n}" ); 255 | } 256 | 257 | protected function finishDocument() { 258 | $this->finishSubject(); 259 | $this->write( function () { 260 | // if this is drained before finishJson(), then disable 261 | // the graph optimization and dump what we've got so far. 262 | $str = ''; 263 | if ( $this->graph !== null && count( $this->graph ) > 0 ) { 264 | $this->disableGraphOpt = true; 265 | if ( $this->role === self::DOCUMENT_ROLE && !$this->wroteGraph ) { 266 | $str .= " \"@graph\": [\n"; 267 | $this->wroteGraph = true; 268 | } else { 269 | $str .= ",\n"; 270 | } 271 | $str .= $this->encode( $this->graph, 1 ); 272 | $this->graph = []; 273 | return $str; 274 | } 275 | // Delay; maybe we'll be able to optimize this later. 276 | return $str; 277 | } ); 278 | } 279 | 280 | /** 281 | * @param string $base 282 | * @param string|null $local 283 | */ 284 | protected function writeSubject( $base, $local = null ) { 285 | $this->predicates = [ 286 | '@id' => $this->compactify( $base, $local ) 287 | ]; 288 | } 289 | 290 | protected function finishSubject() { 291 | $this->finishPredicate(); 292 | $this->graph[] = $this->predicates; 293 | } 294 | 295 | /** 296 | * @param string $base 297 | * @param string|null $local 298 | */ 299 | protected function writePredicate( $base, $local = null ) { 300 | // no op 301 | } 302 | 303 | /** 304 | * @param string $base 305 | * @param string|null $local 306 | */ 307 | protected function writeResource( $base, $local = null ) { 308 | $pred = $this->getCurrentTerm(); 309 | $value = $this->compactify( $base, $local ); 310 | $this->addTypedValue( '@id', $value, [ 311 | '@id' => $value 312 | ], ( $pred === self::RDF_TYPE_IRI ) ); 313 | } 314 | 315 | /** 316 | * @param string $text 317 | * @param string|null $language 318 | */ 319 | protected function writeText( $text, $language = null ) { 320 | if ( !$this->isValidLanguageCode( $language ) ) { 321 | $this->addTypedValue( self::DEFAULT_TYPE, $text ); 322 | } else { 323 | $expanded = [ 324 | '@language' => $language, 325 | '@value' => $text 326 | ]; 327 | $this->addTypedValue( self::DEFAULT_TYPE, $expanded, $expanded ); 328 | } 329 | } 330 | 331 | /** 332 | * @param string $literal 333 | * @param string|null $typeBase 334 | * @param string|null $typeLocal 335 | */ 336 | public function writeValue( $literal, $typeBase, $typeLocal = null ) { 337 | if ( $typeBase === null && $typeLocal === null ) { 338 | $this->addTypedValue( self::DEFAULT_TYPE, $literal ); 339 | return; 340 | } 341 | 342 | switch ( $this->toIRI( $typeBase, $typeLocal ) ) { 343 | case 'http://www.w3.org/2001/XMLSchema#string': 344 | $this->addTypedValue( self::DEFAULT_TYPE, strval( $literal ) ); 345 | return; 346 | case 'http://www.w3.org/2001/XMLSchema#integer': 347 | $this->addTypedValue( self::DEFAULT_TYPE, intval( $literal ) ); 348 | return; 349 | case 'http://www.w3.org/2001/XMLSchema#boolean': 350 | $this->addTypedValue( self::DEFAULT_TYPE, ( $literal === 'true' ) ); 351 | return; 352 | case 'http://www.w3.org/2001/XMLSchema#double': 353 | $v = floatval( $literal ); 354 | // Only "numbers with fractions" are xsd:double. We need 355 | // to verify that the JSON string will contain a decimal 356 | // point, otherwise the value would be interpreted as an 357 | // xsd:integer. 358 | // TODO: consider instead using JSON_PRESERVE_ZERO_FRACTION 359 | // in $this->encode() once our required PHP >= 5.6.6. 360 | // OTOH, the spec language is ambiguous about whether "5." 361 | // would be considered an integer or a double. 362 | if ( strpos( json_encode( $v ), '.' ) !== false ) { 363 | $this->addTypedValue( self::DEFAULT_TYPE, $v ); 364 | return; 365 | } 366 | } 367 | 368 | $type = $this->compactify( $typeBase, $typeLocal ); 369 | $literal = strval( $literal ); 370 | $this->addTypedValue( $type, $literal, [ 371 | '@type' => $type, 372 | '@value' => $literal 373 | ] ); 374 | } 375 | 376 | /** 377 | * Add a typed value for the given predicate. If possible, adds a 378 | * default type to the context to avoid having to repeat type information 379 | * in each value for this predicate. If there is already a default 380 | * type which conflicts with this one, or if $forceExpand is true, 381 | * then use the "expanded" value which will explicitly override any 382 | * default type. 383 | * 384 | * @param string $type The compactified JSON-LD @type for this value, or 385 | * self::DEFAULT_TYPE to indicate the default JSON-LD type coercion rules 386 | * should be used. 387 | * @param string|int|float|bool $simpleVal The "simple" representation 388 | * for this value, used if the type can be hoisted into the context. 389 | * @param array|null $expandedVal The "expanded" representation for this 390 | * value, used if the context @type conflicts with this value; or null 391 | * to use "@value" for the expanded representation. 392 | * @param bool $forceExpand If true, don't try to add this type to the 393 | * context. Defaults to false. 394 | */ 395 | protected function addTypedValue( $type, $simpleVal, $expandedVal = null, $forceExpand = false ) { 396 | if ( !$forceExpand ) { 397 | $pred = $this->getCurrentTerm(); 398 | if ( $type === self::DEFAULT_TYPE ) { 399 | if ( !isset( $this->context[ $pred ][ '@type' ] ) ) { 400 | $this->defaulted[ $pred ] = true; 401 | } 402 | if ( isset( $this->defaulted[ $pred ] ) ) { 403 | $this->values[] = $simpleVal; 404 | return; 405 | } 406 | } elseif ( !isset( $this->defaulted[ $pred ] ) ) { 407 | if ( !isset( $this->context[ $pred ] ) ) { 408 | $this->context[ $pred ] = []; 409 | } 410 | if ( !isset( $this->context[ $pred ][ '@type' ] ) ) { 411 | $this->context[ $pred ][ '@type' ] = $type; 412 | } 413 | if ( $this->context[ $pred ][ '@type' ] === $type ) { 414 | $this->values[] = $simpleVal; 415 | return; 416 | } 417 | } 418 | } 419 | if ( $expandedVal === null ) { 420 | $this->values[] = [ '@value' => $simpleVal ]; 421 | } else { 422 | $this->values[] = $expandedVal; 423 | } 424 | } 425 | 426 | protected function finishPredicate() { 427 | $name = $this->getCurrentTerm(); 428 | 429 | if ( $name === self::RDF_TYPE_IRI ) { 430 | $name = '@type'; 431 | $this->values = array_map( static function ( array $val ) { 432 | return $val[ '@id' ]; 433 | }, $this->values ); 434 | } 435 | if ( isset( $this->predicates[$name] ) ) { 436 | $was = $this->predicates[$name]; 437 | // Wrap $was into a numeric indexed array if it isn't already. 438 | // Note that $was could have non-numeric indices, eg 439 | // [ "@id" => "foo" ], in which was it still needs to be wrapped. 440 | if ( !( is_array( $was ) && isset( $was[0] ) ) ) { 441 | $was = [ $was ]; 442 | } 443 | $this->values = array_merge( $was, $this->values ); 444 | } 445 | 446 | $cnt = count( $this->values ); 447 | if ( $cnt === 0 ) { 448 | throw new LogicException( 'finishPredicate can\'t be called without at least one value' ); 449 | } elseif ( $cnt === 1 ) { 450 | $this->predicates[$name] = $this->values[0]; 451 | } else { 452 | $this->predicates[$name] = $this->values; 453 | } 454 | 455 | $this->values = []; 456 | } 457 | 458 | /** 459 | * @param string $role 460 | * @param BNodeLabeler $labeler 461 | * 462 | * @return RdfWriterBase 463 | */ 464 | protected function newSubWriter( $role, BNodeLabeler $labeler ) { 465 | $writer = new self( $role, $labeler ); 466 | 467 | // Have subwriter share context with this parent. 468 | $writer->context = &$this->context; 469 | $writer->defaulted = &$this->defaulted; 470 | 471 | // We can't use the @graph optimization. 472 | $this->disableGraphOpt = true; 473 | 474 | return $writer; 475 | } 476 | 477 | /** 478 | * @return string a MIME type 479 | */ 480 | public function getMimeType() { 481 | return 'application/ld+json; charset=UTF-8'; 482 | } 483 | 484 | } 485 | -------------------------------------------------------------------------------- /src/N3Quoter.php: -------------------------------------------------------------------------------- 1 | escaper = $escapeUnicode ? new UnicodeEscaper() : null; 24 | } 25 | 26 | /** 27 | * @param string $iri 28 | * 29 | * @return string 30 | */ 31 | public function escapeIRI( $iri ) { 32 | // FIXME: apply unicode escaping?! 33 | return strtr( $iri, [ 34 | ' ' => '%20', 35 | '"' => '%22', 36 | '<' => '%3C', 37 | '>' => '%3E', 38 | '\\' => '%5C', 39 | '`' => '%60', 40 | '^' => '%5E', 41 | '|' => '%7C', 42 | '{' => '%7B', 43 | '}' => '%7D', 44 | ] ); 45 | } 46 | 47 | /** 48 | * @param string $s 49 | * 50 | * @return string 51 | */ 52 | public function escapeLiteral( $s ) { 53 | // Performance: If the entire string is just (a safe subset) of ASCII, let it through. 54 | // Ok are space (31), ! (32), # (35) - [ (91) and ] (93) to ~ (126), excludes " (34) and \ (92). 55 | if ( preg_match( '/^[ !#-[\]-~]*\z/', $s ) ) { 56 | return $s; 57 | } 58 | 59 | // String escapes. Note that the N3 spec is more restrictive than the Turtle and TR 60 | // specifications, see 61 | // and 62 | // and . 63 | // Allowed escapes according to the N3 spec are: 64 | // ECHAR ::= '\' [tbnrf"'\] 65 | // The single quote however does not require escaping when used in double quotes. 66 | $escaped = strtr( $s, [ 67 | "\x00" => '\u0000', 68 | "\x01" => '\u0001', 69 | "\x02" => '\u0002', 70 | "\x03" => '\u0003', 71 | "\x04" => '\u0004', 72 | "\x05" => '\u0005', 73 | "\x06" => '\u0006', 74 | "\x07" => '\u0007', 75 | "\x08" => '\b', 76 | "\x09" => '\t', 77 | "\x0A" => '\n', 78 | "\x0B" => '\u000B', 79 | "\x0C" => '\f', 80 | "\x0D" => '\r', 81 | "\x0E" => '\u000E', 82 | "\x0F" => '\u000F', 83 | "\x10" => '\u0010', 84 | "\x11" => '\u0011', 85 | "\x12" => '\u0012', 86 | "\x13" => '\u0013', 87 | "\x14" => '\u0014', 88 | "\x15" => '\u0015', 89 | "\x16" => '\u0016', 90 | "\x17" => '\u0017', 91 | "\x18" => '\u0018', 92 | "\x19" => '\u0019', 93 | "\x1A" => '\u001A', 94 | "\x1B" => '\u001B', 95 | "\x1C" => '\u001C', 96 | "\x1D" => '\u001D', 97 | "\x1E" => '\u001E', 98 | "\x1F" => '\u001F', 99 | '"' => '\"', 100 | '\\' => '\\\\', 101 | ] ); 102 | 103 | if ( $this->escaper !== null ) { 104 | $escaped = $this->escaper->escapeString( $escaped ); 105 | } 106 | 107 | return $escaped; 108 | } 109 | 110 | } 111 | -------------------------------------------------------------------------------- /src/N3RdfWriterBase.php: -------------------------------------------------------------------------------- 1 | quoter = $quoter ?: new N3Quoter(); 31 | } 32 | 33 | /** 34 | * @param string $base 35 | * @param string|null $local 36 | */ 37 | protected function writeRef( $base, $local = null ) { 38 | if ( $local === null ) { 39 | if ( $base === 'a' ) { 40 | $this->write( 'a' ); 41 | } else { 42 | $this->writeIRI( $base ); 43 | } 44 | } else { 45 | $this->write( "$base:$local" ); 46 | } 47 | } 48 | 49 | /** 50 | * @param string $iri 51 | * @param bool $trustIRI 52 | */ 53 | protected function writeIRI( $iri, $trustIRI = false ) { 54 | if ( !$trustIRI ) { 55 | $iri = $this->quoter->escapeIRI( $iri ); 56 | } 57 | $this->write( "<$iri>" ); 58 | } 59 | 60 | /** 61 | * @inheritDoc 62 | */ 63 | protected function writeText( $text, $language = null ) { 64 | $value = $this->quoter->escapeLiteral( $text ); 65 | $this->write( '"' . $value . '"' ); 66 | 67 | if ( $this->isValidLanguageCode( $language ) ) { 68 | $this->write( '@' . $language ); 69 | } 70 | } 71 | 72 | /** 73 | * @param string $value 74 | * @param string|null $typeBase 75 | * @param string|null $typeLocal 76 | */ 77 | protected function writeValue( $value, $typeBase, $typeLocal = null ) { 78 | $value = $this->quoter->escapeLiteral( $value ); 79 | $this->write( '"' . $value . '"' ); 80 | 81 | if ( $typeBase !== null ) { 82 | $this->write( '^^' ); 83 | $this->writeRef( $typeBase, $typeLocal ); 84 | } 85 | } 86 | 87 | } 88 | -------------------------------------------------------------------------------- /src/NTriplesRdfWriter.php: -------------------------------------------------------------------------------- 1 | quoter->setEscapeUnicode( true ); 28 | 29 | $this->transitionTable[self::STATE_OBJECT] = [ 30 | self::STATE_DOCUMENT => " .\n", 31 | self::STATE_SUBJECT => " .\n", 32 | self::STATE_PREDICATE => " .\n", 33 | self::STATE_OBJECT => " .\n", 34 | ]; 35 | } 36 | 37 | /** 38 | * @inheritDoc 39 | */ 40 | protected function expandSubject( &$base, &$local ) { 41 | $this->expandQName( $base, $local ); 42 | } 43 | 44 | /** 45 | * @inheritDoc 46 | */ 47 | protected function writeSubject( $base, $local = null ) { 48 | // noop 49 | } 50 | 51 | /** 52 | * @inheritDoc 53 | */ 54 | protected function expandPredicate( &$base, &$local ) { 55 | $this->expandShorthand( $base, $local ); // e.g. ( 'a', null ) => ( 'rdf', 'type' ) 56 | $this->expandQName( $base, $local ); // e.g. ( 'acme', 'foo' ) => ( 'http://acme.test/foo', null ) 57 | } 58 | 59 | /** 60 | * @inheritDoc 61 | */ 62 | protected function writePredicate( $base, $local = null ) { 63 | // noop 64 | } 65 | 66 | private function writeSubjectAndObject() { 67 | $this->writeRef( $this->currentSubject[0], $this->currentSubject[1] ); 68 | $this->write( ' ' ); 69 | $this->writeRef( $this->currentPredicate[0], $this->currentPredicate[1] ); 70 | } 71 | 72 | /** 73 | * @inheritDoc 74 | */ 75 | protected function expandResource( &$base, &$local ) { 76 | $this->expandQName( $base, $local ); 77 | } 78 | 79 | /** 80 | * @inheritDoc 81 | */ 82 | protected function expandType( &$base, &$local ) { 83 | $this->expandQName( $base, $local ); 84 | } 85 | 86 | /** 87 | * @inheritDoc 88 | */ 89 | protected function writeResource( $base, $local = null ) { 90 | $this->writeSubjectAndObject(); 91 | $this->write( ' ' ); 92 | $this->writeRef( $base, $local ); 93 | } 94 | 95 | /** 96 | * @inheritDoc 97 | */ 98 | protected function writeText( $text, $language = null ) { 99 | $this->writeSubjectAndObject(); 100 | $this->write( ' ' ); 101 | 102 | parent::writeText( $text, $language ); 103 | } 104 | 105 | /** 106 | * @param string $value 107 | * @param string|null $typeBase 108 | * @param string|null $typeLocal 109 | */ 110 | protected function writeValue( $value, $typeBase, $typeLocal = null ) { 111 | $this->writeSubjectAndObject(); 112 | $this->write( ' ' ); 113 | 114 | parent::writeValue( $value, $typeBase, $typeLocal ); 115 | } 116 | 117 | /** 118 | * @param string $role 119 | * @param BNodeLabeler $labeler 120 | * 121 | * @return RdfWriterBase 122 | */ 123 | protected function newSubWriter( $role, BNodeLabeler $labeler ) { 124 | $writer = new self( $role, $labeler, $this->quoter ); 125 | 126 | return $writer; 127 | } 128 | 129 | /** 130 | * @return string a MIME type 131 | */ 132 | public function getMimeType() { 133 | // NOTE: Add charset=UTF-8 if and when the constructor configures $this->quoter 134 | // to write utf-8. 135 | return 'application/n-triples'; 136 | } 137 | 138 | } 139 | -------------------------------------------------------------------------------- /src/RdfWriter.php: -------------------------------------------------------------------------------- 1 | prefix( 'acme', 'http://acme.test/terms/' ); 14 | * $writer->about( 'http://quux.test/Something' ) 15 | * ->say( 'acme', 'name' )->text( 'Thingy' )->text( 'Dingsda', 'de' ) 16 | * ->say( 'acme', 'owner' )->is( 'http://quux.test/' ); 17 | * @endcode 18 | * 19 | * To get the generated RDF output, use the drain() method. 20 | * 21 | * @note: The contract of this interface follows the GIGO principle, that is, 22 | * implementations are not required to ensure valid output or prompt failure on 23 | * invalid input. Speed should generally be favored over safety. 24 | * 25 | * Caveats: 26 | * - no relative iris 27 | * - predicates must be qnames 28 | * - no inline/nested blank nodes 29 | * - no comments 30 | * - no collections 31 | * - no automatic conversion of iris to qnames 32 | * 33 | * @license GPL-2.0-or-later 34 | * @author Daniel Kinzler 35 | */ 36 | interface RdfWriter { 37 | 38 | // TODO: split: generic RdfWriter class with shorthands, use RdfFormatters for output 39 | 40 | /** 41 | * Returns the local name of a blank node, for use with the "_" prefix. 42 | * 43 | * @param string|null $label node label, will be generated if not given. 44 | * 45 | * @return string A local name for the blank node, for use with the '_' prefix. 46 | */ 47 | public function blank( $label = null ); 48 | 49 | /** 50 | * Start the document. May generate a header. 51 | */ 52 | public function start(); 53 | 54 | /** 55 | * Finish the document. May generate a footer. 56 | * 57 | * This will detach all sub-writers that had earlier been returned by sub(). 58 | */ 59 | public function finish(); 60 | 61 | /** 62 | * Generates an RDF string from the current buffers state and returns it. 63 | * The buffer is reset to the empty state. 64 | * Before the result string is generated, implementations should close any 65 | * pending syntactical structures (close tags, generate footers, etc). 66 | * 67 | * @return string The RDF output 68 | */ 69 | public function drain(); 70 | 71 | /** 72 | * Declare a prefix for later use. Prefixes should be declared before being used. 73 | * Should not be called after start(). 74 | * 75 | * @param string $prefix 76 | * @param string $iri a IRI 77 | */ 78 | public function prefix( $prefix, $iri ); 79 | 80 | /** 81 | * Start an "about" (subject) clause, given a subject. 82 | * Can occur at the beginning odf the output sequence, but can later only follow 83 | * a call to is(), text(), or value(). 84 | * Should fail if called at an inappropriate time in the output sequence. 85 | * 86 | * @param string $base A QName prefix if $local is given, or an IRI if $local is null. 87 | * @param string|null $local A QName suffix, or null if $base is an IRI. 88 | * 89 | * @return RdfWriter $this 90 | */ 91 | public function about( $base, $local = null ); 92 | 93 | /** 94 | * Start a predicate clause. 95 | * Can only follow a call to about() or say(). 96 | * Should fail if called at an inappropriate time in the output sequence. 97 | * 98 | * @note Unlike about() and is(), say() cannot be called with a full IRI, 99 | * but must always use qname form. This is required to cater to output 100 | * formats that do not allow IRIs to be used as predicates directly, 101 | * like RDF/XML. 102 | * 103 | * @param string $base A QName prefix if $local is given, or a shorthand. MUST NOT be an IRI. 104 | * @param string|null $local A QName suffix, or null if $base is a shorthand. 105 | * 106 | * @return RdfWriter $this 107 | */ 108 | public function say( $base, $local = null ); 109 | 110 | /** 111 | * Produce a resource as the object of a statement. 112 | * Can only follow a call to say() or a call to one of is(), text(), or value(). 113 | * Should fail if called at an inappropriate time in the output sequence. 114 | * 115 | * @param string $base A QName prefix if $local is given, or an IRI or shorthand if $local is null. 116 | * @param string|null $local A QName suffix, or null if $base is an IRI or shorthand. 117 | * 118 | * @return RdfWriter $this 119 | */ 120 | public function is( $base, $local = null ); 121 | 122 | /** 123 | * Produce a text literal as the object of a statement. 124 | * Can only follow a call to say() or a call to one of is(), text(), or value(). 125 | * Should fail if called at an inappropriate time in the output sequence. 126 | * 127 | * @param string $text the text to be placed in the output 128 | * @param string|null $language the language the text is in 129 | * 130 | * @return RdfWriter $this 131 | */ 132 | public function text( $text, $language = null ); 133 | 134 | /** 135 | * Produce a typed or untyped literal as the object of a statement. 136 | * Can only follow a call to say() or a call to one of is(), text(), or value(). 137 | * Should fail if called at an inappropriate time in the output sequence. 138 | * 139 | * @param string $value the value encoded as a string 140 | * @param string|null $typeBase The data type's QName prefix if $typeLocal is given, 141 | * or an IRI or shorthand if $typeLocal is null. 142 | * @param string|null $typeLocal The data type's QName suffix, 143 | * or null if $typeBase is an IRI or shorthand. 144 | * 145 | * @return RdfWriter $this 146 | */ 147 | public function value( $value, $typeBase = null, $typeLocal = null ); 148 | 149 | /** 150 | * Shorthand for say( 'a' )->is( $type ). 151 | * 152 | * @param string $typeBase The data type's QName prefix if $typeLocal is given, 153 | * or an IRI or shorthand if $typeLocal is null. 154 | * @param string|null $typeLocal The data type's QName suffix, 155 | * or null if $typeBase is an IRI or shorthand. 156 | * 157 | * @return RdfWriter $this 158 | */ 159 | public function a( $typeBase, $typeLocal = null ); 160 | 161 | /** 162 | * Returns a document-level sub-writer. 163 | * This can be used to generate parts statements out of sequence. 164 | * Output generated by the sub-writer will be present in the 165 | * return value of drain(), after any output generated by this 166 | * writer itself. 167 | * 168 | * @note calling drain() on sub-writers results in undefined behavior! 169 | * @note using sub-writers after finish() has been called on this writer 170 | * results in undefined behavior! 171 | * 172 | * @return RdfWriter 173 | */ 174 | public function sub(); 175 | 176 | /** 177 | * Returns the MIME type of the RDF serialization the writer produces. 178 | * 179 | * @return string a MIME type 180 | */ 181 | public function getMimeType(); 182 | 183 | } 184 | -------------------------------------------------------------------------------- /src/RdfWriterBase.php: -------------------------------------------------------------------------------- 1 | role = $role; 103 | $this->labeler = $labeler ?: new BNodeLabeler(); 104 | 105 | $this->registerShorthand( 'a', 'rdf', 'type' ); 106 | 107 | $this->prefix( 'rdf', 'http://www.w3.org/1999/02/22-rdf-syntax-ns#' ); 108 | $this->prefix( 'xsd', 'http://www.w3.org/2001/XMLSchema#' ); 109 | } 110 | 111 | /** 112 | * @param string $role 113 | * @param BNodeLabeler $labeler 114 | * 115 | * @return RdfWriterBase 116 | */ 117 | abstract protected function newSubWriter( $role, BNodeLabeler $labeler ); 118 | 119 | /** 120 | * Registers a shorthand that can be used instead of a qname, 121 | * like 'a' can be used instead of 'rdf:type'. 122 | * 123 | * @param string $shorthand 124 | * @param string $prefix 125 | * @param string $local 126 | */ 127 | protected function registerShorthand( $shorthand, $prefix, $local ) { 128 | $this->shorthands[$shorthand] = [ $prefix, $local ]; 129 | } 130 | 131 | /** 132 | * Registers a prefix 133 | * 134 | * @param string $prefix 135 | * @param string $iri The base IRI 136 | * 137 | * @throws LogicException 138 | */ 139 | public function prefix( $prefix, $iri ) { 140 | if ( $this->prefixesLocked ) { 141 | throw new LogicException( 'Prefixes can not be added after start()' ); 142 | } 143 | 144 | $this->prefixes[$prefix] = $iri; 145 | } 146 | 147 | /** 148 | * Determines whether $shorthand can be used as a shorthand. 149 | * 150 | * @param string $shorthand 151 | * 152 | * @return bool 153 | */ 154 | protected function isShorthand( $shorthand ) { 155 | return isset( $this->shorthands[$shorthand] ); 156 | } 157 | 158 | /** 159 | * Determines whether $shorthand can legally be used as a prefix. 160 | * 161 | * @param string $prefix 162 | * 163 | * @return bool 164 | */ 165 | protected function isPrefix( $prefix ) { 166 | return isset( $this->prefixes[$prefix] ); 167 | } 168 | 169 | /** 170 | * Returns the prefix map. 171 | * 172 | * @return string[] An associative array mapping prefixes to base IRIs. 173 | */ 174 | public function getPrefixes() { 175 | return $this->prefixes; 176 | } 177 | 178 | /** 179 | * @param string|null $languageCode 180 | * 181 | * @return bool 182 | */ 183 | protected function isValidLanguageCode( $languageCode ) { 184 | // preg_match is somewhat (12%) slower than strspn but more readable 185 | return $languageCode !== null && preg_match( '/^[\da-z-]{2,}$/i', $languageCode ); 186 | } 187 | 188 | /** 189 | * @return RdfWriter 190 | */ 191 | final public function sub() { 192 | $writer = $this->newSubWriter( self::SUBDOCUMENT_ROLE, $this->labeler ); 193 | $writer->state = self::STATE_DOCUMENT; 194 | 195 | // share registered prefixes 196 | $writer->prefixes =& $this->prefixes; 197 | 198 | $this->subs[] = $writer; 199 | return $writer; 200 | } 201 | 202 | /** 203 | * @return string A string corresponding to one of the the XXX_ROLE constants. 204 | */ 205 | final public function getRole() { 206 | return $this->role; 207 | } 208 | 209 | /** 210 | * Appends string to the output buffer. 211 | * @param string $w 212 | */ 213 | final protected function write( $w ) { 214 | $this->buffer[] = $w; 215 | } 216 | 217 | /** 218 | * If $base is a shorthand, $base and $local are updated to hold whatever qname 219 | * the shorthand was associated with. 220 | * 221 | * Otherwise, $base and $local remain unchanged. 222 | * 223 | * @param string &$base 224 | * @param string|null &$local 225 | */ 226 | protected function expandShorthand( &$base, &$local ) { 227 | if ( $local === null && isset( $this->shorthands[$base] ) ) { 228 | [ $base, $local ] = $this->shorthands[$base]; 229 | } 230 | } 231 | 232 | /** 233 | * If $base is a registered prefix, $base will be replaced by the base IRI associated with 234 | * that prefix, with $local appended. $local will be set to null. 235 | * 236 | * Otherwise, $base and $local remain unchanged. 237 | * 238 | * @param string &$base 239 | * @param string|null &$local 240 | * 241 | * @throws LogicException 242 | */ 243 | protected function expandQName( &$base, &$local ) { 244 | if ( $local !== null && $base !== '_' ) { 245 | if ( isset( $this->prefixes[$base] ) ) { 246 | $base = $this->prefixes[$base] . $local; // XXX: can we avoid this concat? 247 | $local = null; 248 | } else { 249 | throw new LogicException( 'Unknown prefix: ' . $base ); 250 | } 251 | } 252 | } 253 | 254 | /** 255 | * @see RdfWriter::blank() 256 | * 257 | * @param string|null $label node label; will be generated if not given. 258 | * 259 | * @return string 260 | */ 261 | final public function blank( $label = null ) { 262 | return $this->labeler->getLabel( $label ); 263 | } 264 | 265 | /** 266 | * @see RdfWriter::start() 267 | */ 268 | final public function start() { 269 | $this->state( self::STATE_DOCUMENT ); 270 | $this->prefixesLocked = true; 271 | } 272 | 273 | /** 274 | * @see RdfWriter::finish() 275 | */ 276 | final public function finish() { 277 | // close all unclosed states 278 | $this->state( self::STATE_DOCUMENT ); 279 | 280 | // ...then insert output of sub-writers into the buffer, 281 | // so it gets placed before the footer... 282 | $this->drainSubs(); 283 | 284 | // and then finalize 285 | $this->state( self::STATE_FINISH ); 286 | 287 | // Detaches all subs. 288 | $this->subs = []; 289 | } 290 | 291 | /** 292 | * @see RdfWriter::drain() 293 | * 294 | * @return string RDF 295 | */ 296 | final public function drain() { 297 | // we can drain after finish, but finish state is sticky 298 | if ( $this->state !== self::STATE_FINISH ) { 299 | $this->state( self::STATE_DOCUMENT ); 300 | } 301 | 302 | $this->drainSubs(); 303 | $this->flattenBuffer(); 304 | 305 | $rdf = implode( '', $this->buffer ); 306 | $this->buffer = []; 307 | 308 | return $rdf; 309 | } 310 | 311 | /** 312 | * Calls drain() an any RdfWriter instances in $this->buffer, and replaces them 313 | * in $this->buffer with the string returned by the drain() call. Any closures 314 | * present in the $this->buffer will be called, and replaced by their return value. 315 | */ 316 | private function flattenBuffer() { 317 | foreach ( $this->buffer as &$b ) { 318 | if ( $b instanceof Closure ) { 319 | $b = $b(); 320 | } 321 | if ( $b instanceof RdfWriter ) { 322 | $b = $b->drain(); 323 | } 324 | } 325 | } 326 | 327 | /** 328 | * Drains all subwriters, and appends their output to this writer's buffer. 329 | * Subwriters remain usable. 330 | */ 331 | private function drainSubs() { 332 | foreach ( $this->subs as $sub ) { 333 | $rdf = $sub->drain(); 334 | $this->write( $rdf ); 335 | } 336 | } 337 | 338 | /** 339 | * @see RdfWriter::about() 340 | * 341 | * @param string $base A QName prefix if $local is given, or an IRI if $local is null. 342 | * @param string|null $local A QName suffix, or null if $base is an IRI. 343 | * 344 | * @return RdfWriter $this 345 | */ 346 | final public function about( $base, $local = null ) { 347 | $this->expandSubject( $base, $local ); 348 | 349 | if ( $this->state === self::STATE_OBJECT 350 | && $base === $this->currentSubject[0] 351 | && $local === $this->currentSubject[1] 352 | ) { 353 | return $this; // redundant about() call 354 | } 355 | 356 | $this->state( self::STATE_SUBJECT ); 357 | 358 | $this->currentSubject[0] = $base; 359 | $this->currentSubject[1] = $local; 360 | $this->currentPredicate[0] = null; 361 | $this->currentPredicate[1] = null; 362 | 363 | $this->writeSubject( $base, $local ); 364 | return $this; 365 | } 366 | 367 | /** 368 | * @see RdfWriter::a() 369 | * Shorthand for say( 'a' )->is( $type ). 370 | * 371 | * @param string $typeBase The data type's QName prefix if $typeLocal is given, 372 | * or an IRI or shorthand if $typeLocal is null. 373 | * @param string|null $typeLocal The data type's QName suffix, 374 | * or null if $typeBase is an IRI or shorthand. 375 | * 376 | * @return RdfWriter $this 377 | */ 378 | final public function a( $typeBase, $typeLocal = null ) { 379 | return $this->say( 'a' )->is( $typeBase, $typeLocal ); 380 | } 381 | 382 | /** 383 | * @see RdfWriter::say() 384 | * 385 | * @param string $base A QName prefix. 386 | * @param string|null $local A QName suffix. 387 | * 388 | * @return RdfWriter $this 389 | */ 390 | final public function say( $base, $local = null ) { 391 | $this->expandPredicate( $base, $local ); 392 | 393 | if ( $this->state === self::STATE_OBJECT 394 | && $base === $this->currentPredicate[0] 395 | && $local === $this->currentPredicate[1] 396 | ) { 397 | return $this; // redundant about() call 398 | } 399 | 400 | $this->state( self::STATE_PREDICATE ); 401 | 402 | $this->currentPredicate[0] = $base; 403 | $this->currentPredicate[1] = $local; 404 | 405 | $this->writePredicate( $base, $local ); 406 | return $this; 407 | } 408 | 409 | /** 410 | * @see RdfWriter::is() 411 | * 412 | * @param string $base A QName prefix if $local is given, or an IRI if $local is null. 413 | * @param string|null $local A QName suffix, or null if $base is an IRI. 414 | * 415 | * @return RdfWriter $this 416 | */ 417 | final public function is( $base, $local = null ) { 418 | $this->state( self::STATE_OBJECT ); 419 | 420 | $this->expandResource( $base, $local ); 421 | $this->writeResource( $base, $local ); 422 | return $this; 423 | } 424 | 425 | /** 426 | * @see RdfWriter::text() 427 | * 428 | * @param string $text the text to be placed in the output 429 | * @param string|null $language the language the text is in 430 | * 431 | * @return $this 432 | */ 433 | final public function text( $text, $language = null ) { 434 | $this->state( self::STATE_OBJECT ); 435 | 436 | $this->writeText( $text, $language ); 437 | return $this; 438 | } 439 | 440 | /** 441 | * @see RdfWriter::value() 442 | * 443 | * @param string $value the value encoded as a string 444 | * @param string|null $typeBase The data type's QName prefix if $typeLocal is given, 445 | * or an IRI or shorthand if $typeLocal is null. 446 | * @param string|null $typeLocal The data type's QName suffix, 447 | * or null if $typeBase is an IRI or shorthand. 448 | * 449 | * @return $this 450 | */ 451 | final public function value( $value, $typeBase = null, $typeLocal = null ) { 452 | $this->state( self::STATE_OBJECT ); 453 | 454 | if ( $typeBase === null && !is_string( $value ) ) { 455 | $vtype = gettype( $value ); 456 | switch ( $vtype ) { 457 | case 'integer': 458 | $typeBase = 'xsd'; 459 | $typeLocal = 'integer'; 460 | $value = "$value"; 461 | break; 462 | 463 | case 'double': 464 | $typeBase = 'xsd'; 465 | $typeLocal = 'double'; 466 | $value = "$value"; 467 | break; 468 | 469 | case 'boolean': 470 | $typeBase = 'xsd'; 471 | $typeLocal = 'boolean'; 472 | $value = $value ? 'true' : 'false'; 473 | break; 474 | } 475 | } 476 | 477 | $this->expandType( $typeBase, $typeLocal ); 478 | 479 | $this->writeValue( $value, $typeBase, $typeLocal ); 480 | return $this; 481 | } 482 | 483 | /** 484 | * State transition table 485 | * First state is "from", second is "to" 486 | * @var array 487 | */ 488 | protected $transitionTable = [ 489 | self::STATE_START => [ 490 | self::STATE_DOCUMENT => true, 491 | ], 492 | self::STATE_DOCUMENT => [ 493 | self::STATE_DOCUMENT => true, 494 | self::STATE_SUBJECT => true, 495 | self::STATE_FINISH => true, 496 | ], 497 | self::STATE_SUBJECT => [ 498 | self::STATE_PREDICATE => true, 499 | ], 500 | self::STATE_PREDICATE => [ 501 | self::STATE_OBJECT => true, 502 | ], 503 | self::STATE_OBJECT => [ 504 | self::STATE_DOCUMENT => true, 505 | self::STATE_SUBJECT => true, 506 | self::STATE_PREDICATE => true, 507 | self::STATE_OBJECT => true, 508 | ], 509 | ]; 510 | 511 | /** 512 | * Perform a state transition. Writer states roughly correspond to states in a naive 513 | * regular parser for the respective syntax. State transitions may generate output, 514 | * particularly of structural elements which correspond to terminals in a respective 515 | * parser. 516 | * 517 | * @param int $newState one of the self::STATE_... constants 518 | * 519 | * @throws LogicException 520 | */ 521 | final protected function state( $newState ) { 522 | if ( !isset( $this->transitionTable[$this->state][$newState] ) ) { 523 | throw new LogicException( 'Bad transition: ' . $this->state . ' -> ' . $newState ); 524 | } 525 | 526 | $action = $this->transitionTable[$this->state][$newState]; 527 | if ( $action !== true ) { 528 | if ( is_string( $action ) ) { 529 | $this->write( $action ); 530 | } else { 531 | $action(); 532 | } 533 | } 534 | 535 | $this->state = $newState; 536 | } 537 | 538 | /** 539 | * Must be implemented to generate output that starts a statement (or set of statements) 540 | * about a subject. Depending on the requirements of the output format, the implementation 541 | * may be empty. 542 | * 543 | * @note $base and $local are given as passed to about() and processed by expandSubject(). 544 | * 545 | * @param string $base 546 | * @param string|null $local 547 | */ 548 | abstract protected function writeSubject( $base, $local = null ); 549 | 550 | /** 551 | * Must be implemented to generate output that represents the association of a predicate 552 | * with a subject that was previously defined by a call to writeSubject(). 553 | * 554 | * @note $base and $local are given as passed to say() and processed by expandPredicate(). 555 | * 556 | * @param string $base 557 | * @param string|null $local 558 | */ 559 | abstract protected function writePredicate( $base, $local = null ); 560 | 561 | /** 562 | * Must be implemented to generate output that represents a resource used as the object 563 | * of a statement. 564 | * 565 | * @note $base and $local are given as passed to is() and processed by expandObject(). 566 | * 567 | * @param string $base 568 | * @param string|null $local 569 | */ 570 | abstract protected function writeResource( $base, $local = null ); 571 | 572 | /** 573 | * Must be implemented to generate output that represents a text used as the object 574 | * of a statement. 575 | * 576 | * @param string $text the text to be placed in the output 577 | * @param string|null $language the language the text is in 578 | */ 579 | abstract protected function writeText( $text, $language ); 580 | 581 | /** 582 | * Must be implemented to generate output that represents a (typed) literal used as the object 583 | * of a statement. 584 | * 585 | * @note $typeBase and $typeLocal are given as passed to value() and processed by expandType(). 586 | * 587 | * @param string $value the value encoded as a string 588 | * @param string|null $typeBase 589 | * @param string|null $typeLocal 590 | */ 591 | abstract protected function writeValue( $value, $typeBase, $typeLocal = null ); 592 | 593 | /** 594 | * Perform any expansion (shorthand to qname, qname to IRI) desired 595 | * for subject identifiers. 596 | * 597 | * @param string &$base 598 | * @param string|null &$local 599 | */ 600 | protected function expandSubject( &$base, &$local ) { 601 | } 602 | 603 | /** 604 | * Perform any expansion (shorthand to qname, qname to IRI) desired 605 | * for predicate identifiers. 606 | * 607 | * @param string &$base 608 | * @param string|null &$local 609 | */ 610 | protected function expandPredicate( &$base, &$local ) { 611 | } 612 | 613 | /** 614 | * Perform any expansion (shorthand to qname, qname to IRI) desired 615 | * for resource identifiers. 616 | * 617 | * @param string &$base 618 | * @param string|null &$local 619 | */ 620 | protected function expandResource( &$base, &$local ) { 621 | } 622 | 623 | /** 624 | * Perform any expansion (shorthand to qname, qname to IRI) desired 625 | * for type identifiers. 626 | * 627 | * @param string|null &$base 628 | * @param string|null &$local 629 | */ 630 | protected function expandType( &$base, &$local ) { 631 | } 632 | 633 | } 634 | -------------------------------------------------------------------------------- /src/RdfWriterFactory.php: -------------------------------------------------------------------------------- 1 | trustIRIs; 23 | } 24 | 25 | /** 26 | * @param bool $trustIRIs 27 | */ 28 | public function setTrustIRIs( $trustIRIs ) { 29 | $this->trustIRIs = $trustIRIs; 30 | } 31 | 32 | /** 33 | * @param string $role 34 | * @param BNodeLabeler|null $labeler 35 | * @param N3Quoter|null $quoter 36 | */ 37 | public function __construct( 38 | $role = parent::DOCUMENT_ROLE, 39 | ?BNodeLabeler $labeler = null, 40 | ?N3Quoter $quoter = null 41 | ) { 42 | parent::__construct( $role, $labeler, $quoter ); 43 | $this->transitionTable[self::STATE_OBJECT] = [ 44 | self::STATE_DOCUMENT => " .\n", 45 | self::STATE_SUBJECT => " .\n\n", 46 | self::STATE_PREDICATE => " ;\n\t", 47 | self::STATE_OBJECT => ",\n\t\t", 48 | ]; 49 | $this->transitionTable[self::STATE_DOCUMENT][self::STATE_SUBJECT] = "\n"; 50 | $this->transitionTable[self::STATE_SUBJECT][self::STATE_PREDICATE] = ' '; 51 | $this->transitionTable[self::STATE_PREDICATE][self::STATE_OBJECT] = ' '; 52 | $this->transitionTable[self::STATE_START][self::STATE_DOCUMENT] = function () { 53 | $this->beginDocument(); 54 | }; 55 | } 56 | 57 | /** 58 | * Write prefixes 59 | */ 60 | private function beginDocument() { 61 | foreach ( $this->getPrefixes() as $prefix => $uri ) { 62 | $this->write( "@prefix $prefix: <" . $this->quoter->escapeIRI( $uri ) . "> .\n" ); 63 | } 64 | } 65 | 66 | /** 67 | * @inheritDoc 68 | */ 69 | protected function writeSubject( $base, $local = null ) { 70 | if ( $local !== null ) { 71 | $this->write( "$base:$local" ); 72 | } else { 73 | $this->writeIRI( $base, $this->trustIRIs ); 74 | } 75 | } 76 | 77 | /** 78 | * @inheritDoc 79 | */ 80 | protected function writePredicate( $base, $local = null ) { 81 | if ( $base === 'a' ) { 82 | $this->write( 'a' ); 83 | return; 84 | } 85 | if ( $local !== null ) { 86 | $this->write( "$base:$local" ); 87 | } else { 88 | $this->writeIRI( $base, $this->trustIRIs ); 89 | } 90 | } 91 | 92 | /** 93 | * @inheritDoc 94 | */ 95 | protected function writeResource( $base, $local = null ) { 96 | if ( $local !== null ) { 97 | $this->write( "$base:$local" ); 98 | } else { 99 | $this->writeIRI( $base ); 100 | } 101 | } 102 | 103 | /** 104 | * @param string $role 105 | * @param BNodeLabeler $labeler 106 | * 107 | * @return RdfWriterBase 108 | */ 109 | protected function newSubWriter( $role, BNodeLabeler $labeler ) { 110 | $writer = new self( $role, $labeler, $this->quoter ); 111 | 112 | return $writer; 113 | } 114 | 115 | /** 116 | * @return string a MIME type 117 | */ 118 | public function getMimeType() { 119 | return 'text/turtle; charset=UTF-8'; 120 | } 121 | 122 | } 123 | -------------------------------------------------------------------------------- /src/UnicodeEscaper.php: -------------------------------------------------------------------------------- 1 | escChars[$c] ) ) { 59 | $this->escChars[$c] = $this->escapedChar( $c ); 60 | } 61 | $result .= $this->escChars[$c]; 62 | } 63 | return $result; 64 | } 65 | 66 | /** 67 | * @param string $cUtf 68 | * 69 | * @return int 70 | */ 71 | private function unicodeCharNo( $cUtf ) { 72 | $bl = strlen( $cUtf ); /* binary length */ 73 | $r = 0; 74 | switch ( $bl ) { 75 | case 1: /* 0####### (0-127) */ 76 | $r = ord( $cUtf ); 77 | break; 78 | case 2: /* 110##### 10###### = 192+x 128+x */ 79 | $r = ( ( ord( $cUtf[0] ) - 192 ) * 64 ) + 80 | ( ord( $cUtf[1] ) - 128 ); 81 | break; 82 | case 3: /* 1110#### 10###### 10###### = 224+x 128+x 128+x */ 83 | $r = ( ( ord( $cUtf[0] ) - 224 ) * 4096 ) + 84 | ( ( ord( $cUtf[1] ) - 128 ) * 64 ) + 85 | ( ord( $cUtf[2] ) - 128 ); 86 | break; 87 | case 4: /* 1111#### 10###### 10###### 10###### = 240+x 128+x 128+x 128+x */ 88 | $r = ( ( ord( $cUtf[0] ) - 240 ) * 262144 ) + 89 | ( ( ord( $cUtf[1] ) - 128 ) * 4096 ) + 90 | ( ( ord( $cUtf[2] ) - 128 ) * 64 ) + 91 | ( ord( $cUtf[3] ) - 128 ); 92 | break; 93 | } 94 | return $r; 95 | } 96 | 97 | /** 98 | * @param string $c 99 | * 100 | * @return string 101 | */ 102 | private function escapedChar( $c ) { 103 | $no = $this->unicodeCharNo( $c ); 104 | /* see http://www.w3.org/TR/rdf-testcases/#ntrip_strings */ 105 | if ( $no < 9 ) { 106 | return '\u' . sprintf( '%04X', $no ); /* #x0-#x8 (0-8) */ 107 | } elseif ( $no == 9 ) { 108 | return '\t'; /* #x9 (9) */ 109 | } elseif ( $no == 10 ) { 110 | return '\n'; /* #xA (10) */ 111 | } elseif ( $no < 13 ) { 112 | return '\u' . sprintf( '%04X', $no ); /* #xB-#xC (11-12) */ 113 | } elseif ( $no == 13 ) { 114 | return '\r'; /* #xD (13) */ 115 | } elseif ( $no < 32 ) { 116 | return '\u' . sprintf( '%04X', $no ); /* #xE-#x1F (14-31) */ 117 | } elseif ( $no < 127 ) { 118 | return $c; /* #x20-#x7E (32-126) */ 119 | } elseif ( $no < 65536 ) { 120 | return '\u' . sprintf( '%04X', $no ); /* #x7F-#xFFFF (128-65535) */ 121 | } elseif ( $no < 1114112 ) { 122 | return '\U' . sprintf( '%08X', $no ); /* #x10000-#x10FFFF (65536-1114111) */ 123 | } else { 124 | return ''; /* not defined => ignore (also probably unreachable since PHP 8.3) */ 125 | } 126 | } 127 | 128 | } 129 | -------------------------------------------------------------------------------- /src/XmlRdfWriter.php: -------------------------------------------------------------------------------- 1 | transitionTable[self::STATE_START][self::STATE_DOCUMENT] = function () { 23 | $this->beginDocument(); 24 | }; 25 | $this->transitionTable[self::STATE_DOCUMENT][self::STATE_FINISH] = function () { 26 | $this->finishDocument(); 27 | }; 28 | $this->transitionTable[self::STATE_OBJECT][self::STATE_DOCUMENT] = function () { 29 | $this->finishSubject(); 30 | }; 31 | $this->transitionTable[self::STATE_OBJECT][self::STATE_SUBJECT] = function () { 32 | $this->finishSubject(); 33 | }; 34 | } 35 | 36 | /** 37 | * @param string $text 38 | * 39 | * @return string 40 | */ 41 | private function escape( $text ) { 42 | return htmlspecialchars( $text, ENT_QUOTES ); 43 | } 44 | 45 | /** 46 | * @inheritDoc 47 | */ 48 | protected function expandSubject( &$base, &$local ) { 49 | $this->expandQName( $base, $local ); 50 | } 51 | 52 | /** 53 | * @inheritDoc 54 | */ 55 | protected function expandPredicate( &$base, &$local ) { 56 | $this->expandShorthand( $base, $local ); 57 | } 58 | 59 | /** 60 | * @inheritDoc 61 | */ 62 | protected function expandResource( &$base, &$local ) { 63 | $this->expandQName( $base, $local ); 64 | } 65 | 66 | /** 67 | * @inheritDoc 68 | */ 69 | protected function expandType( &$base, &$local ) { 70 | $this->expandQName( $base, $local ); 71 | } 72 | 73 | /** 74 | * @param string $ns 75 | * @param string $name 76 | * @param string[] $attributes 77 | * @param string|null $content 78 | */ 79 | private function tag( $ns, $name, $attributes = [], $content = null ) { 80 | $sep = $ns === '' ? '' : ':'; 81 | $this->write( '<' . $ns . $sep . $name ); 82 | 83 | foreach ( $attributes as $attr => $value ) { 84 | if ( is_int( $attr ) ) { 85 | // positional array entries are passed verbatim, may be callbacks. 86 | $this->write( $value ); 87 | continue; 88 | } 89 | 90 | $this->write( " $attr=\"" . $this->escape( $value ) . '"' ); 91 | } 92 | 93 | if ( $content === null ) { 94 | $this->write( '>' ); 95 | } elseif ( $content === '' ) { 96 | $this->write( '/>' ); 97 | } else { 98 | $this->write( '>' . $content ); 99 | $this->close( $ns, $name ); 100 | } 101 | } 102 | 103 | /** 104 | * @param string $ns 105 | * @param string $name 106 | */ 107 | private function close( $ns, $name ) { 108 | $sep = $ns === '' ? '' : ':'; 109 | $this->write( '' ); 110 | } 111 | 112 | /** 113 | * Generates an attribute list, containing the attribute given by $name, or rdf:nodeID 114 | * if $target is a blank node id (starting with "_:"). If $target is a qname, an attempt 115 | * is made to resolve it into a full IRI based on the namespaces registered by calling 116 | * prefix(). 117 | * 118 | * @param string $name the attribute name (without the 'rdf:' prefix) 119 | * @param string|null $base 120 | * @param string|null $local 121 | * 122 | * @throws InvalidArgumentException 123 | * @return string[] 124 | */ 125 | private function getTargetAttributes( $name, $base, $local ) { 126 | if ( $base === null && $local === null ) { 127 | return []; 128 | } 129 | 130 | // handle blank 131 | if ( $base === '_' ) { 132 | $name = 'nodeID'; 133 | $value = $local; 134 | } elseif ( $local !== null ) { 135 | throw new InvalidArgumentException( "Expected IRI, got QName: $base:$local" ); 136 | } else { 137 | $value = $base; 138 | } 139 | 140 | return [ 141 | // @phan-suppress-next-line PhanTypeMismatchReturn 142 | "rdf:$name" => $value 143 | ]; 144 | } 145 | 146 | /** 147 | * Emit a document header. 148 | */ 149 | private function beginDocument() { 150 | $this->write( "\n" ); 151 | 152 | // define a callback for generating namespace attributes 153 | $namespaceAttrCallback = function () { 154 | $attr = ''; 155 | 156 | $namespaces = $this->getPrefixes(); 157 | foreach ( $namespaces as $ns => $uri ) { 158 | $escapedUri = htmlspecialchars( $uri, ENT_QUOTES ); 159 | $nss = $ns === '' ? '' : ":$ns"; 160 | $attr .= " xmlns$nss=\"$escapedUri\""; 161 | } 162 | 163 | return $attr; 164 | }; 165 | 166 | $this->tag( 'rdf', 'RDF', [ $namespaceAttrCallback ] ); 167 | $this->write( "\n" ); 168 | } 169 | 170 | /** 171 | * @param string $base 172 | * @param string|null $local 173 | */ 174 | protected function writeSubject( $base, $local = null ) { 175 | $attr = $this->getTargetAttributes( 'about', $base, $local ); 176 | 177 | $this->write( "\t" ); 178 | $this->tag( 'rdf', 'Description', $attr ); 179 | $this->write( "\n" ); 180 | } 181 | 182 | /** 183 | * Emit the root element 184 | */ 185 | private function finishSubject() { 186 | $this->write( "\t" ); 187 | $this->close( 'rdf', 'Description' ); 188 | $this->write( "\n" ); 189 | } 190 | 191 | /** 192 | * Write document footer 193 | */ 194 | private function finishDocument() { 195 | // close document element 196 | $this->close( 'rdf', 'RDF' ); 197 | $this->write( "\n" ); 198 | } 199 | 200 | /** 201 | * @param string $base 202 | * @param string|null $local 203 | */ 204 | protected function writePredicate( $base, $local = null ) { 205 | // noop 206 | } 207 | 208 | /** 209 | * @param string $base 210 | * @param string|null $local 211 | */ 212 | protected function writeResource( $base, $local = null ) { 213 | $attr = $this->getTargetAttributes( 'resource', $base, $local ); 214 | 215 | $this->write( "\t\t" ); 216 | $this->tag( $this->currentPredicate[0], $this->currentPredicate[1], $attr, '' ); 217 | $this->write( "\n" ); 218 | } 219 | 220 | /** 221 | * @param string $text 222 | * @param string|null $language 223 | */ 224 | protected function writeText( $text, $language = null ) { 225 | $attr = $this->isValidLanguageCode( $language ) 226 | ? [ 'xml:lang' => $language ] 227 | : []; 228 | 229 | $this->write( "\t\t" ); 230 | $this->tag( 231 | $this->currentPredicate[0], 232 | $this->currentPredicate[1], 233 | $attr, 234 | $this->escape( $text ) 235 | ); 236 | $this->write( "\n" ); 237 | } 238 | 239 | /** 240 | * @param string $literal 241 | * @param string|null $typeBase 242 | * @param string|null $typeLocal 243 | */ 244 | public function writeValue( $literal, $typeBase, $typeLocal = null ) { 245 | $attr = $this->getTargetAttributes( 'datatype', $typeBase, $typeLocal ); 246 | 247 | $this->write( "\t\t" ); 248 | $this->tag( 249 | $this->currentPredicate[0], 250 | $this->currentPredicate[1], 251 | $attr, 252 | $this->escape( $literal ) 253 | ); 254 | $this->write( "\n" ); 255 | } 256 | 257 | /** 258 | * @param string $role 259 | * @param BNodeLabeler $labeler 260 | * 261 | * @return RdfWriterBase 262 | */ 263 | protected function newSubWriter( $role, BNodeLabeler $labeler ) { 264 | $writer = new self( $role, $labeler ); 265 | 266 | return $writer; 267 | } 268 | 269 | /** 270 | * @return string a MIME type 271 | */ 272 | public function getMimeType() { 273 | return 'application/rdf+xml; charset=UTF-8'; 274 | } 275 | 276 | } 277 | --------------------------------------------------------------------------------