├── .editorconfig ├── .gitignore ├── .npmignore ├── .travis.yml ├── LICENSE ├── README.md ├── lib ├── Posterizer.js ├── Potrace.js ├── index.js ├── types │ ├── Bitmap.js │ ├── Curve.js │ ├── Histogram.js │ ├── Opti.js │ ├── Path.js │ ├── Point.js │ ├── Quad.js │ └── Sum.js └── utils.js ├── package.json └── test ├── example-output-posterized.svg ├── example-output.svg ├── reference-copies ├── output-posterized.svg ├── output.svg ├── posterized-bw-threshold-170.svg ├── posterized-clouds-white-40.svg ├── posterized-yao-black-threshold-128.svg ├── posterized-yao-black-threshold-170.svg ├── posterized-yao-black-threshold-65.svg ├── potrace-bw-black-threshold-0.svg ├── potrace-bw-black-threshold-255.svg ├── potrace-bw-threshold-128.svg ├── potrace-bw-threshold-170.svg ├── potrace-bw-threshold-65.svg ├── potrace-bw-white-threshold-0.svg ├── potrace-bw-white-threshold-255.svg ├── potrace-wb-black-threshold-0.svg ├── potrace-wb-black-threshold-255.svg ├── potrace-wb-threshold-128.svg ├── potrace-wb-white-threshold-0.svg └── potrace-wb-white-threshold-255.svg ├── sources ├── Lenna.png ├── clouds.jpg ├── white-on-black.png └── yao.jpg └── test.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = false 6 | indent_style = space 7 | indent_size = 2 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 18 | .grunt 19 | 20 | # node-waf configuration 21 | .lock-wscript 22 | 23 | # Compiled binary addons (http://nodejs.org/api/addons.html) 24 | build/Release 25 | 26 | # Dependency directory 27 | node_modules 28 | 29 | # Optional npm cache directory 30 | .npm 31 | 32 | # Optional REPL history 33 | .node_repl_history 34 | 35 | # JetBrains IDEs 36 | .idea 37 | 38 | # Test case output 39 | test/output*.svg -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # IDE files 30 | .project 31 | .idea 32 | *.iml 33 | 34 | # Test directory 35 | test/ 36 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "0.12" 4 | - "4" 5 | - "6" 6 | sudo: false -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | {description} 294 | Copyright (C) {year} {fullname} 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | {signature of Ty Coon}, 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. 340 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # node-potrace 2 | A NodeJS-compatible fork of [Potrace in JavaScript][potrace-by-kilobtye] with some additions, which is in turn a port of [the original Potrace][potrace] — a tool for tracing bitmaps. 3 | 4 | ## Example and demo 5 | 6 | | **Original image** | **Potrace output** | **Posterized output** | 7 | |---------------------------|------------------------------|-----------------------------------------| 8 | | ![](test/sources/yao.jpg) | ![](https://cdn.rawgit.com/tooolbox/node-potrace/9ee822d/test/example-output.svg) | ![](https://cdn.rawgit.com/tooolbox/node-potrace/9ee822d/test/example-output-posterized.svg) | 9 | 10 | (Example image inherited from [online demo of the browser version][potrace-js-demo]) 11 | 12 | ## Usage 13 | 14 | Install 15 | 16 | ```sh 17 | npm install potrace 18 | ``` 19 | 20 | Basic usage 21 | 22 | ```js 23 | var potrace = require('potrace'), 24 | fs = require('fs'); 25 | 26 | potrace.trace('./path/to/image.png', function(err, svg) { 27 | if (err) throw err; 28 | fs.writeFileSync('./output.svg', svg); 29 | }); 30 | ``` 31 | 32 | You can also provide a configuration object as a second argument. 33 | 34 | ```js 35 | var params = { 36 | background: '#49ffd2', 37 | color: 'blue', 38 | threshold: 120 39 | }; 40 | 41 | potrace.trace('./path/to/image.png', params, function(err, svg) { 42 | /*...*/ 43 | }); 44 | ``` 45 | 46 | If you want to run Potrace algorithm multiple times on the same image with different threshold setting and merge results together in a single file - `posterize` method does exactly that. 47 | 48 | ```js 49 | potrace.posterize('./path/to/image.png', { threshold: 180, steps: 4 }, function(err, svg) { 50 | /*...*/ 51 | }); 52 | 53 | // or if you know exactly where you want to break it on different levels 54 | 55 | potrace.posterize('./path/to/image.png', { steps: [40, 85, 135, 180] }, function(err, svg) { 56 | /*...*/ 57 | }); 58 | ``` 59 | 60 | ### Advanced usage and configuration 61 | 62 | Both `trace` and `posterize` methods return instances of `Potrace` and `Posterizer` classes respectively to a callback function as third argument. 63 | 64 | You can also instantiate these classes directly: 65 | 66 | ```js 67 | var potrace = require('potrace'); 68 | 69 | // Tracing 70 | 71 | var trace = new potrace.Potrace(); 72 | 73 | // You can also pass configuration object to the constructor 74 | trace.setParameters({ 75 | threshold: 128, 76 | color: '#880000' 77 | }); 78 | 79 | trace.loadImage('path/to/image.png', function(err) { 80 | if (err) throw err; 81 | 82 | trace.getSVG(); // returns SVG document contents 83 | trace.getPathTag(); // will return just tag 84 | trace.getSymbol('traced-image'); // will return tag with given ID 85 | }); 86 | 87 | // Posterization 88 | 89 | var posterizer = new potrace.Posterize(); 90 | 91 | posterizer.loadImage('path/to/image.png', function(err) { 92 | if (err) throw err; 93 | 94 | posterizer.setParameter({ 95 | color: '#ccc', 96 | background: '#222', 97 | steps: 3, 98 | threshold: 200, 99 | fillStrategy: potrace.Posterize.FILL_MEAN 100 | }); 101 | 102 | posterizer.getSVG(); 103 | // or 104 | posterizer.getSymbol('posterized-image'); 105 | }); 106 | ``` 107 | 108 | Callback function provided to `loadImage` methods will be executed in context of the `Potrace`/`Posterizer` instance, so if it doesn't go against your code style - you can just do 109 | 110 | ```js 111 | new potrace.Potrace() 112 | .loadImage('path/to/image.bmp', function() { 113 | if (err) throw err; 114 | this.getSymbol('foo'); 115 | }); 116 | ``` 117 | 118 | [Jimp module][jimp] is used on the back end, so first argument accepted by `loadImage` method could be anything Jimp can read: a `Buffer`, local path or a url string. Supported formats are: PNG, JPEG or BMP. It also could be a Jimp instance (provided bitmap is not modified) 119 | 120 | ### Parameters 121 | 122 | `Potrace` class expects following parameters: 123 | 124 | - **turnPolicy** - how to resolve ambiguities in path decomposition. Possible values are exported as constants: `TURNPOLICY_BLACK`, `TURNPOLICY_WHITE`, `TURNPOLICY_LEFT`, `TURNPOLICY_RIGHT`, `TURNPOLICY_MINORITY`, `TURNPOLICY_MAJORITY`. Refer to [this document][potrace-algorithm] for more information (page 4) 125 | (default: `TURNPOLICY_MINORITY`) 126 | - **turdSize** - suppress speckles of up to this size 127 | (default: 2) 128 | - **alphaMax** - corner threshold parameter 129 | (default: 1) 130 | - **optCurve** - curve optimization 131 | (default: true) 132 | - **optTolerance** - curve optimization tolerance 133 | (default: 0.2) 134 | - **threshold** - threshold below which color is considered black. 135 | Should be a number in range 0..255 or `THRESHOLD_AUTO` in which case threshold will be selected automatically using [Algorithm For Multilevel Thresholding][multilevel-thresholding] 136 | (default: `THRESHOLD_AUTO`) 137 | - **blackOnWhite** - specifies colors by which side from threshold should be turned into vector shape 138 | (default: `true`) 139 | - **color** - Fill color. Will be ignored when exporting as \. (default: `COLOR_AUTO`, which means black or white, depending on `blackOnWhite` property) 140 | - **background** - Background color. Will be ignored when exporting as \. By default is not present (`COLOR_TRANSPARENT`) 141 | 142 | --------------- 143 | 144 | `Posterizer` class has same methods as `Potrace`, in exception of `.getPathTag()`. 145 | Configuration object is extended with following properties: 146 | 147 | - **fillStrategy** - determines how fill color for each layer should be selected. Possible values are exported as constants: 148 | - `FILL_DOMINANT` - most frequent color in range (used by default), 149 | - `FILL_MEAN` - arithmetic mean (average), 150 | - `FILL_MEDIAN` - median color, 151 | - `FILL_SPREAD` - ignores color information of the image and just spreads colors equally in range 0..\ (or \..255 if `blackOnWhite` is set to `false`), 152 | - **rangeDistribution** - how color stops for each layer should be selected. Ignored if `steps` is an array. Possible values are: 153 | - `RANGES_AUTO` - Performs automatic thresholding (using [Algorithm For Multilevel Thresholding][multilevel-thresholding]). Preferable method for already posterized sources, but takes long time to calculate 5 or more thresholds (exponential time complexity) 154 | *(used by default)* 155 | - `RANGES_EQUAL` - Ignores color information of the image and breaks available color space into equal chunks 156 | - **steps** - Specifies desired number of layers in resulting image. If a number provided - thresholds for each layer will be automatically calculated according to `rangeDistribution` parameter. If an array provided it expected to be an array with precomputed thresholds for each layer (in range 0..255) 157 | (default: `STEPS_AUTO` which will result in `3` or `4`, depending on `threshold` value) 158 | - **threshold** - Breaks image into foreground and background (and only foreground being broken into desired number of layers). Basically when provided it becomes a threshold for last (least opaque) layer and then `steps - 1` intermediate thresholds calculated. If **steps** is an array of thresholds and every value from the array is lower (or larger if **blackOnWhite** parameter set to `false`) than threshold - threshold will be added to the array, otherwise just ignored. 159 | (default: `Potrace.THRESHOLD_AUTO`) 160 | - *all other parameters that Potrace class accepts* 161 | 162 | **Notes:** 163 | 164 | - When number of `steps` is greater than 10 - an extra layer could be added to ensure presence of darkest/brightest colors if needed to ensure presence of probably-important-at-this-point details like shadows or line art. 165 | - With big number of layers produced image will be looking brighter overall than original due to math error at the rendering phase because of how layers are composited. 166 | - With default configuration `steps`, `threshold` and `rangeDistribution` settings all set to auto, resulting in a 4 thresholds/color stops being calculated with Multilevel Thresholding algorithm mentioned above. Calculation of 4 thresholds takes 3-5 seconds on average laptop. You may want to explicitly limit number of `steps` to 3 to moderately improve processing speed. 167 | 168 | ## Thanks to 169 | 170 | - Peter Selinger for [original Potrace tool and algorithm][potrace] 171 | - @kilobtye for original [javascript port][potrace-by-kilobtye] 172 | 173 | ## License 174 | 175 | The GNU General Public License version 2 (GPLv2). Please see [License File](LICENSE) for more information. 176 | 177 | [potrace]: http://potrace.sourceforge.net/ 178 | [potrace-algorithm]: http://potrace.sourceforge.net/potrace.pdf 179 | [multilevel-thresholding]: http://www.iis.sinica.edu.tw/page/jise/2001/200109_01.pdf 180 | [potrace-by-kilobtye]: https://github.com/kilobtye/potrace 181 | [potrace-js-demo]: http://kilobtye.github.io/potrace/ 182 | [jimp]: https://github.com/oliver-moran/jimp -------------------------------------------------------------------------------- /lib/Posterizer.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Potrace = require('./Potrace'); 4 | var utils = require('./utils'); 5 | 6 | /** 7 | * Takes multiple samples using {@link Potrace} with different threshold 8 | * settings and combines output into a single file. 9 | * 10 | * @param {Posterizer~Options} [options] 11 | * @constructor 12 | */ 13 | function Posterizer(options) { 14 | this._potrace = new Potrace(); 15 | 16 | this._calculatedThreshold = null; 17 | 18 | this._params = { 19 | threshold: Potrace.THRESHOLD_AUTO, 20 | blackOnWhite: true, 21 | steps: Posterizer.STEPS_AUTO, 22 | background: Potrace.COLOR_TRANSPARENT, 23 | fillStrategy: Posterizer.FILL_DOMINANT, 24 | rangeDistribution: Posterizer.RANGES_AUTO 25 | }; 26 | 27 | if (options) { 28 | this.setParameters(options); 29 | } 30 | } 31 | 32 | // Inherit constants from Potrace class 33 | for (var key in Potrace) { 34 | if (Object.prototype.hasOwnProperty.call(Potrace, key) && key === key.toUpperCase()) { 35 | Posterizer[key] = Potrace[key]; 36 | } 37 | } 38 | 39 | Posterizer.STEPS_AUTO = -1; 40 | Posterizer.FILL_SPREAD = 'spread'; 41 | Posterizer.FILL_DOMINANT = 'dominant'; 42 | Posterizer.FILL_MEDIAN = 'median'; 43 | Posterizer.FILL_MEAN = 'mean'; 44 | 45 | Posterizer.RANGES_AUTO = 'auto'; 46 | Posterizer.RANGES_EQUAL = 'equal'; 47 | 48 | Posterizer.prototype = { 49 | /** 50 | * Fine tuning to color ranges. 51 | * 52 | * If last range (featuring most saturated color) is larger than 10% of color space (25 units) 53 | * then we want to add another color stop, that hopefully will include darkest pixels, improving presence of 54 | * shadows and line art 55 | * 56 | * @param ranges 57 | * @private 58 | */ 59 | _addExtraColorStop: function(ranges) { 60 | var blackOnWhite = this._params.blackOnWhite; 61 | var lastColorStop = ranges[ranges.length - 1]; 62 | var lastRangeFrom = blackOnWhite ? 0 : lastColorStop.value; 63 | var lastRangeTo = blackOnWhite ? lastColorStop.value : 255; 64 | 65 | if (lastRangeTo - lastRangeFrom > 25 && lastColorStop.colorIntensity !== 1) { 66 | var histogram = this._getImageHistogram(); 67 | var levels = histogram.getStats(lastRangeFrom, lastRangeTo).levels; 68 | 69 | var newColorStop = levels.mean + levels.stdDev <= 25 ? levels.mean + levels.stdDev 70 | : levels.mean - levels.stdDev <= 25 ? levels.mean - levels.stdDev 71 | : 25; 72 | 73 | var newStats = (blackOnWhite ? histogram.getStats(0, newColorStop) : histogram.getStats(newColorStop, 255)); 74 | var color = newStats.levels.mean; 75 | 76 | ranges.push({ 77 | value: Math.abs((blackOnWhite ? 0 : 255) - newColorStop), 78 | colorIntensity: isNaN(color) ? 0 : ((blackOnWhite ? 255 - color : color) / 255) 79 | }); 80 | } 81 | 82 | return ranges; 83 | }, 84 | 85 | 86 | /** 87 | * Calculates color intensity for each element of numeric array 88 | * 89 | * @param {number[]} colorStops 90 | * @returns {{ levels: number, colorIntensity: number }[]} 91 | * @private 92 | */ 93 | _calcColorIntensity: function(colorStops) { 94 | var blackOnWhite = this._params.blackOnWhite; 95 | var colorSelectionStrat = this._params.fillStrategy; 96 | var histogram = colorSelectionStrat !== Posterizer.FILL_SPREAD ? this._getImageHistogram() : null; 97 | var fullRange = Math.abs(this._paramThreshold() - (blackOnWhite ? 0 : 255)); 98 | 99 | return colorStops.map(function(threshold, index) { 100 | var nextValue = index + 1 === colorStops.length ? (blackOnWhite ? -1 : 256) : colorStops[index + 1]; 101 | var rangeStart = Math.round(blackOnWhite ? nextValue + 1 : threshold); 102 | var rangeEnd = Math.round(blackOnWhite ? threshold : nextValue - 1); 103 | var factor = index / (colorStops.length - 1); 104 | var intervalSize = rangeEnd - rangeStart; 105 | var stats = histogram.getStats(rangeStart, rangeEnd); 106 | var color = -1; 107 | 108 | if (stats.pixels === 0) { 109 | return { 110 | value: threshold, 111 | colorIntensity: 0 112 | }; 113 | } 114 | 115 | switch (colorSelectionStrat) { 116 | case Posterizer.FILL_SPREAD: 117 | // We want it to be 0 (255 when white on black) at the most saturated end, so... 118 | color = (blackOnWhite ? rangeStart : rangeEnd) 119 | + (blackOnWhite ? 1 : -1) * intervalSize * Math.max(0.5, fullRange / 255) * factor; 120 | break; 121 | case Posterizer.FILL_DOMINANT: 122 | color = histogram.getDominantColor(rangeStart, rangeEnd, utils.clamp(intervalSize, 1, 5)); 123 | break; 124 | case Posterizer.FILL_MEAN: 125 | color = stats.levels.mean; 126 | break; 127 | case Posterizer.FILL_MEDIAN: 128 | color = stats.levels.median; 129 | break; 130 | } 131 | 132 | // We don't want colors to be too close to each other, so we introduce some spacing in between 133 | if (index !== 0) { 134 | color = blackOnWhite 135 | ? utils.clamp(color, rangeStart, rangeEnd - Math.round(intervalSize * 0.1)) 136 | : utils.clamp(color, rangeStart + Math.round(intervalSize * 0.1), rangeEnd); 137 | } 138 | 139 | return { 140 | value: threshold, 141 | colorIntensity: color === -1 ? 0 : ((blackOnWhite ? 255 - color : color) / 255) 142 | }; 143 | }); 144 | }, 145 | 146 | /** 147 | * @returns {Histogram} 148 | * @private 149 | */ 150 | _getImageHistogram: function() { 151 | return this._potrace._luminanceData.histogram(); 152 | }, 153 | 154 | /** 155 | * Processes threshold, steps and rangeDistribution parameters and returns normalized array of color stops 156 | * @returns {*} 157 | * @private 158 | */ 159 | _getRanges: function() { 160 | var steps = this._paramSteps(); 161 | 162 | if (!Array.isArray(steps)) { 163 | return this._params.rangeDistribution === Posterizer.RANGES_AUTO 164 | ? this._getRangesAuto() 165 | : this._getRangesEquallyDistributed(); 166 | } 167 | 168 | // Steps is array of thresholds and we want to preprocess it 169 | 170 | var colorStops = []; 171 | var threshold = this._paramThreshold(); 172 | var lookingForDarkPixels = this._params.blackOnWhite; 173 | 174 | steps.forEach(function(item) { 175 | if (colorStops.indexOf(item) === -1 && utils.between(item, 0, 255)) { 176 | colorStops.push(item); 177 | } 178 | }); 179 | 180 | if (!colorStops.length) { 181 | colorStops.push(threshold); 182 | } 183 | 184 | colorStops = colorStops.sort(function (a, b) { 185 | return a < b === lookingForDarkPixels ? 1 : -1; 186 | }); 187 | 188 | if (lookingForDarkPixels && colorStops[0] < threshold) { 189 | colorStops.unshift(threshold); 190 | } else if (!lookingForDarkPixels && colorStops[colorStops.length - 1] < threshold) { 191 | colorStops.push(threshold); 192 | } 193 | 194 | return this._calcColorIntensity(colorStops); 195 | }, 196 | 197 | /** 198 | * Calculates given (or lower) number of thresholds using automatic thresholding algorithm 199 | * @returns {*} 200 | * @private 201 | */ 202 | _getRangesAuto: function() { 203 | var histogram = this._getImageHistogram(); 204 | var steps = this._paramSteps(true); 205 | var colorStops; 206 | 207 | if (this._params.threshold === Potrace.THRESHOLD_AUTO) { 208 | colorStops = histogram.multilevelThresholding(steps); 209 | } else { 210 | var threshold = this._paramThreshold(); 211 | 212 | colorStops = this._params.blackOnWhite 213 | ? histogram.multilevelThresholding(steps - 1, 0, threshold) 214 | : histogram.multilevelThresholding(steps - 1, threshold, 255); 215 | 216 | if (this._params.blackOnWhite) { 217 | colorStops.push(threshold); 218 | } else { 219 | colorStops.unshift(threshold); 220 | } 221 | } 222 | 223 | if (this._params.blackOnWhite) { 224 | colorStops = colorStops.reverse(); 225 | } 226 | 227 | return this._calcColorIntensity(colorStops); 228 | }, 229 | 230 | /** 231 | * Calculates color stops and color representing each segment, returning them 232 | * from least to most intense color (black or white, depending on blackOnWhite parameter) 233 | * 234 | * @private 235 | */ 236 | _getRangesEquallyDistributed: function() { 237 | var blackOnWhite = this._params.blackOnWhite; 238 | var colorsToThreshold = blackOnWhite ? this._paramThreshold() : 255 - this._paramThreshold(); 239 | var steps = this._paramSteps(); 240 | 241 | var stepSize = colorsToThreshold / steps; 242 | var colorStops = []; 243 | var i = steps - 1, 244 | factor, 245 | threshold; 246 | 247 | while (i >= 0) { 248 | factor = i / (steps - 1); 249 | threshold = Math.min(colorsToThreshold, (i + 1) * stepSize); 250 | threshold = blackOnWhite ? threshold : 255 - threshold; 251 | i--; 252 | 253 | colorStops.push(threshold); 254 | } 255 | 256 | return this._calcColorIntensity(colorStops); 257 | }, 258 | 259 | /** 260 | * Returns valid steps value 261 | * @param {Boolean} [count=false] 262 | * @returns {number|number[]} 263 | * @private 264 | */ 265 | _paramSteps: function(count) { 266 | var steps = this._params.steps; 267 | 268 | if (Array.isArray(steps)) { 269 | return count ? steps.length : steps; 270 | } 271 | 272 | if (steps === Posterizer.STEPS_AUTO && this._params.threshold === Potrace.THRESHOLD_AUTO) { 273 | return 4; 274 | } 275 | 276 | var blackOnWhite = this._params.blackOnWhite; 277 | var colorsCount = blackOnWhite ? this._paramThreshold() : 255 - this._paramThreshold(); 278 | 279 | return steps === Posterizer.STEPS_AUTO 280 | ? (colorsCount > 200 ? 4 : 3) 281 | : Math.min(colorsCount, Math.max(2, steps)); 282 | }, 283 | 284 | /** 285 | * Returns valid threshold value 286 | * @returns {number} 287 | * @private 288 | */ 289 | _paramThreshold: function() { 290 | if (this._calculatedThreshold !== null) { 291 | return this._calculatedThreshold; 292 | } 293 | 294 | if (this._params.threshold !== Potrace.THRESHOLD_AUTO) { 295 | this._calculatedThreshold = this._params.threshold; 296 | return this._calculatedThreshold; 297 | } 298 | 299 | var twoThresholds = this._getImageHistogram().multilevelThresholding(2); 300 | this._calculatedThreshold = this._params.blackOnWhite ? twoThresholds[1] : twoThresholds[0]; 301 | this._calculatedThreshold = this._calculatedThreshold || 128; 302 | 303 | return this._calculatedThreshold; 304 | }, 305 | 306 | /** 307 | * Running potrace on the image multiple times with different thresholds and returns an array 308 | * of path tags 309 | * 310 | * @param {Boolean} [noFillColor] 311 | * @returns {string[]} 312 | * @private 313 | */ 314 | _pathTags: function(noFillColor) { 315 | var ranges = this._getRanges(); 316 | var potrace = this._potrace; 317 | var blackOnWhite = this._params.blackOnWhite; 318 | 319 | if (ranges.length >= 10) { 320 | ranges = this._addExtraColorStop(ranges); 321 | } 322 | 323 | potrace.setParameters({ blackOnWhite: blackOnWhite }); 324 | 325 | var actualPrevLayersOpacity = 0; 326 | 327 | return ranges.map(function(colorStop) { 328 | var thisLayerOpacity = colorStop.colorIntensity; 329 | 330 | if (thisLayerOpacity === 0) { 331 | return ''; 332 | } 333 | 334 | // NOTE: With big number of layers (something like 70) there will be noticeable math error on rendering side. 335 | // In Chromium at least image will end up looking brighter overall compared to the same layers painted in solid colors. 336 | // However it works fine with sane number of layers, and it's not like we can do much about it. 337 | 338 | var calculatedOpacity = (!actualPrevLayersOpacity || thisLayerOpacity === 1) 339 | ? thisLayerOpacity 340 | : ((actualPrevLayersOpacity - thisLayerOpacity) / (actualPrevLayersOpacity - 1)); 341 | 342 | calculatedOpacity = utils.clamp(parseFloat(calculatedOpacity.toFixed(3)), 0, 1); 343 | actualPrevLayersOpacity = actualPrevLayersOpacity + (1 - actualPrevLayersOpacity) * calculatedOpacity; 344 | 345 | potrace.setParameters({ threshold: colorStop.value }); 346 | 347 | var element = noFillColor ? potrace.getPathTag('') : potrace.getPathTag(); 348 | element = utils.setHtmlAttr(element, 'fill-opacity', calculatedOpacity.toFixed(3)); 349 | 350 | var canBeIgnored = calculatedOpacity === 0 || element.indexOf(' d=""') !== -1; 351 | 352 | // var c = Math.round(Math.abs((blackOnWhite ? 255 : 0) - 255 * thisLayerOpacity)); 353 | // element = utils.setHtmlAttr(element, 'fill', 'rgb('+c+', '+c+', '+c+')'); 354 | // element = utils.setHtmlAttr(element, 'fill-opacity', ''); 355 | 356 | return canBeIgnored ? '' : element; 357 | }); 358 | }, 359 | 360 | /** 361 | * Loads image. 362 | * 363 | * @param {string|Buffer|Jimp} target Image source. Could be anything that {@link Jimp} can read (buffer, local path or url). Supported formats are: PNG, JPEG or BMP 364 | * @param {Function} callback 365 | */ 366 | loadImage: function(target, callback) { 367 | var self = this; 368 | 369 | this._potrace.loadImage(target, function(err) { 370 | self._calculatedThreshold = null; 371 | callback.call(self, err); 372 | }); 373 | }, 374 | 375 | /** 376 | * Sets parameters. Accepts same object as {Potrace} 377 | * 378 | * @param {Posterizer~Options} params 379 | */ 380 | setParameters: function(params) { 381 | if (!params) { 382 | return; 383 | } 384 | 385 | this._potrace.setParameters(params); 386 | 387 | if (params.steps && !Array.isArray(params.steps) && (!utils.isNumber(params.steps) || !utils.between(params.steps, 1, 255))) { 388 | throw new Error('Bad \'steps\' value'); 389 | } 390 | 391 | for (var key in this._params) { 392 | if (this._params.hasOwnProperty(key) && params.hasOwnProperty(key)) { 393 | this._params[key] = params[key]; 394 | } 395 | } 396 | 397 | this._calculatedThreshold = null; 398 | }, 399 | 400 | /** 401 | * Returns image as tag. Always has viewBox specified 402 | * 403 | * @param {string} id 404 | */ 405 | getSymbol: function(id) { 406 | var width = this._potrace._luminanceData.width; 407 | var height = this._potrace._luminanceData.height; 408 | var paths = this._pathTags(true); 409 | 410 | return '' + 411 | paths.join('') + 412 | ''; 413 | }, 414 | 415 | /** 416 | * Generates SVG image 417 | * @returns {String} 418 | */ 419 | getSVG: function() { 420 | var width = this._potrace._luminanceData.width, 421 | height = this._potrace._luminanceData.height; 422 | 423 | var tags = this._pathTags(false); 424 | 425 | var svg = '\n\t' + 430 | (this._params.background !== Potrace.COLOR_TRANSPARENT 431 | ? '\n\t' 432 | : '') + 433 | tags.join('\n\t') + 434 | '\n'; 435 | 436 | return svg.replace(/\n(?:\t*\n)+(\t*)/g, '\n$1'); 437 | } 438 | }; 439 | 440 | module.exports = Posterizer; 441 | 442 | /** 443 | * Posterizer options 444 | * 445 | * @typedef {Potrace~Options} Posterizer~Options 446 | * @property {Number} [steps] - Number of samples that needs to be taken (and number of layers in SVG). (default: Posterizer.STEPS_AUTO, which most likely will result in 3, sometimes 4) 447 | * @property {*} [fillStrategy] - How to select fill color for color ranges - equally spread or dominant. (default: Posterizer.FILL_DOMINANT) 448 | * @property {*} [rangeDistribution] - How to choose thresholds in-between - after equal intervals or automatically balanced. (default: Posterizer.RANGES_AUTO) 449 | */ 450 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Potrace = require('./Potrace'); 4 | var Posterizer = require('./Posterizer'); 5 | 6 | /** 7 | * Wrapper for Potrace that simplifies use down to one function call 8 | * 9 | * @param {string|Buffer|Jimp} file Source image, file path or {@link Jimp} instance 10 | * @param {Potrace~Options} [options] 11 | * @param {traceCallback} cb Callback function. Accepts 3 arguments: error, svg content and instance of {@link Potrace} (so it could be reused with different set of parameters) 12 | */ 13 | function trace(file, options, cb) { 14 | if (arguments.length === 2) { 15 | cb = options; 16 | options = {}; 17 | } 18 | 19 | var potrace = new Potrace(options); 20 | 21 | potrace.loadImage(file, function(err) { 22 | if (err) { return cb(err); } 23 | cb(null, potrace.getSVG(), potrace); 24 | }); 25 | } 26 | 27 | /** 28 | * Wrapper for Potrace that simplifies use down to one function call 29 | * 30 | * @param {string|Buffer|Jimp} file Source image, file path or {@link Jimp} instance 31 | * @param {Posterizer~Options} [options] 32 | * @param {posterizeCallback} cb Callback function. Accepts 3 arguments: error, svg content and instance of {@link Potrace} (so it could be reused with different set of parameters) 33 | */ 34 | function posterize(file, options, cb) { 35 | if (arguments.length === 2) { 36 | cb = options; 37 | options = {}; 38 | } 39 | 40 | var posterizer = new Posterizer(options); 41 | 42 | posterizer.loadImage(file, function(err) { 43 | if (err) { return cb(err); } 44 | cb(null, posterizer.getSVG(), posterizer); 45 | }); 46 | } 47 | 48 | module.exports = { 49 | trace: trace, 50 | posterize: posterize, 51 | Potrace: Potrace, 52 | Posterizer: Posterizer 53 | }; 54 | 55 | /** 56 | * Callback for trace method 57 | * @callback traceCallback 58 | * @param {Error|null} err 59 | * @param {string} svg SVG document contents 60 | * @param {Potrace} [instance] Potrace class instance 61 | */ 62 | 63 | /** 64 | * Callback for posterize method 65 | * @callback posterizeCallback 66 | * @param {Error|null} err 67 | * @param {string} svg SVG document contents 68 | * @param {Posterizer} [instance] Posterizer class instance 69 | */ -------------------------------------------------------------------------------- /lib/types/Bitmap.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Point = require('./Point'); 4 | var utils = require('../utils'); 5 | var Histogram; 6 | 7 | /** 8 | * Represents a bitmap where each pixel can be a number in range of 0..255 9 | * Used internally to store luminance data. 10 | * 11 | * @param {Number} w 12 | * @param {Number} h 13 | * @constructor 14 | */ 15 | function Bitmap(w, h) { 16 | this._histogram = null; 17 | 18 | this.width = w; 19 | this.height = h; 20 | this.size = w * h; 21 | this.arrayBuffer = new ArrayBuffer(this.size); 22 | this.data = new Uint8Array(this.arrayBuffer); 23 | } 24 | 25 | module.exports = Bitmap; 26 | Histogram = require('./Histogram'); 27 | 28 | Bitmap.prototype = { 29 | /** 30 | * Returns pixel value 31 | * 32 | * @param {Number|Point} x - index, point or x 33 | * @param {Number} [y] 34 | */ 35 | getValueAt: function(x, y) { 36 | var index = (typeof x === 'number' && typeof y !== 'number') ? x : this.pointToIndex(x, y); 37 | return this.data[index]; 38 | }, 39 | 40 | /** 41 | * Converts {@link Point} to index value 42 | * 43 | * @param {Number} index 44 | * @returns {Point} 45 | */ 46 | indexToPoint: function(index) { 47 | var point = new Point(); 48 | 49 | if (utils.between(index, 0, this.size)) { 50 | point.y = Math.floor(index / this.width); 51 | point.x = index - point.y * this.width; 52 | } else { 53 | point.x = -1; 54 | point.y = -1; 55 | } 56 | 57 | return point; 58 | }, 59 | 60 | /** 61 | * Calculates index for point or coordinate pair 62 | * 63 | * @param {Number|Point} pointOrX 64 | * @param {Number} [y] 65 | * @returns {Number} 66 | */ 67 | pointToIndex: function(pointOrX, y) { 68 | var _x = pointOrX, 69 | _y = y; 70 | 71 | if (pointOrX instanceof Point) { 72 | _x = pointOrX.x; 73 | _y = pointOrX.y; 74 | } 75 | 76 | if (!utils.between(_x, 0, this.width) || !utils.between(_y, 0, this.height)) { 77 | return -1; 78 | } 79 | 80 | return this.width * _y + _x; 81 | }, 82 | 83 | /** 84 | * Makes a copy of current bitmap 85 | * 86 | * @param {Function} [iterator] optional callback, used for processing pixel value. Accepted arguments: value, index 87 | * @returns {Bitmap} 88 | */ 89 | copy: function(iterator) { 90 | var bm = new Bitmap(this.width, this.height), 91 | iteratorPresent = typeof iterator === 'function', 92 | i; 93 | 94 | for (i = 0; i < this.size; i++) { 95 | bm.data[i] = iteratorPresent ? iterator(this.data[i], i) : this.data[i]; 96 | } 97 | 98 | return bm; 99 | }, 100 | 101 | histogram: function() { 102 | var Histogram = require('./Histogram'); 103 | 104 | if (this._histogram) { 105 | return this._histogram; 106 | } 107 | 108 | this._histogram = new Histogram(this); 109 | return this._histogram; 110 | } 111 | }; -------------------------------------------------------------------------------- /lib/types/Curve.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * Curve type 5 | * 6 | * @param n 7 | * @constructor 8 | * @protected 9 | */ 10 | function Curve(n) { 11 | this.n = n; 12 | this.tag = new Array(n); 13 | this.c = new Array(n * 3); 14 | this.alphaCurve = 0; 15 | this.vertex = new Array(n); 16 | this.alpha = new Array(n); 17 | this.alpha0 = new Array(n); 18 | this.beta = new Array(n); 19 | } 20 | 21 | module.exports = Curve; -------------------------------------------------------------------------------- /lib/types/Histogram.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | // Histogram 4 | 5 | var utils = require('../utils'); 6 | var Jimp = null; try { Jimp = require('jimp'); } catch(e) {} 7 | var Bitmap = require('./Bitmap'); 8 | 9 | var COLOR_DEPTH = 256; 10 | var COLOR_RANGE_END = COLOR_DEPTH - 1; 11 | 12 | /** 13 | * Calculates array index for pair of indexes. We multiple column (x) by 256 and then add row to it, 14 | * this way `(index(i, j) + 1) === index(i, j + i)` thus we can reuse `index(i, j)` we once calculated 15 | * 16 | * Note: this is different from how indexes calculated in {@link Bitmap} class, keep it in mind. 17 | * 18 | * @param x 19 | * @param y 20 | * @returns {*} 21 | * @private 22 | */ 23 | function index(x, y) { 24 | return COLOR_DEPTH * x + y; 25 | } 26 | 27 | function normalizeMinMax(levelMin, levelMax) { 28 | /** 29 | * Shared parameter normalization for methods 'multilevelThresholding', 'autoThreshold', 'getDominantColor' and 'getStats' 30 | * 31 | * @param levelMin 32 | * @param levelMax 33 | * @returns {number[]} 34 | * @private 35 | */ 36 | levelMin = typeof levelMin === 'number' ? utils.clamp(Math.round(levelMin), 0, COLOR_RANGE_END) : 0; 37 | levelMax = typeof levelMax === 'number' ? utils.clamp(Math.round(levelMax), 0, COLOR_RANGE_END) : COLOR_RANGE_END; 38 | 39 | if (levelMin > levelMax) { 40 | throw new Error('Invalid range "'+ levelMin + '...' + levelMax + '"'); 41 | } 42 | 43 | return [levelMin, levelMax]; 44 | } 45 | 46 | /** 47 | * 1D Histogram 48 | * 49 | * @param {Number|Bitmap|Jimp} imageSource - Image to collect pixel data from. Or integer to create empty histogram for image of specific size 50 | * @param [mode] Used only for Jimp images. {@link Bitmap} currently can only store 256 values per pixel, so it's assumed that it contains values we are looking for 51 | * @constructor 52 | * @protected 53 | */ 54 | function Histogram(imageSource, mode) { 55 | this.data = null; 56 | this.pixels = 0; 57 | this._sortedIndexes = null; 58 | this._cachedStats = {}; 59 | this._lookupTableH = null; 60 | 61 | if (typeof imageSource === 'number') { 62 | this._createArray(imageSource); 63 | } else if (imageSource instanceof Bitmap) { 64 | this._collectValuesBitmap(imageSource); 65 | } else if (Jimp && imageSource instanceof Jimp) { 66 | this._collectValuesJimp(imageSource, mode); 67 | } else { 68 | throw new Error('Unsupported image source'); 69 | } 70 | } 71 | 72 | Histogram.MODE_LUMINANCE = 'luminance'; 73 | Histogram.MODE_R = 'r'; 74 | Histogram.MODE_G = 'g'; 75 | Histogram.MODE_B = 'b'; 76 | 77 | Histogram.prototype = { 78 | /** 79 | * Initializes data array for an image of given pixel size 80 | * @param imageSize 81 | * @returns {Uint8Array|Uint16Array|Uint32Array} 82 | * @private 83 | */ 84 | _createArray: function(imageSize) { 85 | var ArrayType = imageSize <= Math.pow(2, 8) ? Uint8Array 86 | : imageSize <= Math.pow(2, 16) ? Uint16Array : Uint32Array; 87 | 88 | this.pixels = imageSize; 89 | 90 | return this.data = new ArrayType(COLOR_DEPTH); 91 | }, 92 | 93 | /** 94 | * Aggregates color data from {@link Jimp} instance 95 | * @param {Jimp} source 96 | * @param mode 97 | * @private 98 | */ 99 | _collectValuesJimp: function(source, mode) { 100 | var pixelData = source.bitmap.data; 101 | var data = this._createArray(source.bitmap.width * source.bitmap.height); 102 | 103 | source.scan(0, 0, source.bitmap.width, source.bitmap.height, function(x, y, idx) { 104 | var val = mode === Histogram.MODE_R ? pixelData[idx] 105 | : mode === Histogram.MODE_G ? pixelData[idx + 1] 106 | : mode === Histogram.MODE_B ? pixelData[idx + 2] 107 | : utils.luminance(pixelData[idx], pixelData[idx + 1], pixelData[idx + 2]); 108 | 109 | data[val]++; 110 | }); 111 | }, 112 | 113 | /** 114 | * Aggregates color data from {@link Bitmap} instance 115 | * @param {Bitmap} source 116 | * @private 117 | */ 118 | _collectValuesBitmap: function(source) { 119 | var data = this._createArray(source.size); 120 | var len = source.data.length; 121 | var color; 122 | 123 | for (var i = 0; i < len; i++) { 124 | color = source.data[i]; 125 | data[color]++ 126 | } 127 | }, 128 | 129 | /** 130 | * Returns array of color indexes in ascending order 131 | * @param refresh 132 | * @returns {*} 133 | * @private 134 | */ 135 | _getSortedIndexes: function(refresh) { 136 | if (!refresh && this._sortedIndexes) { 137 | return this._sortedIndexes; 138 | } 139 | 140 | var data = this.data; 141 | var indexes = new Array(COLOR_DEPTH); 142 | var i = 0; 143 | 144 | for (i; i < COLOR_DEPTH; i++) { 145 | indexes[i] = i; 146 | } 147 | 148 | indexes.sort(function(a, b) { 149 | return data[a] > data[b] ? 1 : data[a] < data[b] ? -1 : 0; 150 | }); 151 | 152 | this._sortedIndexes = indexes; 153 | return indexes; 154 | }, 155 | 156 | /** 157 | * Builds lookup table H from lookup tables P and S. 158 | * see {@link http://www.iis.sinica.edu.tw/page/jise/2001/200109_01.pdf|this paper} for more details 159 | * 160 | * @returns {Float64Array} 161 | * @private 162 | */ 163 | _thresholdingBuildLookupTable: function() { 164 | var P = new Float64Array(COLOR_DEPTH * COLOR_DEPTH); 165 | var S = new Float64Array(COLOR_DEPTH * COLOR_DEPTH); 166 | var H = new Float64Array(COLOR_DEPTH * COLOR_DEPTH); 167 | var pixelsTotal = this.pixels; 168 | var i, j, idx, tmp; 169 | 170 | // diagonal 171 | for (i = 1; i < COLOR_DEPTH; ++i) { 172 | idx = index(i, i); 173 | tmp = this.data[i] / pixelsTotal; 174 | 175 | P[idx] = tmp; 176 | S[idx] = i * tmp; 177 | } 178 | 179 | // calculate first row (row 0 is all zero) 180 | for (i = 1; i < COLOR_DEPTH - 1; ++i) { 181 | tmp = this.data[i + 1] / pixelsTotal; 182 | idx = index(1, i); 183 | 184 | P[idx+1] = P[idx] + tmp; 185 | S[idx+1] = S[idx] + (i + 1) * tmp; 186 | } 187 | 188 | // using row 1 to calculate others 189 | for (i = 2; i < COLOR_DEPTH; i++) { 190 | for (j=i+1; j < COLOR_DEPTH; j++) { 191 | P[index(i, j)] = P[index(1, j)] - P[index(1, i-1)]; 192 | S[index(i, j)] = S[index(1, j)] - S[index(1, i-1)]; 193 | } 194 | } 195 | 196 | // now calculate H[i][j] 197 | for (i = 1; i < COLOR_DEPTH; ++i) { 198 | for (j = i + 1; j < COLOR_DEPTH; j++) { 199 | idx = index(i, j); 200 | H[idx] = P[idx] !== 0 ? S[idx] * S[idx] / P[idx] : 0; 201 | } 202 | } 203 | 204 | return this._lookupTableH = H; 205 | }, 206 | 207 | /** 208 | * Implements Algorithm For Multilevel Thresholding 209 | * Receives desired number of color stops, returns array of said size. Could be limited to a range levelMin..levelMax 210 | * 211 | * Regardless of levelMin and levelMax values it still relies on between class variances for the entire histogram 212 | * 213 | * @param amount - how many thresholds should be calculated 214 | * @param [levelMin=0] - histogram segment start 215 | * @param [levelMax=255] - histogram segment end 216 | * @returns {number[]} 217 | */ 218 | multilevelThresholding: function (amount, levelMin, levelMax) { 219 | levelMin = normalizeMinMax(levelMin, levelMax); 220 | levelMax = levelMin[1]; 221 | levelMin = levelMin[0]; 222 | amount = Math.min(levelMax - levelMin - 2, ~~amount); 223 | 224 | if (amount < 1) { 225 | return []; 226 | } 227 | 228 | if (!this._lookupTableH) { 229 | this._thresholdingBuildLookupTable(); 230 | } 231 | 232 | var H = this._lookupTableH; 233 | 234 | var colorStops = null; 235 | var maxSig = 0; 236 | 237 | if (amount > 4) { 238 | console.log('[Warning]: Threshold computation for more than 5 levels may take a long time'); 239 | } 240 | 241 | function iterateRecursive (startingPoint, prevVariance, indexes, previousDepth) { 242 | startingPoint = (startingPoint || 0) + 1; 243 | prevVariance = prevVariance || 0; 244 | indexes = indexes || (new Array(amount)); 245 | previousDepth = previousDepth || 0; 246 | 247 | var depth = previousDepth + 1; // t 248 | var variance; 249 | 250 | for (var i = startingPoint; i < levelMax - amount + previousDepth; i++) { 251 | variance = prevVariance + H[index(startingPoint, i)]; 252 | indexes[depth - 1] = i; 253 | 254 | if (depth + 1 < amount + 1) { 255 | // we need to go deeper 256 | iterateRecursive(i, variance, indexes, depth); 257 | } else { 258 | // enough, we can compare values now 259 | variance += H[index(i + 1, levelMax)]; 260 | 261 | if (maxSig < variance) { 262 | maxSig = variance; 263 | colorStops = indexes.slice(); 264 | } 265 | } 266 | } 267 | } 268 | 269 | iterateRecursive(levelMin || 0); 270 | 271 | return colorStops ? colorStops : []; 272 | }, 273 | 274 | /** 275 | * Automatically finds threshold value using Algorithm For Multilevel Thresholding 276 | * 277 | * @param {number} [levelMin] 278 | * @param {number} [levelMax] 279 | * @returns {null|number} 280 | */ 281 | autoThreshold: function(levelMin, levelMax) { 282 | var value = this.multilevelThresholding(1, levelMin, levelMax); 283 | return value.length ? value[0] : null; 284 | }, 285 | 286 | /** 287 | * Returns dominant color in given range. Returns -1 if not a single color from the range present on the image 288 | * 289 | * @param [levelMin=0] 290 | * @param [levelMax=255] 291 | * @param [tolerance=1] 292 | * @returns {number} 293 | */ 294 | getDominantColor: function(levelMin, levelMax, tolerance) { 295 | levelMin = normalizeMinMax(levelMin, levelMax); 296 | levelMax = levelMin[1]; 297 | levelMin = levelMin[0]; 298 | tolerance = tolerance || 1; 299 | 300 | var colors = this.data, 301 | dominantIndex = -1, 302 | dominantValue = -1, 303 | i, j, tmp; 304 | 305 | if (levelMin === levelMax) { 306 | return colors[levelMin] ? levelMin : -1; 307 | } 308 | 309 | for (i=levelMin; i <= levelMax; i++) { 310 | tmp = 0; 311 | 312 | for (j = ~~(tolerance / -2); j < tolerance; j++) { 313 | tmp += utils.between(i + j, 0, COLOR_RANGE_END) ? colors[i + j] : 0; 314 | } 315 | 316 | var summIsBigger = tmp > dominantValue; 317 | var summEqualButMainColorIsBigger = dominantValue === tmp && (dominantIndex < 0 || colors[i] > colors[dominantIndex]); 318 | 319 | if (summIsBigger || summEqualButMainColorIsBigger) { 320 | dominantIndex = i; 321 | dominantValue = tmp; 322 | } 323 | } 324 | 325 | return dominantValue <= 0 ? -1 : dominantIndex; 326 | }, 327 | 328 | /** 329 | * Returns stats for histogram or its segment. 330 | * 331 | * Returned object contains median, mean and standard deviation for pixel values; 332 | * peak, mean and median number of pixels per level and few other values 333 | * 334 | * If no pixels colors from specified range present on the image - most values will be NaN 335 | * 336 | * @param {Number} [levelMin=0] - histogram segment start 337 | * @param {Number} [levelMax=255] - histogram segment end 338 | * @param {Boolean} [refresh=false] - if cached result can be returned 339 | * @returns {{levels: {mean: (number|*), median: *, stdDev: number, unique: number}, pixelsPerLevel: {mean: (number|*), median: (number|*), peak: number}, pixels: number}} 340 | */ 341 | getStats: function(levelMin, levelMax, refresh) { 342 | levelMin = normalizeMinMax(levelMin, levelMax); 343 | levelMax = levelMin[1]; 344 | levelMin = levelMin[0]; 345 | 346 | if (!refresh && this._cachedStats[levelMin + '-' + levelMax]) { 347 | return this._cachedStats[levelMin + '-' + levelMax]; 348 | } 349 | 350 | var data = this.data; 351 | var sortedIndexes = this._getSortedIndexes(); 352 | 353 | var pixelsTotal = 0; 354 | var medianValue = null; 355 | var meanValue; 356 | var medianPixelIndex; 357 | var pixelsPerLevelMean; 358 | var pixelsPerLevelMedian; 359 | var tmpSumOfDeviations = 0; 360 | var tmpPixelsIterated = 0; 361 | var allPixelValuesCombined = 0; 362 | var i, tmpPixels, tmpPixelValue; 363 | 364 | var uniqueValues = 0; // counter for levels that's represented by at least one pixel 365 | var mostPixelsPerLevel = 0; 366 | 367 | // Finding number of pixels and mean 368 | 369 | for (i = levelMin; i <= levelMax; i++) { 370 | pixelsTotal += data[i]; 371 | allPixelValuesCombined += data[i] * i; 372 | 373 | uniqueValues += data[i] === 0 ? 0 : 1; 374 | 375 | if (mostPixelsPerLevel < data[i]) { 376 | mostPixelsPerLevel = data[i]; 377 | } 378 | } 379 | 380 | meanValue = allPixelValuesCombined / pixelsTotal; 381 | pixelsPerLevelMean = pixelsTotal / (levelMax - levelMin); 382 | pixelsPerLevelMedian = pixelsTotal / uniqueValues; 383 | medianPixelIndex = Math.floor(pixelsTotal / 2); 384 | 385 | // Finding median and standard deviation 386 | 387 | for (i = 0; i < COLOR_DEPTH; i++) { 388 | tmpPixelValue = sortedIndexes[i]; 389 | tmpPixels = data[tmpPixelValue]; 390 | 391 | if (tmpPixelValue < levelMin || tmpPixelValue > levelMax) { 392 | continue; 393 | } 394 | 395 | tmpPixelsIterated += tmpPixels; 396 | tmpSumOfDeviations += Math.pow(tmpPixelValue - meanValue, 2) * tmpPixels; 397 | 398 | if (medianValue === null && tmpPixelsIterated >= medianPixelIndex) { 399 | medianValue = tmpPixelValue; 400 | } 401 | } 402 | 403 | return this._cachedStats[levelMin + '-' + levelMax] = { 404 | // various pixel counts for levels (0..255) 405 | 406 | levels: { 407 | mean: meanValue, 408 | median: medianValue, 409 | stdDev: Math.sqrt(tmpSumOfDeviations / pixelsTotal), 410 | unique: uniqueValues 411 | }, 412 | 413 | // what's visually represented as bars 414 | pixelsPerLevel: { 415 | mean: pixelsPerLevelMean, 416 | median: pixelsPerLevelMedian, 417 | peak: mostPixelsPerLevel 418 | }, 419 | 420 | pixels: pixelsTotal 421 | }; 422 | } 423 | }; 424 | 425 | module.exports = Histogram; -------------------------------------------------------------------------------- /lib/types/Opti.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Point = require('./Point'); 4 | 5 | function Opti() { 6 | this.pen = 0; 7 | this.c = [new Point(), new Point()]; 8 | this.t = 0; 9 | this.s = 0; 10 | this.alpha = 0; 11 | } 12 | 13 | module.exports = Opti; -------------------------------------------------------------------------------- /lib/types/Path.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | function Path() { 4 | this.area = 0; 5 | this.len = 0; 6 | this.curve = {}; 7 | this.pt = []; 8 | this.minX = 100000; 9 | this.minY = 100000; 10 | this.maxX = -1; 11 | this.maxY = -1; 12 | } 13 | 14 | module.exports = Path; -------------------------------------------------------------------------------- /lib/types/Point.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | function Point(x, y) { 4 | this.x = x || 0; 5 | this.y = y || 0; 6 | } 7 | 8 | Point.prototype = { 9 | copy: function() { 10 | return new Point(this.x, this.y); 11 | } 12 | }; 13 | 14 | module.exports = Point; -------------------------------------------------------------------------------- /lib/types/Quad.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | function Quad() { 4 | this.data = [0,0,0,0,0,0,0,0,0]; 5 | } 6 | 7 | Quad.prototype.at = function(x, y) { 8 | return this.data[x * 3 + y]; 9 | }; 10 | 11 | module.exports = Quad; -------------------------------------------------------------------------------- /lib/types/Sum.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | function Sum(x, y, xy, x2, y2) { 4 | this.x = x; 5 | this.y = y; 6 | this.xy = xy; 7 | this.x2 = x2; 8 | this.y2 = y2; 9 | } 10 | 11 | module.exports = Sum; -------------------------------------------------------------------------------- /lib/utils.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Point = require('./types/Point'); 4 | var attrRegexps = {}; 5 | 6 | function getAttrRegexp(attrName) { 7 | if (attrRegexps[attrName]) { 8 | return attrRegexps[attrName]; 9 | } 10 | 11 | attrRegexps[attrName] = new RegExp(' ' + attrName + '="((?:\\\\(?=")"|[^"])+)"', 'i'); 12 | return attrRegexps[attrName]; 13 | } 14 | 15 | function setHtmlAttribute(html, attrName, value) { 16 | var attr = ' ' + attrName + '="' + value + '"'; 17 | 18 | if (html.indexOf(' ' + attrName + '="') === -1) { 19 | html = html.replace(/<[a-z]+/i, function(beginning) { return beginning + attr; }); 20 | } else { 21 | html = html.replace(getAttrRegexp(attrName), attr); 22 | } 23 | 24 | return html; 25 | } 26 | 27 | function fixed(number) { 28 | return number.toFixed(3).replace('.000', ''); 29 | } 30 | 31 | function mod(a, n) { 32 | return a >= n ? a % n : a>=0 ? a : n-1-(-1-a) % n; 33 | } 34 | 35 | function xprod(p1, p2) { 36 | return p1.x * p2.y - p1.y * p2.x; 37 | } 38 | 39 | function cyclic(a, b, c) { 40 | if (a <= c) { 41 | return (a <= b && b < c); 42 | } else { 43 | return (a <= b || b < c); 44 | } 45 | } 46 | 47 | function sign(i) { 48 | return i > 0 ? 1 : i < 0 ? -1 : 0; 49 | } 50 | 51 | function quadform(Q, w) { 52 | var v = new Array(3), i, j, sum; 53 | 54 | v[0] = w.x; 55 | v[1] = w.y; 56 | v[2] = 1; 57 | sum = 0.0; 58 | 59 | for (i=0; i<3; i++) { 60 | for (j=0; j<3; j++) { 61 | sum += v[i] * Q.at(i, j) * v[j]; 62 | } 63 | } 64 | return sum; 65 | } 66 | 67 | function interval(lambda, a, b) { 68 | var res = new Point(); 69 | 70 | res.x = a.x + lambda * (b.x - a.x); 71 | res.y = a.y + lambda * (b.y - a.y); 72 | return res; 73 | } 74 | 75 | function dorth_infty(p0, p2) { 76 | var r = new Point(); 77 | 78 | r.y = sign(p2.x - p0.x); 79 | r.x = -sign(p2.y - p0.y); 80 | 81 | return r; 82 | } 83 | 84 | function ddenom(p0, p2) { 85 | var r = dorth_infty(p0, p2); 86 | 87 | return r.y * (p2.x - p0.x) - r.x * (p2.y - p0.y); 88 | } 89 | 90 | function dpara(p0, p1, p2) { 91 | var x1, y1, x2, y2; 92 | 93 | x1 = p1.x - p0.x; 94 | y1 = p1.y - p0.y; 95 | x2 = p2.x - p0.x; 96 | y2 = p2.y - p0.y; 97 | 98 | return x1 * y2 - x2 * y1; 99 | } 100 | 101 | function cprod(p0, p1, p2, p3) { 102 | var x1, y1, x2, y2; 103 | 104 | x1 = p1.x - p0.x; 105 | y1 = p1.y - p0.y; 106 | x2 = p3.x - p2.x; 107 | y2 = p3.y - p2.y; 108 | 109 | return x1 * y2 - x2 * y1; 110 | } 111 | 112 | function iprod(p0, p1, p2) { 113 | var x1, y1, x2, y2; 114 | 115 | x1 = p1.x - p0.x; 116 | y1 = p1.y - p0.y; 117 | x2 = p2.x - p0.x; 118 | y2 = p2.y - p0.y; 119 | 120 | return x1*x2 + y1*y2; 121 | } 122 | 123 | function iprod1(p0, p1, p2, p3) { 124 | var x1, y1, x2, y2; 125 | 126 | x1 = p1.x - p0.x; 127 | y1 = p1.y - p0.y; 128 | x2 = p3.x - p2.x; 129 | y2 = p3.y - p2.y; 130 | 131 | return x1 * x2 + y1 * y2; 132 | } 133 | 134 | function ddist(p, q) { 135 | return Math.sqrt((p.x - q.x) * (p.x - q.x) + (p.y - q.y) * (p.y - q.y)); 136 | } 137 | 138 | module.exports = { 139 | luminance: function (r, g, b) { 140 | return Math.round(0.2126 * r + 0.7153 * g + 0.0721 * b); 141 | }, 142 | 143 | between: function(val, min, max) { 144 | return val >= min && val <= max; 145 | }, 146 | 147 | clamp: function(val, min, max) { 148 | return Math.min(max, Math.max(min, val)); 149 | }, 150 | 151 | isNumber: function(val) { 152 | return typeof val === 'number'; 153 | }, 154 | 155 | setHtmlAttr: setHtmlAttribute, 156 | 157 | /** 158 | * Generates path instructions for given curve 159 | * 160 | * @param {Curve} curve 161 | * @param {Number} [scale] 162 | * @returns {string} 163 | */ 164 | renderCurve: function(curve, scale) { 165 | scale = scale || 1; 166 | 167 | var startingPoint = curve.c[(curve.n - 1) * 3 + 2]; 168 | 169 | var path = 'M ' 170 | + fixed(startingPoint.x * scale) + ' ' 171 | + fixed(startingPoint.y * scale) + ' '; 172 | 173 | curve.tag.forEach(function(tag, i) { 174 | var i3 = i * 3; 175 | var p0 = curve.c[i3]; 176 | var p1 = curve.c[i3 + 1]; 177 | var p2 = curve.c[i3 + 2]; 178 | 179 | if (tag === "CURVE") { 180 | path += 'C '; 181 | path += fixed(p0.x * scale) + ' ' + fixed(p0.y * scale) + ', '; 182 | path += fixed(p1.x * scale) + ' ' + fixed(p1.y * scale) + ', '; 183 | path += fixed(p2.x * scale) + ' ' + fixed(p2.y * scale) + ' '; 184 | } else if (tag === "CORNER") { 185 | path += 'L '; 186 | path += fixed(p1.x * scale) + ' ' + fixed(p1.y * scale) + ' '; 187 | path += fixed(p2.x * scale) + ' ' + fixed(p2.y * scale) + ' '; 188 | } 189 | }); 190 | 191 | return path; 192 | }, 193 | 194 | bezier: function bezier(t, p0, p1, p2, p3) { 195 | var s = 1 - t, res = new Point(); 196 | 197 | res.x = s*s*s*p0.x + 3*(s*s*t)*p1.x + 3*(t*t*s)*p2.x + t*t*t*p3.x; 198 | res.y = s*s*s*p0.y + 3*(s*s*t)*p1.y + 3*(t*t*s)*p2.y + t*t*t*p3.y; 199 | 200 | return res; 201 | }, 202 | 203 | tangent: function tangent(p0, p1, p2, p3, q0, q1) { 204 | var A, B, C, a, b, c, d, s, r1, r2; 205 | 206 | A = cprod(p0, p1, q0, q1); 207 | B = cprod(p1, p2, q0, q1); 208 | C = cprod(p2, p3, q0, q1); 209 | 210 | a = A - 2 * B + C; 211 | b = -2 * A + 2 * B; 212 | c = A; 213 | 214 | d = b * b - 4 * a * c; 215 | 216 | if (a===0 || d<0) { 217 | return -1.0; 218 | } 219 | 220 | s = Math.sqrt(d); 221 | 222 | r1 = (-b + s) / (2 * a); 223 | r2 = (-b - s) / (2 * a); 224 | 225 | if (r1 >= 0 && r1 <= 1) { 226 | return r1; 227 | } else if (r2 >= 0 && r2 <= 1) { 228 | return r2; 229 | } else { 230 | return -1.0; 231 | } 232 | }, 233 | 234 | mod: mod, 235 | xprod: xprod, 236 | cyclic: cyclic, 237 | sign: sign, 238 | quadform: quadform, 239 | interval: interval, 240 | dorth_infty: dorth_infty, 241 | ddenom: ddenom, 242 | dpara: dpara, 243 | cprod: cprod, 244 | iprod: iprod, 245 | iprod1: iprod1, 246 | ddist: ddist 247 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "potrace", 3 | "version": "2.1.2", 4 | "description": "Potrace in Javascript, for NodeJS", 5 | "main": "lib/index.js", 6 | "scripts": { 7 | "test": "cd test && mocha test.js --reporter spec" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/tooolbox/node-potrace.git" 12 | }, 13 | "keywords": [ 14 | "potrace", 15 | "trace", 16 | "tracing", 17 | "svg", 18 | "bitmap", 19 | "posterization" 20 | ], 21 | "author": "mattmc", 22 | "license": "GPL-2.0", 23 | "bugs": { 24 | "url": "https://github.com/tooolbox/node-potrace/issues" 25 | }, 26 | "homepage": "https://github.com/tooolbox/node-potrace#readme", 27 | "dependencies": { 28 | "jimp": "^0.6.4" 29 | }, 30 | "devDependencies": { 31 | "lodash": "^4.15.0", 32 | "mocha": "^3.0.2", 33 | "should": "^11.1.0", 34 | "should-sinon": "0.0.5", 35 | "sinon": "^1.17.5" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /test/example-output.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /test/reference-copies/output.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /test/reference-copies/posterized-yao-black-threshold-65.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /test/reference-copies/potrace-bw-black-threshold-0.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /test/reference-copies/potrace-bw-black-threshold-255.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /test/reference-copies/potrace-bw-threshold-128.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /test/reference-copies/potrace-bw-threshold-170.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /test/reference-copies/potrace-bw-threshold-65.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /test/reference-copies/potrace-bw-white-threshold-0.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /test/reference-copies/potrace-bw-white-threshold-255.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /test/reference-copies/potrace-wb-black-threshold-0.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /test/reference-copies/potrace-wb-black-threshold-255.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /test/reference-copies/potrace-wb-threshold-128.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /test/reference-copies/potrace-wb-white-threshold-0.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /test/reference-copies/potrace-wb-white-threshold-255.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /test/sources/Lenna.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iwsfg/node-potrace/b86608eaf3b9c5fea6d6b2034c762ea666e70ec6/test/sources/Lenna.png -------------------------------------------------------------------------------- /test/sources/clouds.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iwsfg/node-potrace/b86608eaf3b9c5fea6d6b2034c762ea666e70ec6/test/sources/clouds.jpg -------------------------------------------------------------------------------- /test/sources/white-on-black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iwsfg/node-potrace/b86608eaf3b9c5fea6d6b2034c762ea666e70ec6/test/sources/white-on-black.png -------------------------------------------------------------------------------- /test/sources/yao.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iwsfg/node-potrace/b86608eaf3b9c5fea6d6b2034c762ea666e70ec6/test/sources/yao.jpg -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var _ = require('lodash'), 4 | assert = require('assert'), 5 | should = require('should'), 6 | sinon = require('sinon'); 7 | 8 | require('should-sinon'); 9 | 10 | var fs = require('fs'), 11 | Jimp = require('jimp'), 12 | Potrace = require('../lib/Potrace'), 13 | Posterizer = require('../lib/Posterizer'), 14 | Histogram = require('../lib/types/Histogram'), 15 | lib = require('../lib/index'); 16 | 17 | var PATH_TO_YAO = './sources/yao.jpg'; 18 | var PATH_TO_LENNA = './sources/Lenna.png'; 19 | var PATH_TO_BLACK_AND_WHITE_IMAGE = './sources/clouds.jpg'; 20 | 21 | var blackImage = new Jimp(100, 100, 0x000000FF); 22 | var whiteImage = new Jimp(100, 100, 0xFFFFFFFF); 23 | 24 | describe('Histogram class (private, responsible for auto thresholding)', function() { 25 | var histogram = null; 26 | 27 | var blackHistogram = new Histogram(blackImage, Histogram.MODE_LUMINANCE); 28 | var whiteHistogram = new Histogram(whiteImage, Histogram.MODE_LUMINANCE); 29 | 30 | before(function(done) { 31 | this.timeout(10000); 32 | 33 | Jimp.read(PATH_TO_LENNA, function(err, img) { 34 | if (err) throw err; 35 | histogram = new Histogram(img, Histogram.MODE_LUMINANCE); 36 | done(); 37 | }); 38 | }); 39 | 40 | describe('#getDominantColor', function() { 41 | it('gives different results with different tolerance values', function() { 42 | assert.equal(histogram.getDominantColor(0, 255), 149); 43 | assert.equal(histogram.getDominantColor(0, 255, 10), 143); 44 | }); 45 | 46 | it('has default argument values of 0, 255 and 1', function() { 47 | assert.equal(histogram.getDominantColor(), histogram.getDominantColor(0, 255, 1)); 48 | }); 49 | 50 | it('works for a segment of histogram', function() { 51 | assert.equal(41, histogram.getDominantColor(20, 80)); 52 | }); 53 | 54 | it('does not fail when min and max values are the same', function() { 55 | assert.equal(histogram.getDominantColor(42, 42), 42); 56 | }); 57 | 58 | it('returns -1 if colors from the range are not present on image', function() { 59 | assert.equal(histogram.getDominantColor(0, 15), -1); 60 | assert.equal(histogram.getDominantColor(7, 7, 1), -1); 61 | }); 62 | 63 | it('throws error if range start is larger than range end', function() { 64 | (function() { 65 | histogram.getDominantColor(80, 20); 66 | }).should.throw(); 67 | }); 68 | 69 | it('behaves predictably in edge cases', function() { 70 | blackHistogram.getDominantColor(0, 255).should.be.equal(0); 71 | whiteHistogram.getDominantColor(0, 255).should.be.equal(255); 72 | whiteHistogram.getDominantColor(25, 235).should.be.equal(-1); 73 | 74 | // Tolerance should not affect returned value 75 | 76 | blackHistogram.getDominantColor(0, 255, 15).should.be.equal(0); 77 | whiteHistogram.getDominantColor(0, 255, 15).should.be.equal(255); 78 | }) 79 | }); 80 | 81 | describe('#getStats', function() { 82 | function toFixedDeep(stats, fractionalDigits) { 83 | return _.cloneDeepWith(stats, function(val) { 84 | if (_.isNumber(val) && !_.isInteger(val)) { 85 | return parseFloat(val.toFixed(fractionalDigits)); 86 | } 87 | }); 88 | } 89 | 90 | it('produces expected stats object for entire histogram', function() { 91 | var expectedValue = { 92 | levels: { 93 | mean: 116.7673568725586, 94 | median: 95, 95 | stdDev: 49.42205692905339, 96 | unique: 222 97 | }, 98 | pixelsPerLevel: { 99 | mean: 1028.0156862745098, 100 | median: 1180.8288288288288, 101 | peak: 2495 102 | }, 103 | pixels: 262144 104 | }; 105 | 106 | assert.deepEqual( 107 | toFixedDeep(histogram.getStats(), 4), 108 | toFixedDeep(expectedValue, 4) 109 | ); 110 | }); 111 | 112 | it('produces expected stats object for histogram segment', function() { 113 | var expectedValue = { 114 | levels: { 115 | mean: 121.89677761754915, 116 | median: 93, 117 | stdDev: 30.2466970087377, 118 | unique: 121 119 | }, 120 | pixelsPerLevel: { 121 | mean: 1554.4916666666666, 122 | median: 1541.6446280991736, 123 | peak: 2495 124 | }, 125 | pixels: 186539 126 | }; 127 | 128 | assert.deepEqual( 129 | toFixedDeep(histogram.getStats(60, 180), 4), 130 | toFixedDeep(expectedValue, 4) 131 | ); 132 | }); 133 | 134 | it('throws error if range start is larger than range end', function() { 135 | (function() { 136 | histogram.getStats(255, 123); 137 | }).should.throw(); 138 | }); 139 | 140 | it('behaves predictably in edge cases', function() { 141 | var blackImageStats = blackHistogram.getStats(); 142 | var whiteImageStats = blackHistogram.getStats(); 143 | 144 | blackImageStats.levels.mean.should.be.equal(blackImageStats.levels.median); 145 | whiteImageStats.levels.mean.should.be.equal(whiteImageStats.levels.median); 146 | 147 | blackHistogram.getStats(25, 235).should.be.deepEqual(whiteHistogram.getStats(25, 235)); 148 | }); 149 | }); 150 | 151 | describe('#multilevelThresholding', function() { 152 | it('calculates correct thresholds', function() { 153 | assert.deepEqual(histogram.multilevelThresholding(1), [111]); 154 | assert.deepEqual(histogram.multilevelThresholding(2), [ 92, 154 ]); 155 | assert.deepEqual(histogram.multilevelThresholding(3), [ 73, 121, 168 ]); 156 | }); 157 | 158 | it('works for histogram segment', function() { 159 | assert.deepEqual(histogram.multilevelThresholding(2, 60, 180), [ 103, 138 ]); 160 | }); 161 | 162 | it('calculates as many thresholds as can be fit in given range', function() { 163 | assert.deepEqual(histogram.multilevelThresholding(2, 102, 106), [ 103, 104 ]); 164 | assert.deepEqual(histogram.multilevelThresholding(2, 103, 106), [ 104 ]); 165 | }); 166 | 167 | it('returns empty array if no colors from histogram segment is present on the image', function() { 168 | assert.deepEqual(histogram.multilevelThresholding(3, 2, 14), []); 169 | }); 170 | 171 | it('throws error if range start is larger than range end', function() { 172 | (function() { 173 | histogram.multilevelThresholding(2, 180, 60); 174 | }).should.throw(); 175 | }); 176 | 177 | }); 178 | }); 179 | 180 | describe('Potrace class', function() { 181 | var jimpInstance = null; 182 | 183 | this.timeout(10000); 184 | 185 | before(function(done) { 186 | Jimp.read(PATH_TO_YAO, function(err, img) { 187 | if (err) { 188 | return done(err); 189 | } 190 | 191 | jimpInstance = img; 192 | done(); 193 | }); 194 | }); 195 | 196 | describe('#loadImage', function() { 197 | it('instance is being passed to callback function as context', function(done) { 198 | var instance = new Potrace(); 199 | 200 | instance.loadImage(PATH_TO_YAO, function(err) { 201 | this.should.be.an.instanceOf(Potrace).and.be.equal(instance); 202 | done(err); 203 | }); 204 | }); 205 | 206 | it('supports Jimp instances provided as source image', function(done) { 207 | var instance = new Potrace(); 208 | 209 | instance.loadImage(jimpInstance, done); 210 | }); 211 | 212 | it('should throw error if called before previous image was loaded', function(done) { 213 | function onImageLoad() { 214 | if (firstFinished && secondFinished) { 215 | done(); 216 | } 217 | } 218 | 219 | var potraceInstance = new Potrace(); 220 | var firstFinished = false; 221 | var secondFinished = false; 222 | 223 | potraceInstance.loadImage(PATH_TO_LENNA, function(err) { 224 | firstFinished = true; 225 | should(function() { should.ifError(err); }).throw(/another.*instead/i); 226 | onImageLoad(); 227 | }); 228 | 229 | potraceInstance.loadImage(PATH_TO_YAO, function(err) { 230 | secondFinished = true; 231 | should(function() { should.ifError(err); }).not.throw(); 232 | onImageLoad(); 233 | }); 234 | }); 235 | }); 236 | 237 | describe('#_processPath', function() { 238 | var instance = new Potrace(); 239 | var processingSpy = null; 240 | 241 | before(function() { 242 | processingSpy = instance._processPath = sinon.spy(Potrace.prototype._processPath); 243 | }); 244 | 245 | it('should not execute until path is requested for the first time', function(done) { 246 | instance.loadImage(jimpInstance, function() { 247 | processingSpy.should.have.callCount(0); 248 | this.getSVG(); 249 | processingSpy.should.have.callCount(1); 250 | done(); 251 | }); 252 | }); 253 | 254 | it('should not execute on repetitive SVG/Symbol export', function() { 255 | instance.loadImage(jimpInstance, function() { 256 | var initialCallCount = processingSpy.callCount; 257 | 258 | this.getSVG(); 259 | this.getSVG(); 260 | this.getPathTag(); 261 | this.getPathTag('red'); 262 | this.getSymbol('symbol-id'); 263 | processingSpy.should.have.callCount(initialCallCount); 264 | }); 265 | }); 266 | 267 | it('should not execute after change of foreground/background colors', function() { 268 | instance.loadImage(jimpInstance, function() { 269 | var initialCallCount = processingSpy.callCount; 270 | 271 | this.setParameters({ color: 'red' }); 272 | this.getSVG(); 273 | 274 | this.setParameters({ background: 'crimson' }); 275 | this.getSVG(); 276 | 277 | processingSpy.should.have.callCount(initialCallCount); 278 | }); 279 | }); 280 | }); 281 | 282 | describe('#getSVG', function() { 283 | var instanceYao = new Potrace(); 284 | 285 | before(function(done) { 286 | instanceYao.loadImage(jimpInstance, done); 287 | }); 288 | 289 | it('produces expected results with different thresholds', function() { 290 | var expected; 291 | 292 | expected = fs.readFileSync('./reference-copies/potrace-bw-threshold-128.svg', { encoding: 'utf8' }); 293 | instanceYao.setParameters({ threshold: 128 }); 294 | assert.equal(instanceYao.getSVG(), expected, 'Image with threshold 128 does not match with reference copy'); 295 | 296 | expected = fs.readFileSync('./reference-copies/potrace-bw-threshold-65.svg', { encoding: 'utf8' }); 297 | instanceYao.setParameters({ threshold: 65 }); 298 | assert.equal(instanceYao.getSVG(), expected, 'Image with threshold 65 does not match with reference copy'); 299 | 300 | expected = fs.readFileSync('./reference-copies/potrace-bw-threshold-170.svg', { encoding: 'utf8' }); 301 | instanceYao.setParameters({ threshold: 170 }); 302 | assert.equal(instanceYao.getSVG(), expected, 'Image with threshold 170 does not match with reference copy'); 303 | }); 304 | 305 | it('produces expected white on black image with threshold 170', function(done) { 306 | var instance = new Potrace({ 307 | threshold: 128, 308 | blackOnWhite: false, 309 | color: 'cyan', 310 | background: 'darkred' 311 | }); 312 | 313 | instance.loadImage(PATH_TO_BLACK_AND_WHITE_IMAGE, function(err) { 314 | if (err) return done(err); 315 | 316 | var expected = fs.readFileSync('./reference-copies/potrace-wb-threshold-128.svg', { encoding: 'utf8' }); 317 | var actual = instance.getSVG(); 318 | 319 | assert.equal(actual, expected); 320 | done(); 321 | }); 322 | }); 323 | }); 324 | 325 | describe('#getSymbol', function() { 326 | var instanceYao = new Potrace(); 327 | 328 | before(function(done) { 329 | instanceYao.loadImage(jimpInstance, done); 330 | }); 331 | 332 | it('should not have fill color or background', function() { 333 | instanceYao.setParameters({ 334 | color: 'red', 335 | background: 'cyan' 336 | }); 337 | 338 | var symbol = instanceYao.getSymbol('whatever'); 339 | 340 | symbol.should.not.match(/]+(?:fill="\s*"|fill='\s*'|)[^>]*>/i); 342 | }); 343 | }); 344 | 345 | describe('behaves predictably in edge cases', function() { 346 | var instance = new Potrace(); 347 | 348 | var bwBlackThreshold0; 349 | var bwBlackThreshold255; 350 | var bwWhiteThreshold0; 351 | var bwWhiteThreshold255; 352 | var wbWhiteThreshold0; 353 | var wbWhiteThreshold255; 354 | var wbBlackThreshold0; 355 | var wbBlackThreshold255; 356 | 357 | before(function() { 358 | bwBlackThreshold0 = fs.readFileSync('./reference-copies/potrace-bw-black-threshold-0.svg', { encoding: 'utf8' }); 359 | bwBlackThreshold255 = fs.readFileSync('./reference-copies/potrace-bw-black-threshold-255.svg', { encoding: 'utf8' }); 360 | bwWhiteThreshold0 = fs.readFileSync('./reference-copies/potrace-bw-white-threshold-0.svg', { encoding: 'utf8' }); 361 | bwWhiteThreshold255 = fs.readFileSync('./reference-copies/potrace-bw-white-threshold-255.svg', { encoding: 'utf8' }); 362 | 363 | wbWhiteThreshold0 = fs.readFileSync('./reference-copies/potrace-wb-white-threshold-0.svg', { encoding: 'utf8' }); 364 | wbWhiteThreshold255 = fs.readFileSync('./reference-copies/potrace-wb-white-threshold-255.svg', { encoding: 'utf8' }); 365 | wbBlackThreshold0 = fs.readFileSync('./reference-copies/potrace-wb-black-threshold-0.svg', { encoding: 'utf8' }); 366 | wbBlackThreshold255 = fs.readFileSync('./reference-copies/potrace-wb-black-threshold-255.svg', { encoding: 'utf8' }); 367 | }); 368 | 369 | it('compares colors against threshold in the same way as original tool', function(done) { 370 | instance.loadImage(blackImage, function(err) { 371 | if (err) { return done(err); } 372 | 373 | instance.setParameters({ blackOnWhite: true, threshold: 0 }); 374 | instance.getSVG().should.be.equal(bwBlackThreshold0); 375 | 376 | instance.setParameters({ blackOnWhite: true, threshold: 255 }); 377 | instance.getSVG().should.be.equal(bwBlackThreshold255); 378 | 379 | instance.loadImage(whiteImage, function() { 380 | if (err) { return done(err); } 381 | 382 | instance.setParameters({ blackOnWhite: true, threshold: 0 }); 383 | instance.getSVG().should.be.equal(bwWhiteThreshold0); 384 | 385 | instance.setParameters({ blackOnWhite: true, threshold: 255 }); 386 | instance.getSVG().should.be.equal(bwWhiteThreshold255); 387 | 388 | done(); 389 | }); 390 | }); 391 | }); 392 | 393 | it('acts in the same way when colors are inverted', function(done) { 394 | instance.loadImage(whiteImage, function(err) { 395 | if (err) { return done(err); } 396 | instance.setParameters({ blackOnWhite: false, threshold: 255 }); 397 | instance.getSVG().should.be.equal(wbWhiteThreshold255); 398 | 399 | instance.setParameters({ blackOnWhite: false, threshold: 0 }); 400 | instance.getSVG().should.be.equal(wbWhiteThreshold0); 401 | 402 | instance.loadImage(blackImage, function() { 403 | if (err) { return done(err); } 404 | 405 | instance.setParameters({ blackOnWhite: false, threshold: 255 }); 406 | instance.getSVG().should.be.equal(wbBlackThreshold255); 407 | 408 | instance.setParameters({ blackOnWhite: false, threshold: 0 }); 409 | instance.getSVG().should.be.equal(wbBlackThreshold0); 410 | 411 | done(); 412 | }); 413 | }); 414 | }); 415 | }); 416 | }); 417 | 418 | describe('Posterizer class', function() { 419 | var jimpInstance = null; 420 | var sharedPosterizerInstance = new Posterizer(); 421 | 422 | this.timeout(10000); 423 | 424 | before(function(done) { 425 | Jimp.read(PATH_TO_YAO, function(err, img) { 426 | if (err) { 427 | return done(err); 428 | } 429 | 430 | jimpInstance = img; 431 | done(); 432 | }); 433 | }); 434 | 435 | describe('#_getRanges', function() { 436 | var posterizer = new Posterizer(); 437 | 438 | function getColorStops() { 439 | return posterizer._getRanges().map(function(item) { 440 | return item.value; 441 | }); 442 | } 443 | 444 | before(function(done) { 445 | posterizer.loadImage(PATH_TO_YAO, done); 446 | }); 447 | 448 | it('returns correctly calculated color stops with "equally spread" distribution', function() { 449 | posterizer.setParameters({ 450 | rangeDistribution: Posterizer.RANGES_EQUAL, 451 | threshold: 200, 452 | steps: 4, 453 | blackOnWhite: true 454 | }); 455 | 456 | getColorStops().should.be.deepEqual([200, 150, 100, 50]); 457 | 458 | posterizer.setParameters({ 459 | rangeDistribution: Posterizer.RANGES_EQUAL, 460 | threshold: 155, 461 | steps: 4, 462 | blackOnWhite: false 463 | }); 464 | 465 | getColorStops().should.be.deepEqual([155, 180, 205, 230]); 466 | 467 | posterizer.setParameters({ 468 | rangeDistribution: Posterizer.RANGES_EQUAL, 469 | threshold: Potrace.THRESHOLD_AUTO, 470 | steps: 4, 471 | blackOnWhite: true 472 | }); 473 | 474 | getColorStops().should.be.deepEqual([206, 154.5, 103, 51.5]); 475 | }); 476 | 477 | it('returns correctly calculated color stops with "auto" distribution', function() { 478 | posterizer.setParameters({ 479 | rangeDistribution: Posterizer.RANGES_AUTO, 480 | threshold: Potrace.THRESHOLD_AUTO, 481 | steps: 3, 482 | blackOnWhite: true 483 | }); 484 | 485 | getColorStops().should.be.deepEqual([219, 156, 71]); 486 | 487 | posterizer.setParameters({ 488 | rangeDistribution: Posterizer.RANGES_AUTO, 489 | threshold: Potrace.THRESHOLD_AUTO, 490 | steps: 3, 491 | blackOnWhite: false 492 | }); 493 | 494 | getColorStops().should.be.deepEqual([71, 156, 219]); 495 | 496 | // Now with predefined threshold 497 | 498 | posterizer.setParameters({ 499 | rangeDistribution: Posterizer.RANGES_AUTO, 500 | threshold: 128, 501 | steps: 4, 502 | blackOnWhite: true 503 | }); 504 | 505 | getColorStops().should.be.deepEqual([128, 97, 62, 24]); 506 | 507 | posterizer.setParameters({ 508 | rangeDistribution: Posterizer.RANGES_AUTO, 509 | threshold: 128, 510 | steps: 4, 511 | blackOnWhite: false 512 | }); 513 | 514 | getColorStops().should.be.deepEqual([128, 166, 203, 237]); 515 | }); 516 | 517 | it('correctly handles predefined array of color stops', function() { 518 | posterizer.setParameters({ 519 | steps: [20, 60, 80, 160], 520 | threshold: 120, 521 | blackOnWhite: true 522 | }); 523 | 524 | getColorStops().should.be.deepEqual([160, 80, 60, 20]); 525 | 526 | posterizer.setParameters({ 527 | steps: [20, 60, 80, 160], 528 | threshold: 180, 529 | blackOnWhite: true 530 | }); 531 | 532 | getColorStops().should.be.deepEqual([180, 160, 80, 60, 20]); 533 | 534 | posterizer.setParameters({ 535 | steps: [20, 60, 80, 160], 536 | threshold: 180, 537 | blackOnWhite: false 538 | }); 539 | 540 | getColorStops().should.be.deepEqual([20, 60, 80, 160, 180]); 541 | 542 | posterizer.setParameters({ 543 | steps: [212, 16, 26, 50, 212, 128, 211], 544 | threshold: 180, 545 | blackOnWhite: false 546 | }); 547 | 548 | getColorStops().should.be.deepEqual([16, 26, 50, 128, 211, 212], 'Duplicated items should be present only once'); 549 | 550 | posterizer.setParameters({ 551 | steps: [15, 42, 200, 460, 0, -10], 552 | threshold: 180, 553 | blackOnWhite: false 554 | }); 555 | 556 | getColorStops().should.be.deepEqual([0, 15, 42, 200], 'Values out of range should be ignored'); 557 | }); 558 | }); 559 | 560 | describe('#loadImage', function() { 561 | it('instance is being passed to callback function as context', function(done) { 562 | sharedPosterizerInstance.loadImage(PATH_TO_YAO, function(err) { 563 | this.should.be.an.instanceOf(Posterizer).and.be.equal(sharedPosterizerInstance); 564 | done(err); 565 | }); 566 | }); 567 | }); 568 | 569 | describe('#getSVG', function() { 570 | var instanceYao = sharedPosterizerInstance; 571 | 572 | it('produces expected results with different thresholds', function() { 573 | var expected; 574 | 575 | instanceYao.setParameters({ threshold: 128 }); 576 | expected = fs.readFileSync('./reference-copies/posterized-yao-black-threshold-128.svg', { encoding: 'utf8' }); 577 | assert.equal(instanceYao.getSVG(), expected, 'Image with threshold 128 does not match with reference copy'); 578 | 579 | instanceYao.setParameters({ threshold: 65 }); 580 | expected = fs.readFileSync('./reference-copies/posterized-yao-black-threshold-65.svg', { encoding: 'utf8' }); 581 | assert.equal(instanceYao.getSVG(), expected, 'Image with threshold 65 does not match with reference copy'); 582 | 583 | instanceYao.setParameters({ threshold: 170 }); 584 | expected = fs.readFileSync('./reference-copies/posterized-yao-black-threshold-170.svg', { encoding: 'utf8' }); 585 | assert.equal(instanceYao.getSVG(), expected, 'Image with threshold 170 does not match with reference copy'); 586 | }); 587 | 588 | it('produces expected white on black image with threshold 170', function(done) { 589 | var instance = new Posterizer({ 590 | threshold: 40, 591 | blackOnWhite: false, 592 | steps: 3, 593 | color: 'beige', 594 | background: '#222' 595 | }); 596 | 597 | instance.loadImage('sources/clouds.jpg', function(err) { 598 | if (err) return done(err); 599 | 600 | var expected = fs.readFileSync('./reference-copies/posterized-clouds-white-40.svg', { encoding: 'utf8' }); 601 | var actual = instance.getSVG(); 602 | 603 | assert.equal(actual, expected); 604 | done(); 605 | }); 606 | }); 607 | }); 608 | 609 | describe('#getSymbol', function() { 610 | var instanceYao = new Posterizer(); 611 | 612 | before(function(done) { 613 | instanceYao.loadImage(jimpInstance, done); 614 | }); 615 | 616 | it('should not have fill color or background', function() { 617 | instanceYao.setParameters({ 618 | color: 'red', 619 | background: 'cyan', 620 | steps: 3 621 | }); 622 | 623 | var symbol = instanceYao.getSymbol('whatever'); 624 | 625 | symbol.should.not.match(/]+(?:fill="\s*"|fill='\s*'|)[^>]*>/i); 627 | }); 628 | }); 629 | 630 | describe('edge cases', function() { 631 | var instance = new Posterizer(); 632 | 633 | it('does not break on images filled with one color', function(done) { 634 | instance.loadImage(blackImage, function(err) { 635 | if (err) { return done(err); } 636 | 637 | // black image should give us one black layer... 638 | instance.setParameters({ blackOnWhite: true, threshold: 128 }); 639 | instance.getSVG().should.match(/