glutamatt HF Staff commited on
Commit
ecdd7f6
·
verified ·
1 Parent(s): 9033537

Initial deployment of QuickChart service

Browse files
Files changed (22) hide show
  1. .dockerignore +1 -0
  2. .github/FUNDING.yml +1 -0
  3. .gitignore +129 -0
  4. .nvmrc +1 -0
  5. .prettierrc.yaml +3 -0
  6. .travis.yml +5 -0
  7. Dockerfile +31 -0
  8. LICENSE +661 -0
  9. README.md +5 -7
  10. index.js +492 -0
  11. lefthook.yml +6 -0
  12. lib/charts.js +438 -0
  13. lib/google_image_charts.js +958 -0
  14. lib/graphviz.js +31 -0
  15. lib/pdf.js +63 -0
  16. lib/qr.js +53 -0
  17. lib/svg.js +20 -0
  18. lib/util.js +22 -0
  19. logging.js +10 -0
  20. package.json +71 -0
  21. telemetry.js +87 -0
  22. yarn.lock +0 -0
.dockerignore ADDED
@@ -0,0 +1 @@
 
 
1
+ node_modules
.github/FUNDING.yml ADDED
@@ -0,0 +1 @@
 
 
1
+ github: typpo
.gitignore ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ venv
2
+
3
+ ### Vim ###
4
+ [._]*.s[a-w][a-z]
5
+ [._]s[a-w][a-z]
6
+ *.un~
7
+ Session.vim
8
+ .netrwhist
9
+ *~
10
+
11
+
12
+ ### OSX ###
13
+ .DS_Store
14
+ .AppleDouble
15
+ .LSOverride
16
+
17
+ # Icon must end with two \r
18
+ Icon
19
+
20
+
21
+ # Thumbnails
22
+ ._*
23
+
24
+ # Files that might appear in the root of a volume
25
+ .DocumentRevisions-V100
26
+ .fseventsd
27
+ .Spotlight-V100
28
+ .TemporaryItems
29
+ .Trashes
30
+ .VolumeIcon.icns
31
+
32
+ # Directories potentially created on remote AFP share
33
+ .AppleDB
34
+ .AppleDesktop
35
+ Network Trash Folder
36
+ Temporary Items
37
+ .apdisk
38
+
39
+
40
+ ### Python ###
41
+ # Byte-compiled / optimized / DLL files
42
+ __pycache__/
43
+ *.py[cod]
44
+ *$py.class
45
+
46
+ # C extensions
47
+ *.so
48
+
49
+ # Distribution / packaging
50
+ .Python
51
+ env/
52
+ build/
53
+ develop-eggs/
54
+ dist/
55
+ downloads/
56
+ eggs/
57
+ .eggs/
58
+ lib64/
59
+ parts/
60
+ sdist/
61
+ var/
62
+ *.egg-info/
63
+ .installed.cfg
64
+ *.egg
65
+
66
+ # PyInstaller
67
+ # Usually these files are written by a python script from a template
68
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
69
+ *.manifest
70
+ *.spec
71
+
72
+ # Installer logs
73
+ pip-log.txt
74
+ pip-delete-this-directory.txt
75
+
76
+ # Unit test / coverage reports
77
+ htmlcov/
78
+ .tox/
79
+ .coverage
80
+ .coverage.*
81
+ .cache
82
+ nosetests.xml
83
+ coverage.xml
84
+ *,cover
85
+
86
+ # Translations
87
+ *.mo
88
+ *.pot
89
+
90
+ # Django stuff:
91
+ *.log
92
+
93
+ # Sphinx documentation
94
+ docs/_build/
95
+
96
+ # PyBuilder
97
+ target/
98
+
99
+
100
+ ### Node ###
101
+ # Logs
102
+ logs
103
+ *.log
104
+ npm-debug.log*
105
+
106
+ # Runtime data
107
+ pids
108
+ *.pid
109
+ *.seed
110
+
111
+ # Directory for instrumented libs generated by jscoverage/JSCover
112
+ lib-cov
113
+
114
+ # Coverage directory used by tools like istanbul
115
+ coverage
116
+
117
+ # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
118
+ .grunt
119
+
120
+ # node-waf configuration
121
+ .lock-wscript
122
+
123
+ # Compiled binary addons (http://nodejs.org/api/addons.html)
124
+ build/Release
125
+
126
+ # Dependency directory
127
+ # https://docs.npmjs.com/misc/faq#should-i-check-my-node-modules-folder-into-git
128
+ node_modules
129
+
.nvmrc ADDED
@@ -0,0 +1 @@
 
 
1
+ v18.16.0
.prettierrc.yaml ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ trailingComma: "all"
2
+ singleQuote: true
3
+ printWidth: 100
.travis.yml ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ language: node_js
2
+ node_js:
3
+ - 12
4
+ cache: yarn
5
+
Dockerfile ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:18-alpine3.17
2
+
3
+ ENV NODE_ENV production
4
+
5
+ WORKDIR /quickchart
6
+
7
+ RUN apk add --upgrade apk-tools
8
+ RUN apk add --no-cache --virtual .build-deps yarn git build-base g++ python3
9
+ RUN apk add --no-cache --virtual .npm-deps cairo-dev pango-dev libjpeg-turbo-dev librsvg-dev
10
+ RUN apk add --no-cache --virtual .fonts libmount ttf-dejavu ttf-droid ttf-freefont ttf-liberation font-noto font-noto-emoji fontconfig
11
+ RUN apk add --no-cache --repository https://dl-cdn.alpinelinux.org/alpine/edge/community font-wqy-zenhei
12
+ RUN apk add --no-cache libimagequant-dev
13
+ RUN apk add --no-cache vips-dev
14
+ RUN apk add --no-cache --virtual .runtime-deps graphviz
15
+
16
+ COPY package*.json .
17
+ COPY yarn.lock .
18
+ RUN yarn install --production
19
+
20
+ RUN apk update
21
+ RUN rm -rf /var/cache/apk/* && \
22
+ rm -rf /tmp/*
23
+ RUN apk del .build-deps
24
+
25
+ COPY *.js ./
26
+ COPY lib/*.js lib/
27
+ COPY LICENSE .
28
+
29
+ EXPOSE 3400
30
+
31
+ ENTRYPOINT ["node", "--max-http-header-size=65536", "index.js"]
LICENSE ADDED
@@ -0,0 +1,661 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ GNU AFFERO GENERAL PUBLIC LICENSE
2
+ Version 3, 19 November 2007
3
+
4
+ Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
5
+ Everyone is permitted to copy and distribute verbatim copies
6
+ of this license document, but changing it is not allowed.
7
+
8
+ Preamble
9
+
10
+ The GNU Affero General Public License is a free, copyleft license for
11
+ software and other kinds of works, specifically designed to ensure
12
+ cooperation with the community in the case of network server software.
13
+
14
+ The licenses for most software and other practical works are designed
15
+ to take away your freedom to share and change the works. By contrast,
16
+ our General Public Licenses are intended to guarantee your freedom to
17
+ share and change all versions of a program--to make sure it remains free
18
+ software for all its users.
19
+
20
+ When we speak of free software, we are referring to freedom, not
21
+ price. Our General Public Licenses are designed to make sure that you
22
+ have the freedom to distribute copies of free software (and charge for
23
+ them if you wish), that you receive source code or can get it if you
24
+ want it, that you can change the software or use pieces of it in new
25
+ free programs, and that you know you can do these things.
26
+
27
+ Developers that use our General Public Licenses protect your rights
28
+ with two steps: (1) assert copyright on the software, and (2) offer
29
+ you this License which gives you legal permission to copy, distribute
30
+ and/or modify the software.
31
+
32
+ A secondary benefit of defending all users' freedom is that
33
+ improvements made in alternate versions of the program, if they
34
+ receive widespread use, become available for other developers to
35
+ incorporate. Many developers of free software are heartened and
36
+ encouraged by the resulting cooperation. However, in the case of
37
+ software used on network servers, this result may fail to come about.
38
+ The GNU General Public License permits making a modified version and
39
+ letting the public access it on a server without ever releasing its
40
+ source code to the public.
41
+
42
+ The GNU Affero General Public License is designed specifically to
43
+ ensure that, in such cases, the modified source code becomes available
44
+ to the community. It requires the operator of a network server to
45
+ provide the source code of the modified version running there to the
46
+ users of that server. Therefore, public use of a modified version, on
47
+ a publicly accessible server, gives the public access to the source
48
+ code of the modified version.
49
+
50
+ An older license, called the Affero General Public License and
51
+ published by Affero, was designed to accomplish similar goals. This is
52
+ a different license, not a version of the Affero GPL, but Affero has
53
+ released a new version of the Affero GPL which permits relicensing under
54
+ this license.
55
+
56
+ The precise terms and conditions for copying, distribution and
57
+ modification follow.
58
+
59
+ TERMS AND CONDITIONS
60
+
61
+ 0. Definitions.
62
+
63
+ "This License" refers to version 3 of the GNU Affero General Public License.
64
+
65
+ "Copyright" also means copyright-like laws that apply to other kinds of
66
+ works, such as semiconductor masks.
67
+
68
+ "The Program" refers to any copyrightable work licensed under this
69
+ License. Each licensee is addressed as "you". "Licensees" and
70
+ "recipients" may be individuals or organizations.
71
+
72
+ To "modify" a work means to copy from or adapt all or part of the work
73
+ in a fashion requiring copyright permission, other than the making of an
74
+ exact copy. The resulting work is called a "modified version" of the
75
+ earlier work or a work "based on" the earlier work.
76
+
77
+ A "covered work" means either the unmodified Program or a work based
78
+ on the Program.
79
+
80
+ To "propagate" a work means to do anything with it that, without
81
+ permission, would make you directly or secondarily liable for
82
+ infringement under applicable copyright law, except executing it on a
83
+ computer or modifying a private copy. Propagation includes copying,
84
+ distribution (with or without modification), making available to the
85
+ public, and in some countries other activities as well.
86
+
87
+ To "convey" a work means any kind of propagation that enables other
88
+ parties to make or receive copies. Mere interaction with a user through
89
+ a computer network, with no transfer of a copy, is not conveying.
90
+
91
+ An interactive user interface displays "Appropriate Legal Notices"
92
+ to the extent that it includes a convenient and prominently visible
93
+ feature that (1) displays an appropriate copyright notice, and (2)
94
+ tells the user that there is no warranty for the work (except to the
95
+ extent that warranties are provided), that licensees may convey the
96
+ work under this License, and how to view a copy of this License. If
97
+ the interface presents a list of user commands or options, such as a
98
+ menu, a prominent item in the list meets this criterion.
99
+
100
+ 1. Source Code.
101
+
102
+ The "source code" for a work means the preferred form of the work
103
+ for making modifications to it. "Object code" means any non-source
104
+ form of a work.
105
+
106
+ A "Standard Interface" means an interface that either is an official
107
+ standard defined by a recognized standards body, or, in the case of
108
+ interfaces specified for a particular programming language, one that
109
+ is widely used among developers working in that language.
110
+
111
+ The "System Libraries" of an executable work include anything, other
112
+ than the work as a whole, that (a) is included in the normal form of
113
+ packaging a Major Component, but which is not part of that Major
114
+ Component, and (b) serves only to enable use of the work with that
115
+ Major Component, or to implement a Standard Interface for which an
116
+ implementation is available to the public in source code form. A
117
+ "Major Component", in this context, means a major essential component
118
+ (kernel, window system, and so on) of the specific operating system
119
+ (if any) on which the executable work runs, or a compiler used to
120
+ produce the work, or an object code interpreter used to run it.
121
+
122
+ The "Corresponding Source" for a work in object code form means all
123
+ the source code needed to generate, install, and (for an executable
124
+ work) run the object code and to modify the work, including scripts to
125
+ control those activities. However, it does not include the work's
126
+ System Libraries, or general-purpose tools or generally available free
127
+ programs which are used unmodified in performing those activities but
128
+ which are not part of the work. For example, Corresponding Source
129
+ includes interface definition files associated with source files for
130
+ the work, and the source code for shared libraries and dynamically
131
+ linked subprograms that the work is specifically designed to require,
132
+ such as by intimate data communication or control flow between those
133
+ subprograms and other parts of the work.
134
+
135
+ The Corresponding Source need not include anything that users
136
+ can regenerate automatically from other parts of the Corresponding
137
+ Source.
138
+
139
+ The Corresponding Source for a work in source code form is that
140
+ same work.
141
+
142
+ 2. Basic Permissions.
143
+
144
+ All rights granted under this License are granted for the term of
145
+ copyright on the Program, and are irrevocable provided the stated
146
+ conditions are met. This License explicitly affirms your unlimited
147
+ permission to run the unmodified Program. The output from running a
148
+ covered work is covered by this License only if the output, given its
149
+ content, constitutes a covered work. This License acknowledges your
150
+ rights of fair use or other equivalent, as provided by copyright law.
151
+
152
+ You may make, run and propagate covered works that you do not
153
+ convey, without conditions so long as your license otherwise remains
154
+ in force. You may convey covered works to others for the sole purpose
155
+ of having them make modifications exclusively for you, or provide you
156
+ with facilities for running those works, provided that you comply with
157
+ the terms of this License in conveying all material for which you do
158
+ not control copyright. Those thus making or running the covered works
159
+ for you must do so exclusively on your behalf, under your direction
160
+ and control, on terms that prohibit them from making any copies of
161
+ your copyrighted material outside their relationship with you.
162
+
163
+ Conveying under any other circumstances is permitted solely under
164
+ the conditions stated below. Sublicensing is not allowed; section 10
165
+ makes it unnecessary.
166
+
167
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
168
+
169
+ No covered work shall be deemed part of an effective technological
170
+ measure under any applicable law fulfilling obligations under article
171
+ 11 of the WIPO copyright treaty adopted on 20 December 1996, or
172
+ similar laws prohibiting or restricting circumvention of such
173
+ measures.
174
+
175
+ When you convey a covered work, you waive any legal power to forbid
176
+ circumvention of technological measures to the extent such circumvention
177
+ is effected by exercising rights under this License with respect to
178
+ the covered work, and you disclaim any intention to limit operation or
179
+ modification of the work as a means of enforcing, against the work's
180
+ users, your or third parties' legal rights to forbid circumvention of
181
+ technological measures.
182
+
183
+ 4. Conveying Verbatim Copies.
184
+
185
+ You may convey verbatim copies of the Program's source code as you
186
+ receive it, in any medium, provided that you conspicuously and
187
+ appropriately publish on each copy an appropriate copyright notice;
188
+ keep intact all notices stating that this License and any
189
+ non-permissive terms added in accord with section 7 apply to the code;
190
+ keep intact all notices of the absence of any warranty; and give all
191
+ recipients a copy of this License along with the Program.
192
+
193
+ You may charge any price or no price for each copy that you convey,
194
+ and you may offer support or warranty protection for a fee.
195
+
196
+ 5. Conveying Modified Source Versions.
197
+
198
+ You may convey a work based on the Program, or the modifications to
199
+ produce it from the Program, in the form of source code under the
200
+ terms of section 4, provided that you also meet all of these conditions:
201
+
202
+ a) The work must carry prominent notices stating that you modified
203
+ it, and giving a relevant date.
204
+
205
+ b) The work must carry prominent notices stating that it is
206
+ released under this License and any conditions added under section
207
+ 7. This requirement modifies the requirement in section 4 to
208
+ "keep intact all notices".
209
+
210
+ c) You must license the entire work, as a whole, under this
211
+ License to anyone who comes into possession of a copy. This
212
+ License will therefore apply, along with any applicable section 7
213
+ additional terms, to the whole of the work, and all its parts,
214
+ regardless of how they are packaged. This License gives no
215
+ permission to license the work in any other way, but it does not
216
+ invalidate such permission if you have separately received it.
217
+
218
+ d) If the work has interactive user interfaces, each must display
219
+ Appropriate Legal Notices; however, if the Program has interactive
220
+ interfaces that do not display Appropriate Legal Notices, your
221
+ work need not make them do so.
222
+
223
+ A compilation of a covered work with other separate and independent
224
+ works, which are not by their nature extensions of the covered work,
225
+ and which are not combined with it such as to form a larger program,
226
+ in or on a volume of a storage or distribution medium, is called an
227
+ "aggregate" if the compilation and its resulting copyright are not
228
+ used to limit the access or legal rights of the compilation's users
229
+ beyond what the individual works permit. Inclusion of a covered work
230
+ in an aggregate does not cause this License to apply to the other
231
+ parts of the aggregate.
232
+
233
+ 6. Conveying Non-Source Forms.
234
+
235
+ You may convey a covered work in object code form under the terms
236
+ of sections 4 and 5, provided that you also convey the
237
+ machine-readable Corresponding Source under the terms of this License,
238
+ in one of these ways:
239
+
240
+ a) Convey the object code in, or embodied in, a physical product
241
+ (including a physical distribution medium), accompanied by the
242
+ Corresponding Source fixed on a durable physical medium
243
+ customarily used for software interchange.
244
+
245
+ b) Convey the object code in, or embodied in, a physical product
246
+ (including a physical distribution medium), accompanied by a
247
+ written offer, valid for at least three years and valid for as
248
+ long as you offer spare parts or customer support for that product
249
+ model, to give anyone who possesses the object code either (1) a
250
+ copy of the Corresponding Source for all the software in the
251
+ product that is covered by this License, on a durable physical
252
+ medium customarily used for software interchange, for a price no
253
+ more than your reasonable cost of physically performing this
254
+ conveying of source, or (2) access to copy the
255
+ Corresponding Source from a network server at no charge.
256
+
257
+ c) Convey individual copies of the object code with a copy of the
258
+ written offer to provide the Corresponding Source. This
259
+ alternative is allowed only occasionally and noncommercially, and
260
+ only if you received the object code with such an offer, in accord
261
+ with subsection 6b.
262
+
263
+ d) Convey the object code by offering access from a designated
264
+ place (gratis or for a charge), and offer equivalent access to the
265
+ Corresponding Source in the same way through the same place at no
266
+ further charge. You need not require recipients to copy the
267
+ Corresponding Source along with the object code. If the place to
268
+ copy the object code is a network server, the Corresponding Source
269
+ may be on a different server (operated by you or a third party)
270
+ that supports equivalent copying facilities, provided you maintain
271
+ clear directions next to the object code saying where to find the
272
+ Corresponding Source. Regardless of what server hosts the
273
+ Corresponding Source, you remain obligated to ensure that it is
274
+ available for as long as needed to satisfy these requirements.
275
+
276
+ e) Convey the object code using peer-to-peer transmission, provided
277
+ you inform other peers where the object code and Corresponding
278
+ Source of the work are being offered to the general public at no
279
+ charge under subsection 6d.
280
+
281
+ A separable portion of the object code, whose source code is excluded
282
+ from the Corresponding Source as a System Library, need not be
283
+ included in conveying the object code work.
284
+
285
+ A "User Product" is either (1) a "consumer product", which means any
286
+ tangible personal property which is normally used for personal, family,
287
+ or household purposes, or (2) anything designed or sold for incorporation
288
+ into a dwelling. In determining whether a product is a consumer product,
289
+ doubtful cases shall be resolved in favor of coverage. For a particular
290
+ product received by a particular user, "normally used" refers to a
291
+ typical or common use of that class of product, regardless of the status
292
+ of the particular user or of the way in which the particular user
293
+ actually uses, or expects or is expected to use, the product. A product
294
+ is a consumer product regardless of whether the product has substantial
295
+ commercial, industrial or non-consumer uses, unless such uses represent
296
+ the only significant mode of use of the product.
297
+
298
+ "Installation Information" for a User Product means any methods,
299
+ procedures, authorization keys, or other information required to install
300
+ and execute modified versions of a covered work in that User Product from
301
+ a modified version of its Corresponding Source. The information must
302
+ suffice to ensure that the continued functioning of the modified object
303
+ code is in no case prevented or interfered with solely because
304
+ modification has been made.
305
+
306
+ If you convey an object code work under this section in, or with, or
307
+ specifically for use in, a User Product, and the conveying occurs as
308
+ part of a transaction in which the right of possession and use of the
309
+ User Product is transferred to the recipient in perpetuity or for a
310
+ fixed term (regardless of how the transaction is characterized), the
311
+ Corresponding Source conveyed under this section must be accompanied
312
+ by the Installation Information. But this requirement does not apply
313
+ if neither you nor any third party retains the ability to install
314
+ modified object code on the User Product (for example, the work has
315
+ been installed in ROM).
316
+
317
+ The requirement to provide Installation Information does not include a
318
+ requirement to continue to provide support service, warranty, or updates
319
+ for a work that has been modified or installed by the recipient, or for
320
+ the User Product in which it has been modified or installed. Access to a
321
+ network may be denied when the modification itself materially and
322
+ adversely affects the operation of the network or violates the rules and
323
+ protocols for communication across the network.
324
+
325
+ Corresponding Source conveyed, and Installation Information provided,
326
+ in accord with this section must be in a format that is publicly
327
+ documented (and with an implementation available to the public in
328
+ source code form), and must require no special password or key for
329
+ unpacking, reading or copying.
330
+
331
+ 7. Additional Terms.
332
+
333
+ "Additional permissions" are terms that supplement the terms of this
334
+ License by making exceptions from one or more of its conditions.
335
+ Additional permissions that are applicable to the entire Program shall
336
+ be treated as though they were included in this License, to the extent
337
+ that they are valid under applicable law. If additional permissions
338
+ apply only to part of the Program, that part may be used separately
339
+ under those permissions, but the entire Program remains governed by
340
+ this License without regard to the additional permissions.
341
+
342
+ When you convey a copy of a covered work, you may at your option
343
+ remove any additional permissions from that copy, or from any part of
344
+ it. (Additional permissions may be written to require their own
345
+ removal in certain cases when you modify the work.) You may place
346
+ additional permissions on material, added by you to a covered work,
347
+ for which you have or can give appropriate copyright permission.
348
+
349
+ Notwithstanding any other provision of this License, for material you
350
+ add to a covered work, you may (if authorized by the copyright holders of
351
+ that material) supplement the terms of this License with terms:
352
+
353
+ a) Disclaiming warranty or limiting liability differently from the
354
+ terms of sections 15 and 16 of this License; or
355
+
356
+ b) Requiring preservation of specified reasonable legal notices or
357
+ author attributions in that material or in the Appropriate Legal
358
+ Notices displayed by works containing it; or
359
+
360
+ c) Prohibiting misrepresentation of the origin of that material, or
361
+ requiring that modified versions of such material be marked in
362
+ reasonable ways as different from the original version; or
363
+
364
+ d) Limiting the use for publicity purposes of names of licensors or
365
+ authors of the material; or
366
+
367
+ e) Declining to grant rights under trademark law for use of some
368
+ trade names, trademarks, or service marks; or
369
+
370
+ f) Requiring indemnification of licensors and authors of that
371
+ material by anyone who conveys the material (or modified versions of
372
+ it) with contractual assumptions of liability to the recipient, for
373
+ any liability that these contractual assumptions directly impose on
374
+ those licensors and authors.
375
+
376
+ All other non-permissive additional terms are considered "further
377
+ restrictions" within the meaning of section 10. If the Program as you
378
+ received it, or any part of it, contains a notice stating that it is
379
+ governed by this License along with a term that is a further
380
+ restriction, you may remove that term. If a license document contains
381
+ a further restriction but permits relicensing or conveying under this
382
+ License, you may add to a covered work material governed by the terms
383
+ of that license document, provided that the further restriction does
384
+ not survive such relicensing or conveying.
385
+
386
+ If you add terms to a covered work in accord with this section, you
387
+ must place, in the relevant source files, a statement of the
388
+ additional terms that apply to those files, or a notice indicating
389
+ where to find the applicable terms.
390
+
391
+ Additional terms, permissive or non-permissive, may be stated in the
392
+ form of a separately written license, or stated as exceptions;
393
+ the above requirements apply either way.
394
+
395
+ 8. Termination.
396
+
397
+ You may not propagate or modify a covered work except as expressly
398
+ provided under this License. Any attempt otherwise to propagate or
399
+ modify it is void, and will automatically terminate your rights under
400
+ this License (including any patent licenses granted under the third
401
+ paragraph of section 11).
402
+
403
+ However, if you cease all violation of this License, then your
404
+ license from a particular copyright holder is reinstated (a)
405
+ provisionally, unless and until the copyright holder explicitly and
406
+ finally terminates your license, and (b) permanently, if the copyright
407
+ holder fails to notify you of the violation by some reasonable means
408
+ prior to 60 days after the cessation.
409
+
410
+ Moreover, your license from a particular copyright holder is
411
+ reinstated permanently if the copyright holder notifies you of the
412
+ violation by some reasonable means, this is the first time you have
413
+ received notice of violation of this License (for any work) from that
414
+ copyright holder, and you cure the violation prior to 30 days after
415
+ your receipt of the notice.
416
+
417
+ Termination of your rights under this section does not terminate the
418
+ licenses of parties who have received copies or rights from you under
419
+ this License. If your rights have been terminated and not permanently
420
+ reinstated, you do not qualify to receive new licenses for the same
421
+ material under section 10.
422
+
423
+ 9. Acceptance Not Required for Having Copies.
424
+
425
+ You are not required to accept this License in order to receive or
426
+ run a copy of the Program. Ancillary propagation of a covered work
427
+ occurring solely as a consequence of using peer-to-peer transmission
428
+ to receive a copy likewise does not require acceptance. However,
429
+ nothing other than this License grants you permission to propagate or
430
+ modify any covered work. These actions infringe copyright if you do
431
+ not accept this License. Therefore, by modifying or propagating a
432
+ covered work, you indicate your acceptance of this License to do so.
433
+
434
+ 10. Automatic Licensing of Downstream Recipients.
435
+
436
+ Each time you convey a covered work, the recipient automatically
437
+ receives a license from the original licensors, to run, modify and
438
+ propagate that work, subject to this License. You are not responsible
439
+ for enforcing compliance by third parties with this License.
440
+
441
+ An "entity transaction" is a transaction transferring control of an
442
+ organization, or substantially all assets of one, or subdividing an
443
+ organization, or merging organizations. If propagation of a covered
444
+ work results from an entity transaction, each party to that
445
+ transaction who receives a copy of the work also receives whatever
446
+ licenses to the work the party's predecessor in interest had or could
447
+ give under the previous paragraph, plus a right to possession of the
448
+ Corresponding Source of the work from the predecessor in interest, if
449
+ the predecessor has it or can get it with reasonable efforts.
450
+
451
+ You may not impose any further restrictions on the exercise of the
452
+ rights granted or affirmed under this License. For example, you may
453
+ not impose a license fee, royalty, or other charge for exercise of
454
+ rights granted under this License, and you may not initiate litigation
455
+ (including a cross-claim or counterclaim in a lawsuit) alleging that
456
+ any patent claim is infringed by making, using, selling, offering for
457
+ sale, or importing the Program or any portion of it.
458
+
459
+ 11. Patents.
460
+
461
+ A "contributor" is a copyright holder who authorizes use under this
462
+ License of the Program or a work on which the Program is based. The
463
+ work thus licensed is called the contributor's "contributor version".
464
+
465
+ A contributor's "essential patent claims" are all patent claims
466
+ owned or controlled by the contributor, whether already acquired or
467
+ hereafter acquired, that would be infringed by some manner, permitted
468
+ by this License, of making, using, or selling its contributor version,
469
+ but do not include claims that would be infringed only as a
470
+ consequence of further modification of the contributor version. For
471
+ purposes of this definition, "control" includes the right to grant
472
+ patent sublicenses in a manner consistent with the requirements of
473
+ this License.
474
+
475
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
476
+ patent license under the contributor's essential patent claims, to
477
+ make, use, sell, offer for sale, import and otherwise run, modify and
478
+ propagate the contents of its contributor version.
479
+
480
+ In the following three paragraphs, a "patent license" is any express
481
+ agreement or commitment, however denominated, not to enforce a patent
482
+ (such as an express permission to practice a patent or covenant not to
483
+ sue for patent infringement). To "grant" such a patent license to a
484
+ party means to make such an agreement or commitment not to enforce a
485
+ patent against the party.
486
+
487
+ If you convey a covered work, knowingly relying on a patent license,
488
+ and the Corresponding Source of the work is not available for anyone
489
+ to copy, free of charge and under the terms of this License, through a
490
+ publicly available network server or other readily accessible means,
491
+ then you must either (1) cause the Corresponding Source to be so
492
+ available, or (2) arrange to deprive yourself of the benefit of the
493
+ patent license for this particular work, or (3) arrange, in a manner
494
+ consistent with the requirements of this License, to extend the patent
495
+ license to downstream recipients. "Knowingly relying" means you have
496
+ actual knowledge that, but for the patent license, your conveying the
497
+ covered work in a country, or your recipient's use of the covered work
498
+ in a country, would infringe one or more identifiable patents in that
499
+ country that you have reason to believe are valid.
500
+
501
+ If, pursuant to or in connection with a single transaction or
502
+ arrangement, you convey, or propagate by procuring conveyance of, a
503
+ covered work, and grant a patent license to some of the parties
504
+ receiving the covered work authorizing them to use, propagate, modify
505
+ or convey a specific copy of the covered work, then the patent license
506
+ you grant is automatically extended to all recipients of the covered
507
+ work and works based on it.
508
+
509
+ A patent license is "discriminatory" if it does not include within
510
+ the scope of its coverage, prohibits the exercise of, or is
511
+ conditioned on the non-exercise of one or more of the rights that are
512
+ specifically granted under this License. You may not convey a covered
513
+ work if you are a party to an arrangement with a third party that is
514
+ in the business of distributing software, under which you make payment
515
+ to the third party based on the extent of your activity of conveying
516
+ the work, and under which the third party grants, to any of the
517
+ parties who would receive the covered work from you, a discriminatory
518
+ patent license (a) in connection with copies of the covered work
519
+ conveyed by you (or copies made from those copies), or (b) primarily
520
+ for and in connection with specific products or compilations that
521
+ contain the covered work, unless you entered into that arrangement,
522
+ or that patent license was granted, prior to 28 March 2007.
523
+
524
+ Nothing in this License shall be construed as excluding or limiting
525
+ any implied license or other defenses to infringement that may
526
+ otherwise be available to you under applicable patent law.
527
+
528
+ 12. No Surrender of Others' Freedom.
529
+
530
+ If conditions are imposed on you (whether by court order, agreement or
531
+ otherwise) that contradict the conditions of this License, they do not
532
+ excuse you from the conditions of this License. If you cannot convey a
533
+ covered work so as to satisfy simultaneously your obligations under this
534
+ License and any other pertinent obligations, then as a consequence you may
535
+ not convey it at all. For example, if you agree to terms that obligate you
536
+ to collect a royalty for further conveying from those to whom you convey
537
+ the Program, the only way you could satisfy both those terms and this
538
+ License would be to refrain entirely from conveying the Program.
539
+
540
+ 13. Remote Network Interaction; Use with the GNU General Public License.
541
+
542
+ Notwithstanding any other provision of this License, if you modify the
543
+ Program, your modified version must prominently offer all users
544
+ interacting with it remotely through a computer network (if your version
545
+ supports such interaction) an opportunity to receive the Corresponding
546
+ Source of your version by providing access to the Corresponding Source
547
+ from a network server at no charge, through some standard or customary
548
+ means of facilitating copying of software. This Corresponding Source
549
+ shall include the Corresponding Source for any work covered by version 3
550
+ of the GNU General Public License that is incorporated pursuant to the
551
+ following paragraph.
552
+
553
+ Notwithstanding any other provision of this License, you have
554
+ permission to link or combine any covered work with a work licensed
555
+ under version 3 of the GNU General Public License into a single
556
+ combined work, and to convey the resulting work. The terms of this
557
+ License will continue to apply to the part which is the covered work,
558
+ but the work with which it is combined will remain governed by version
559
+ 3 of the GNU General Public License.
560
+
561
+ 14. Revised Versions of this License.
562
+
563
+ The Free Software Foundation may publish revised and/or new versions of
564
+ the GNU Affero General Public License from time to time. Such new versions
565
+ will be similar in spirit to the present version, but may differ in detail to
566
+ address new problems or concerns.
567
+
568
+ Each version is given a distinguishing version number. If the
569
+ Program specifies that a certain numbered version of the GNU Affero General
570
+ Public License "or any later version" applies to it, you have the
571
+ option of following the terms and conditions either of that numbered
572
+ version or of any later version published by the Free Software
573
+ Foundation. If the Program does not specify a version number of the
574
+ GNU Affero General Public License, you may choose any version ever published
575
+ by the Free Software Foundation.
576
+
577
+ If the Program specifies that a proxy can decide which future
578
+ versions of the GNU Affero General Public License can be used, that proxy's
579
+ public statement of acceptance of a version permanently authorizes you
580
+ to choose that version for the Program.
581
+
582
+ Later license versions may give you additional or different
583
+ permissions. However, no additional obligations are imposed on any
584
+ author or copyright holder as a result of your choosing to follow a
585
+ later version.
586
+
587
+ 15. Disclaimer of Warranty.
588
+
589
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
590
+ APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
591
+ HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
592
+ OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
593
+ THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
594
+ PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
595
+ IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
596
+ ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
597
+
598
+ 16. Limitation of Liability.
599
+
600
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
601
+ WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
602
+ THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
603
+ GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
604
+ USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
605
+ DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
606
+ PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
607
+ EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
608
+ SUCH DAMAGES.
609
+
610
+ 17. Interpretation of Sections 15 and 16.
611
+
612
+ If the disclaimer of warranty and limitation of liability provided
613
+ above cannot be given local legal effect according to their terms,
614
+ reviewing courts shall apply local law that most closely approximates
615
+ an absolute waiver of all civil liability in connection with the
616
+ Program, unless a warranty or assumption of liability accompanies a
617
+ copy of the Program in return for a fee.
618
+
619
+ END OF TERMS AND CONDITIONS
620
+
621
+ How to Apply These Terms to Your New Programs
622
+
623
+ If you develop a new program, and you want it to be of the greatest
624
+ possible use to the public, the best way to achieve this is to make it
625
+ free software which everyone can redistribute and change under these terms.
626
+
627
+ To do so, attach the following notices to the program. It is safest
628
+ to attach them to the start of each source file to most effectively
629
+ state the exclusion of warranty; and each file should have at least
630
+ the "copyright" line and a pointer to where the full notice is found.
631
+
632
+ <one line to give the program's name and a brief idea of what it does.>
633
+ Copyright (C) <year> <name of author>
634
+
635
+ This program is free software: you can redistribute it and/or modify
636
+ it under the terms of the GNU Affero General Public License as published by
637
+ the Free Software Foundation, either version 3 of the License, or
638
+ (at your option) any later version.
639
+
640
+ This program is distributed in the hope that it will be useful,
641
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
642
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
643
+ GNU Affero General Public License for more details.
644
+
645
+ You should have received a copy of the GNU Affero General Public License
646
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
647
+
648
+ Also add information on how to contact you by electronic and paper mail.
649
+
650
+ If your software can interact with users remotely through a computer
651
+ network, you should also make sure that it provides a way for users to
652
+ get its source. For example, if your program is a web application, its
653
+ interface could display a "Source" link that leads users to an archive
654
+ of the code. There are many ways you could offer source, and different
655
+ solutions will be better for different programs; see section 13 for the
656
+ specific requirements.
657
+
658
+ You should also get your employer (if you work as a programmer) or school,
659
+ if any, to sign a "copyright disclaimer" for the program, if necessary.
660
+ For more information on this, and how to apply and follow the GNU AGPL, see
661
+ <http://www.gnu.org/licenses/>.
README.md CHANGED
@@ -1,10 +1,8 @@
1
  ---
2
- title: Quickchart
3
- emoji: 📉
4
- colorFrom: purple
5
- colorTo: red
6
  sdk: docker
7
- pinned: false
8
  ---
9
-
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
1
  ---
2
+ title: QuickChart
3
+ emoji: 📊
4
+ colorFrom: blue
5
+ colorTo: green
6
  sdk: docker
7
+ app_port: 3400
8
  ---
 
 
index.js ADDED
@@ -0,0 +1,492 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const path = require('path');
2
+
3
+ const express = require('express');
4
+ const javascriptStringify = require('javascript-stringify').stringify;
5
+ const qs = require('qs');
6
+ const rateLimit = require('express-rate-limit');
7
+ const text2png = require('text2png');
8
+
9
+ const packageJson = require('./package.json');
10
+ const telemetry = require('./telemetry');
11
+ const { getPdfBufferFromPng, getPdfBufferWithText } = require('./lib/pdf');
12
+ const { logger } = require('./logging');
13
+ const { renderChartJs } = require('./lib/charts');
14
+ const { renderGraphviz } = require('./lib/graphviz');
15
+ const { toChartJs, parseSize } = require('./lib/google_image_charts');
16
+ const { renderQr, DEFAULT_QR_SIZE } = require('./lib/qr');
17
+
18
+ const app = express();
19
+
20
+ const isDev = app.get('env') === 'development' || app.get('env') === 'test';
21
+
22
+ app.set('query parser', (str) =>
23
+ qs.parse(str, {
24
+ decode(s) {
25
+ // Default express implementation replaces '+' with space. We don't want
26
+ // that. See https://github.com/expressjs/express/issues/3453
27
+ return decodeURIComponent(s);
28
+ },
29
+ }),
30
+ );
31
+
32
+ app.use(
33
+ express.json({
34
+ limit: process.env.EXPRESS_JSON_LIMIT || '100kb',
35
+ }),
36
+ );
37
+
38
+ app.use(express.urlencoded());
39
+
40
+ if (process.env.RATE_LIMIT_PER_MIN) {
41
+ const limitMax = parseInt(process.env.RATE_LIMIT_PER_MIN, 10);
42
+ logger.info('Enabling rate limit:', limitMax);
43
+
44
+ const limiter = rateLimit({
45
+ windowMs: 60 * 1000,
46
+ max: limitMax,
47
+ message:
48
+ 'Please slow down your requests! This is a shared public endpoint. Email support@quickchart.io or go to https://quickchart.io/pricing/ for rate limit exceptions or to purchase a commercial license.',
49
+ onLimitReached: (req) => {
50
+ logger.info('User hit rate limit!', req.ip);
51
+ },
52
+ keyGenerator: (req) => {
53
+ return req.headers['x-forwarded-for'] || req.ip;
54
+ },
55
+ });
56
+ app.use('/chart', limiter);
57
+ }
58
+
59
+ app.get('/', (req, res) => {
60
+ res.send(
61
+ 'QuickChart is running!<br><br>If you are using QuickChart commercially, please consider <a href="https://quickchart.io/pricing/">purchasing a license</a> to support the project.',
62
+ );
63
+ });
64
+
65
+ app.post('/telemetry', (req, res) => {
66
+ const chartCount = parseInt(req.body.chartCount, 10);
67
+ const qrCount = parseInt(req.body.qrCount, 10);
68
+ const pid = req.body.pid;
69
+
70
+ if (chartCount && !isNaN(chartCount)) {
71
+ telemetry.receive(pid, 'chartCount', chartCount);
72
+ }
73
+ if (qrCount && !isNaN(qrCount)) {
74
+ telemetry.receive(pid, 'qrCount', qrCount);
75
+ }
76
+
77
+ res.send({ success: true });
78
+ });
79
+
80
+ function utf8ToAscii(str) {
81
+ const enc = new TextEncoder();
82
+ const u8s = enc.encode(str);
83
+
84
+ return Array.from(u8s)
85
+ .map((v) => String.fromCharCode(v))
86
+ .join('');
87
+ }
88
+
89
+ function sanitizeErrorHeader(msg) {
90
+ if (typeof msg === 'string') {
91
+ return utf8ToAscii(msg).replace(/\r?\n|\r/g, '');
92
+ }
93
+ return '';
94
+ }
95
+
96
+ function failPng(res, msg, statusCode = 500) {
97
+ res.writeHead(statusCode, {
98
+ 'Content-Type': 'image/png',
99
+ 'X-quickchart-error': sanitizeErrorHeader(msg),
100
+ });
101
+ res.end(
102
+ text2png(`Chart Error: ${msg}`, {
103
+ padding: 10,
104
+ backgroundColor: '#fff',
105
+ }),
106
+ );
107
+ }
108
+
109
+ function failSvg(res, msg, statusCode = 500) {
110
+ res.writeHead(statusCode, {
111
+ 'Content-Type': 'image/svg+xml',
112
+ 'X-quickchart-error': sanitizeErrorHeader(msg),
113
+ });
114
+ res.end(`
115
+ <svg viewBox="0 0 240 80" xmlns="http://www.w3.org/2000/svg">
116
+ <style>
117
+ p {
118
+ font-size: 8px;
119
+ }
120
+ </style>
121
+ <foreignObject width="240" height="80"
122
+ requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
123
+ <p xmlns="http://www.w3.org/1999/xhtml">${msg}</p>
124
+ </foreignObject>
125
+ </svg>`);
126
+ }
127
+
128
+ async function failPdf(res, msg) {
129
+ const buf = await getPdfBufferWithText(msg);
130
+ res.writeHead(500, {
131
+ 'Content-Type': 'application/pdf',
132
+ 'X-quickchart-error': sanitizeErrorHeader(msg),
133
+ });
134
+ res.end(buf);
135
+ }
136
+
137
+ function renderChartToPng(req, res, opts) {
138
+ opts.failFn = failPng;
139
+ opts.onRenderHandler = (buf) => {
140
+ res
141
+ .type('image/png')
142
+ .set({
143
+ // 1 week cache
144
+ 'Cache-Control': isDev ? 'no-cache' : 'public, max-age=604800',
145
+ })
146
+ .send(buf)
147
+ .end();
148
+ };
149
+ doChartjsRender(req, res, opts);
150
+ }
151
+
152
+ function renderChartToSvg(req, res, opts) {
153
+ opts.failFn = failSvg;
154
+ opts.onRenderHandler = (buf) => {
155
+ res
156
+ .type('image/svg+xml')
157
+ .set({
158
+ // 1 week cache
159
+ 'Cache-Control': isDev ? 'no-cache' : 'public, max-age=604800',
160
+ })
161
+ .send(buf)
162
+ .end();
163
+ };
164
+ doChartjsRender(req, res, opts);
165
+ }
166
+
167
+ async function renderChartToPdf(req, res, opts) {
168
+ opts.failFn = failPdf;
169
+ opts.onRenderHandler = async (buf) => {
170
+ const pdfBuf = await getPdfBufferFromPng(buf);
171
+
172
+ res.writeHead(200, {
173
+ 'Content-Type': 'application/pdf',
174
+ 'Content-Length': pdfBuf.length,
175
+
176
+ // 1 week cache
177
+ 'Cache-Control': isDev ? 'no-cache' : 'public, max-age=604800',
178
+ });
179
+ res.end(pdfBuf);
180
+ };
181
+ doChartjsRender(req, res, opts);
182
+ }
183
+
184
+ function doChartjsRender(req, res, opts) {
185
+ if (!opts.chart) {
186
+ opts.failFn(res, 'You are missing variable `c` or `chart`');
187
+ return;
188
+ }
189
+
190
+ const width = parseInt(opts.width, 10) || 500;
191
+ const height = parseInt(opts.height, 10) || 300;
192
+
193
+ let untrustedInput = opts.chart;
194
+ if (opts.encoding === 'base64') {
195
+ // TODO(ian): Move this decoding up the call stack.
196
+ try {
197
+ untrustedInput = Buffer.from(opts.chart, 'base64').toString('utf8');
198
+ } catch (err) {
199
+ logger.warn('base64 malformed', err);
200
+ opts.failFn(res, err);
201
+ return;
202
+ }
203
+ }
204
+
205
+ renderChartJs(
206
+ width,
207
+ height,
208
+ opts.backgroundColor,
209
+ opts.devicePixelRatio,
210
+ opts.version || '2.9.4',
211
+ opts.format,
212
+ untrustedInput,
213
+ )
214
+ .then(opts.onRenderHandler)
215
+ .catch((err) => {
216
+ logger.warn('Chart error', err);
217
+ opts.failFn(res, err);
218
+ });
219
+ }
220
+
221
+ async function handleGraphviz(req, res, graphVizDef, opts) {
222
+ try {
223
+ const buf = await renderGraphviz(req.query.chl, opts);
224
+ res
225
+ .status(200)
226
+ .type(opts.format === 'png' ? 'image/png' : 'image/svg+xml')
227
+ .end(buf);
228
+ } catch (err) {
229
+ if (opts.format === 'png') {
230
+ failPng(res, `Graph Error: ${err}`);
231
+ } else {
232
+ failSvg(res, `Graph Error: ${err}`);
233
+ }
234
+ }
235
+ }
236
+
237
+ function handleGChart(req, res) {
238
+ // TODO(ian): Move these special cases into Google Image Charts-specific
239
+ // handler.
240
+ if (req.query.cht.startsWith('gv')) {
241
+ // Graphviz chart
242
+ const format = req.query.chof;
243
+ const engine = req.query.cht.indexOf(':') > -1 ? req.query.cht.split(':')[1] : 'dot';
244
+ const opts = {
245
+ format,
246
+ engine,
247
+ };
248
+ if (req.query.chs) {
249
+ const size = parseSize(req.query.chs);
250
+ opts.width = size.width;
251
+ opts.height = size.height;
252
+ }
253
+ handleGraphviz(req, res, req.query.chl, opts);
254
+ return;
255
+ } else if (req.query.cht === 'qr') {
256
+ const size = parseInt(req.query.chs.split('x')[0], 10);
257
+ const qrData = req.query.chl;
258
+ const chldVals = (req.query.chld || '').split('|');
259
+ const ecLevel = chldVals[0] || 'L';
260
+ const margin = chldVals[1] || 4;
261
+ const qrOpts = {
262
+ margin: margin,
263
+ width: size,
264
+ errorCorrectionLevel: ecLevel,
265
+ };
266
+
267
+ const format = 'png';
268
+ const encoding = 'UTF-8';
269
+ renderQr(format, encoding, qrData, qrOpts)
270
+ .then((buf) => {
271
+ res.writeHead(200, {
272
+ 'Content-Type': format === 'png' ? 'image/png' : 'image/svg+xml',
273
+ 'Content-Length': buf.length,
274
+
275
+ // 1 week cache
276
+ 'Cache-Control': isDev ? 'no-cache' : 'public, max-age=604800',
277
+ });
278
+ res.end(buf);
279
+ })
280
+ .catch((err) => {
281
+ failPng(res, err);
282
+ });
283
+
284
+ telemetry.count('qrCount');
285
+ return;
286
+ }
287
+
288
+ let converted;
289
+ try {
290
+ converted = toChartJs(req.query);
291
+ } catch (err) {
292
+ logger.error(`GChart error: Could not interpret ${req.originalUrl}`);
293
+ res.status(500).end('Sorry, this chart configuration is not supported right now');
294
+ return;
295
+ }
296
+
297
+ if (req.query.format === 'chartjs-config') {
298
+ // Chart.js config
299
+ res.writeHead(200, {
300
+ 'Content-Type': 'application/json',
301
+ });
302
+ res.end(javascriptStringify(converted.chart, undefined, 2));
303
+ return;
304
+ }
305
+
306
+ renderChartJs(
307
+ converted.width,
308
+ converted.height,
309
+ converted.backgroundColor,
310
+ 1.0 /* devicePixelRatio */,
311
+ '2.9.4' /* version */,
312
+ undefined /* format */,
313
+ converted.chart,
314
+ ).then((buf) => {
315
+ res.writeHead(200, {
316
+ 'Content-Type': 'image/png',
317
+ 'Content-Length': buf.length,
318
+
319
+ // 1 week cache
320
+ 'Cache-Control': isDev ? 'no-cache' : 'public, max-age=604800',
321
+ });
322
+ res.end(buf);
323
+ });
324
+ telemetry.count('chartCount');
325
+ }
326
+
327
+ app.get('/chart', (req, res) => {
328
+ if (req.query.cht) {
329
+ // This is a Google Image Charts-compatible request.
330
+ handleGChart(req, res);
331
+ return;
332
+ }
333
+
334
+ const outputFormat = (req.query.f || req.query.format || 'png').toLowerCase();
335
+ const opts = {
336
+ chart: req.query.c || req.query.chart,
337
+ height: req.query.h || req.query.height,
338
+ width: req.query.w || req.query.width,
339
+ backgroundColor: req.query.backgroundColor || req.query.bkg,
340
+ devicePixelRatio: req.query.devicePixelRatio,
341
+ version: req.query.v || req.query.version,
342
+ encoding: req.query.encoding || 'url',
343
+ format: outputFormat,
344
+ };
345
+
346
+ if (outputFormat === 'pdf') {
347
+ renderChartToPdf(req, res, opts);
348
+ } else if (outputFormat === 'svg') {
349
+ renderChartToSvg(req, res, opts);
350
+ } else if (!outputFormat || outputFormat === 'png') {
351
+ renderChartToPng(req, res, opts);
352
+ } else {
353
+ logger.error(`Request for unsupported format ${outputFormat}`);
354
+ res.status(500).end(`Unsupported format ${outputFormat}`);
355
+ }
356
+
357
+ telemetry.count('chartCount');
358
+ });
359
+
360
+ app.post('/chart', (req, res) => {
361
+ const outputFormat = (req.body.f || req.body.format || 'png').toLowerCase();
362
+ const opts = {
363
+ chart: req.body.c || req.body.chart,
364
+ height: req.body.h || req.body.height,
365
+ width: req.body.w || req.body.width,
366
+ backgroundColor: req.body.backgroundColor || req.body.bkg,
367
+ devicePixelRatio: req.body.devicePixelRatio,
368
+ version: req.body.v || req.body.version,
369
+ encoding: req.body.encoding || 'url',
370
+ format: outputFormat,
371
+ };
372
+
373
+ if (outputFormat === 'pdf') {
374
+ renderChartToPdf(req, res, opts);
375
+ } else if (outputFormat === 'svg') {
376
+ renderChartToSvg(req, res, opts);
377
+ } else {
378
+ renderChartToPng(req, res, opts);
379
+ }
380
+
381
+ telemetry.count('chartCount');
382
+ });
383
+
384
+ app.get('/qr', (req, res) => {
385
+ const qrText = req.query.text;
386
+ if (!qrText) {
387
+ failPng(res, 'You are missing variable `text`');
388
+ return;
389
+ }
390
+
391
+ let format = 'png';
392
+ if (req.query.format === 'svg') {
393
+ format = 'svg';
394
+ }
395
+
396
+ const { mode } = req.query;
397
+
398
+ const margin = typeof req.query.margin === 'undefined' ? 4 : parseInt(req.query.margin, 10);
399
+ const ecLevel = req.query.ecLevel || undefined;
400
+ const size = Math.min(3000, parseInt(req.query.size, 10)) || DEFAULT_QR_SIZE;
401
+ const darkColor = req.query.dark || '000';
402
+ const lightColor = req.query.light || 'fff';
403
+
404
+ const qrOpts = {
405
+ margin,
406
+ width: size,
407
+ errorCorrectionLevel: ecLevel,
408
+ color: {
409
+ dark: darkColor,
410
+ light: lightColor,
411
+ },
412
+ };
413
+
414
+ renderQr(format, mode, qrText, qrOpts)
415
+ .then((buf) => {
416
+ res.writeHead(200, {
417
+ 'Content-Type': format === 'png' ? 'image/png' : 'image/svg+xml',
418
+ 'Content-Length': buf.length,
419
+
420
+ // 1 week cache
421
+ 'Cache-Control': isDev ? 'no-cache' : 'public, max-age=604800',
422
+ });
423
+ res.end(buf);
424
+ })
425
+ .catch((err) => {
426
+ failPng(res, err);
427
+ });
428
+
429
+ telemetry.count('qrCount');
430
+ });
431
+
432
+ app.get('/gchart', handleGChart);
433
+
434
+ app.get('/healthcheck', (req, res) => {
435
+ // A lightweight healthcheck endpoint.
436
+ res.send({ success: true, version: packageJson.version });
437
+ });
438
+
439
+ app.get('/healthcheck/chart', (req, res) => {
440
+ // A heavier healthcheck endpoint that redirects to a unique chart.
441
+ const labels = [...Array(5)].map(() => Math.random());
442
+ const data = [...Array(5)].map(() => Math.random());
443
+ const template = `
444
+ {
445
+ type: 'bar',
446
+ data: {
447
+ labels: [${labels.join(',')}],
448
+ datasets: [{
449
+ data: [${data.join(',')}]
450
+ }]
451
+ }
452
+ }
453
+ `;
454
+ res.redirect(`/chart?c=${template}`);
455
+ });
456
+
457
+ const port = process.env.PORT || 3400;
458
+ const server = app.listen(port);
459
+
460
+ const timeout = parseInt(process.env.REQUEST_TIMEOUT_MS, 10) || 5000;
461
+ server.setTimeout(timeout);
462
+ logger.info(`Setting request timeout: ${timeout} ms`);
463
+
464
+ logger.info(`NODE_ENV: ${process.env.NODE_ENV}`);
465
+ logger.info(`Listening on port ${port}`);
466
+
467
+ if (!isDev) {
468
+ const gracefulShutdown = function gracefulShutdown() {
469
+ logger.info('Received kill signal, shutting down gracefully.');
470
+ server.close(() => {
471
+ logger.info('Closed out remaining connections.');
472
+ process.exit();
473
+ });
474
+
475
+ setTimeout(() => {
476
+ logger.error('Could not close connections in time, forcefully shutting down');
477
+ process.exit();
478
+ }, 10 * 1000);
479
+ };
480
+
481
+ // listen for TERM signal .e.g. kill
482
+ process.on('SIGTERM', gracefulShutdown);
483
+
484
+ // listen for INT signal e.g. Ctrl-C
485
+ process.on('SIGINT', gracefulShutdown);
486
+
487
+ process.on('SIGABRT', () => {
488
+ logger.info('Caught SIGABRT');
489
+ });
490
+ }
491
+
492
+ module.exports = app;
lefthook.yml ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ pre-commit:
2
+ parallel: true
3
+ commands:
4
+ js:
5
+ glob: "*.js"
6
+ run: prettier --single-quote --trailing-comma all --print-width 100 --write {staged_files} && git add {staged_files}
lib/charts.js ADDED
@@ -0,0 +1,438 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const canvas = require('canvas');
2
+ const deepmerge = require('deepmerge');
3
+ const pattern = require('patternomaly');
4
+ const { CanvasRenderService } = require('chartjs-node-canvas');
5
+
6
+ const { fixNodeVmObject } = require('./util');
7
+ const { logger } = require('../logging');
8
+ const { uniqueSvg } = require('./svg');
9
+
10
+ // Polyfills
11
+ require('canvas-5-polyfill');
12
+ global.CanvasGradient = canvas.CanvasGradient;
13
+
14
+ // Constants
15
+ const ROUND_CHART_TYPES = new Set([
16
+ 'pie',
17
+ 'doughnut',
18
+ 'polarArea',
19
+ 'outlabeledPie',
20
+ 'outlabeledDoughnut',
21
+ ]);
22
+
23
+ const BOXPLOT_CHART_TYPES = new Set(['boxplot', 'horizontalBoxplot', 'violin', 'horizontalViolin']);
24
+
25
+ const MAX_HEIGHT = process.env.CHART_MAX_HEIGHT || 3000;
26
+ const MAX_WIDTH = process.env.CHART_MAX_WIDTH || 3000;
27
+
28
+ const rendererCache = {};
29
+
30
+ async function getChartJsForVersion(version) {
31
+ if (version && version.startsWith('4')) {
32
+ return (await import('chart.js-v4/auto')).Chart;
33
+ }
34
+ if (version && version.startsWith('3')) {
35
+ return require('chart.js-v3');
36
+ }
37
+ return require('chart.js');
38
+ }
39
+
40
+ async function getRenderer(width, height, version, format) {
41
+ if (width > MAX_WIDTH) {
42
+ throw `Requested width exceeds maximum of ${MAX_WIDTH}`;
43
+ }
44
+ if (height > MAX_HEIGHT) {
45
+ throw `Requested width exceeds maximum of ${MAX_WIDTH}`;
46
+ }
47
+
48
+ const key = `${width}__${height}__${version}__${format}`;
49
+ if (!rendererCache[key]) {
50
+ const Chart = await getChartJsForVersion(version);
51
+ rendererCache[key] = new CanvasRenderService(width, height, undefined, format, () => Chart);
52
+ }
53
+ return rendererCache[key];
54
+ }
55
+
56
+ function addColorsPlugin(chart) {
57
+ if (chart.options && chart.options.plugins && chart.options.plugins.colorschemes) {
58
+ return;
59
+ }
60
+
61
+ chart.options = deepmerge.all([
62
+ {},
63
+ chart.options,
64
+ {
65
+ plugins: {
66
+ colorschemes: {
67
+ scheme: 'tableau.Tableau10',
68
+ },
69
+ },
70
+ },
71
+ ]);
72
+ }
73
+
74
+ function getGradientFunctions(width, height) {
75
+ const getGradientFill = (colorOptions, linearGradient = [0, 0, width, 0]) => {
76
+ return function colorFunction() {
77
+ const ctx = canvas.createCanvas(20, 20).getContext('2d');
78
+ const gradientFill = ctx.createLinearGradient(...linearGradient);
79
+ colorOptions.forEach((options, idx) => {
80
+ gradientFill.addColorStop(options.offset, options.color);
81
+ });
82
+ return gradientFill;
83
+ };
84
+ };
85
+
86
+ const getGradientFillHelper = (direction, colors, dimensions = {}) => {
87
+ const colorOptions = colors.map((color, idx) => {
88
+ return {
89
+ color,
90
+ offset: idx / (colors.length - 1 || 1),
91
+ };
92
+ });
93
+
94
+ let linearGradient = [0, 0, dimensions.width || width, 0];
95
+ if (direction === 'vertical') {
96
+ linearGradient = [0, 0, 0, dimensions.height || height];
97
+ } else if (direction === 'both') {
98
+ linearGradient = [0, 0, dimensions.width || width, dimensions.height || height];
99
+ }
100
+ return getGradientFill(colorOptions, linearGradient);
101
+ };
102
+
103
+ return {
104
+ getGradientFill,
105
+ getGradientFillHelper,
106
+ };
107
+ }
108
+
109
+ function patternDraw(shapeType, backgroundColor, patternColor, requestedSize) {
110
+ return function doPatternDraw() {
111
+ const size = Math.min(200, requestedSize) || 20;
112
+ // patternomaly requires a document global...
113
+ global.document = {
114
+ createElement: () => {
115
+ return canvas.createCanvas(size, size);
116
+ },
117
+ };
118
+ return pattern.draw(shapeType, backgroundColor, patternColor, size);
119
+ };
120
+ }
121
+
122
+ async function renderChartJs(
123
+ width,
124
+ height,
125
+ backgroundColor,
126
+ devicePixelRatio,
127
+ version,
128
+ format,
129
+ untrustedChart,
130
+ ) {
131
+ let chart;
132
+ if (typeof untrustedChart === 'string') {
133
+ // The chart could contain Javascript - run it in a VM.
134
+ try {
135
+ const { getGradientFill, getGradientFillHelper } = getGradientFunctions(width, height);
136
+ const chartFunction = new Function(
137
+ 'getGradientFill',
138
+ 'getGradientFillHelper',
139
+ 'pattern',
140
+ 'Chart',
141
+ `return ${untrustedChart}`,
142
+ );
143
+ chart = chartFunction(
144
+ getGradientFill,
145
+ getGradientFillHelper,
146
+ { draw: patternDraw },
147
+ getChartJsForVersion(version),
148
+ );
149
+ } catch (err) {
150
+ logger.error('Input Error', err, untrustedChart);
151
+ return Promise.reject(new Error(`Invalid input\n${err}`));
152
+ }
153
+ } else {
154
+ // The chart is just a simple JSON object.
155
+ chart = untrustedChart;
156
+ }
157
+
158
+ // Patch some bugs and issues.
159
+ fixNodeVmObject(chart);
160
+
161
+ chart.options = chart.options || {};
162
+
163
+ if (chart.type === 'donut') {
164
+ // Fix spelling...
165
+ chart.type = 'doughnut';
166
+ }
167
+
168
+ // TODO(ian): Move special chart type out of this file.
169
+ if (chart.type === 'sparkline') {
170
+ if (chart.data.datasets.length < 1) {
171
+ return Promise.reject(new Error('"sparkline" requres 1 dataset'));
172
+ }
173
+ chart.type = 'line';
174
+ const dataseries = chart.data.datasets[0].data;
175
+ if (!chart.data.labels) {
176
+ chart.data.labels = Array(dataseries.length);
177
+ }
178
+ chart.options.legend = chart.options.legend || { display: false };
179
+ if (!chart.options.elements) {
180
+ chart.options.elements = {};
181
+ }
182
+ chart.options.elements.line = chart.options.elements.line || {
183
+ borderColor: '#000',
184
+ borderWidth: 1,
185
+ };
186
+ chart.options.elements.point = chart.options.elements.point || {
187
+ radius: 0,
188
+ };
189
+ if (!chart.options.scales) {
190
+ chart.options.scales = {};
191
+ }
192
+
193
+ let min = Number.POSITIVE_INFINITY;
194
+ let max = Number.NEGATIVE_INFINITY;
195
+ for (let i = 0; i < dataseries.length; i += 1) {
196
+ const dp = dataseries[i];
197
+ min = Math.min(min, dp);
198
+ max = Math.max(max, dp);
199
+ }
200
+
201
+ chart.options.scales.xAxes = chart.options.scales.xAxes || [{ display: false }];
202
+ chart.options.scales.yAxes = chart.options.scales.yAxes || [
203
+ {
204
+ display: false,
205
+ ticks: {
206
+ // Offset the min and max slightly so that pixels aren't shaved off
207
+ // under certain circumstances.
208
+ min: min - min * 0.05,
209
+ max: max + max * 0.05,
210
+ },
211
+ },
212
+ ];
213
+ }
214
+
215
+ if (chart.type === 'progressBar') {
216
+ chart.type = 'horizontalBar';
217
+
218
+ if (chart.data.datasets.length < 1 || chart.data.datasets.length > 2) {
219
+ throw new Error('progressBar chart requires 1 or 2 datasets');
220
+ }
221
+
222
+ let usePercentage = false;
223
+ const dataLen = chart.data.datasets[0].data.length;
224
+ if (chart.data.datasets.length === 1) {
225
+ // Implicit denominator, always out of 100.
226
+ usePercentage = true;
227
+ chart.data.datasets.push({ data: Array(dataLen).fill(100) });
228
+ }
229
+ if (chart.data.datasets[0].data.length !== chart.data.datasets[1].data.length) {
230
+ throw new Error('progressBar datasets must have the same size of data');
231
+ }
232
+
233
+ chart.data.labels = chart.labels || Array.from(Array(dataLen).keys());
234
+ chart.data.datasets[1].backgroundColor = chart.data.datasets[1].backgroundColor || '#fff';
235
+ // Set default border color to first Tableau color.
236
+ chart.data.datasets[1].borderColor = chart.data.datasets[1].borderColor || '#4e78a7';
237
+ chart.data.datasets[1].borderWidth = chart.data.datasets[1].borderWidth || 1;
238
+
239
+ const deepmerge = require('deepmerge');
240
+ chart.options = deepmerge(
241
+ {
242
+ legend: { display: false },
243
+ scales: {
244
+ xAxes: [
245
+ {
246
+ ticks: {
247
+ display: false,
248
+ beginAtZero: true,
249
+ },
250
+ gridLines: {
251
+ display: false,
252
+ drawTicks: false,
253
+ },
254
+ },
255
+ ],
256
+ yAxes: [
257
+ {
258
+ stacked: true,
259
+ ticks: {
260
+ display: false,
261
+ },
262
+ gridLines: {
263
+ display: false,
264
+ drawTicks: false,
265
+ mirror: true,
266
+ },
267
+ },
268
+ ],
269
+ },
270
+ plugins: {
271
+ datalabels: {
272
+ color: '#fff',
273
+ formatter: (val, ctx) => {
274
+ if (usePercentage) {
275
+ return `${val}%`;
276
+ }
277
+ return val;
278
+ },
279
+ display: ctx => ctx.datasetIndex === 0,
280
+ },
281
+ },
282
+ },
283
+ chart.options,
284
+ );
285
+ }
286
+
287
+ // Choose retina resolution by default. This will cause images to be 2x size
288
+ // in absolute terms.
289
+ chart.options.devicePixelRatio = devicePixelRatio || 2.0;
290
+
291
+ // Implement other default options
292
+ if (
293
+ chart.type === 'bar' ||
294
+ chart.type === 'horizontalBar' ||
295
+ chart.type === 'line' ||
296
+ chart.type === 'scatter' ||
297
+ chart.type === 'bubble'
298
+ ) {
299
+ if (!chart.options.scales) {
300
+ // TODO(ian): Merge default options with provided options
301
+ chart.options.scales = {
302
+ yAxes: [
303
+ {
304
+ ticks: {
305
+ beginAtZero: true,
306
+ },
307
+ },
308
+ ],
309
+ };
310
+ }
311
+ addColorsPlugin(chart);
312
+ } else if (chart.type === 'radar') {
313
+ addColorsPlugin(chart);
314
+ } else if (ROUND_CHART_TYPES.has(chart.type)) {
315
+ addColorsPlugin(chart);
316
+ } else if (chart.type === 'scatter') {
317
+ addColorsPlugin(chart);
318
+ } else if (chart.type === 'bubble') {
319
+ addColorsPlugin(chart);
320
+ }
321
+
322
+ if (chart.type === 'line') {
323
+ if (chart.data && chart.data.datasets && Array.isArray(chart.data.datasets)) {
324
+ chart.data.datasets.forEach(dataset => {
325
+ const data = dataset;
326
+ // Make line charts straight lines by default.
327
+ data.lineTension = data.lineTension || 0;
328
+ });
329
+ }
330
+ }
331
+
332
+ chart.options.plugins = chart.options.plugins || {};
333
+ let usingDataLabelsDefaults = false;
334
+ if (!chart.options.plugins.datalabels) {
335
+ usingDataLabelsDefaults = true;
336
+ chart.options.plugins.datalabels = {};
337
+ if (chart.type === 'pie' || chart.type === 'doughnut') {
338
+ chart.options.plugins.datalabels = {
339
+ display: true,
340
+ };
341
+ } else {
342
+ chart.options.plugins.datalabels = {
343
+ display: false,
344
+ };
345
+ }
346
+ }
347
+
348
+ if (ROUND_CHART_TYPES.has(chart.type) || chart.type === 'radialGauge') {
349
+ global.Chart = require('chart.js');
350
+ // These requires have side effects.
351
+ require('chartjs-plugin-piechart-outlabels');
352
+ if (chart.type === 'doughnut' || chart.type === 'outlabeledDoughnut') {
353
+ require('chartjs-plugin-doughnutlabel');
354
+ }
355
+ let userSpecifiedOutlabels = false;
356
+ chart.data.datasets.forEach(dataset => {
357
+ if (dataset.outlabels || chart.options.plugins.outlabels) {
358
+ userSpecifiedOutlabels = true;
359
+ } else {
360
+ // Disable outlabels by default.
361
+ dataset.outlabels = { display: false };
362
+ }
363
+ });
364
+
365
+ if (userSpecifiedOutlabels && usingDataLabelsDefaults) {
366
+ // If outlabels are enabled, disable datalabels by default.
367
+ chart.options.plugins.datalabels = {
368
+ display: false,
369
+ };
370
+ }
371
+ }
372
+ if (chart.options && chart.options.plugins && chart.options.plugins.colorschemes) {
373
+ global.Chart = require('chart.js');
374
+ require('chartjs-plugin-colorschemes');
375
+ }
376
+ logger.debug('Chart:', JSON.stringify(chart));
377
+
378
+ if (version.startsWith('3') || version.startsWith('4')) {
379
+ require('chartjs-adapter-moment');
380
+ }
381
+ if (!chart.plugins) {
382
+ if (version.startsWith('3') || version.startsWith('4')) {
383
+ chart.plugins = [];
384
+ } else {
385
+ const chartAnnotations = require('chartjs-plugin-annotation');
386
+ const chartBoxViolinPlot = require('chartjs-chart-box-and-violin-plot');
387
+ const chartDataLabels = require('chartjs-plugin-datalabels');
388
+ const chartRadialGauge = require('chartjs-chart-radial-gauge');
389
+ chart.plugins = [chartDataLabels, chartAnnotations];
390
+ if (chart.type === 'radialGauge') {
391
+ chart.plugins.push(chartRadialGauge);
392
+ }
393
+ if (BOXPLOT_CHART_TYPES.has(chart.type)) {
394
+ chart.plugins.push(chartBoxViolinPlot);
395
+ }
396
+ }
397
+ }
398
+
399
+ // Background color plugin
400
+ chart.plugins.push({
401
+ id: 'background',
402
+ beforeDraw: chartInstance => {
403
+ if (backgroundColor) {
404
+ // Chart.js v3 provides `chartInstance.chart` as `chartInstance`
405
+ const chart = chartInstance.chart ? chartInstance.chart : chartInstance;
406
+ const { ctx } = chart;
407
+ ctx.fillStyle = backgroundColor;
408
+ ctx.fillRect(0, 0, chart.width, chart.height);
409
+ }
410
+ },
411
+ });
412
+
413
+ // Pad below legend plugin
414
+ if (chart.options.plugins.padBelowLegend) {
415
+ chart.plugins.push({
416
+ id: 'padBelowLegend',
417
+ beforeInit: (chartInstance, val) => {
418
+ global.Chart.Legend.prototype.afterFit = function afterFit() {
419
+ this.height = this.height + (Number(val) || 0);
420
+ };
421
+ },
422
+ });
423
+ }
424
+
425
+ const canvasRenderService = await getRenderer(width, height, version, format);
426
+
427
+ if (format === 'svg') {
428
+ // SVG rendering doesn't work asychronously.
429
+ return Buffer.from(
430
+ uniqueSvg(canvasRenderService.renderToBufferSync(chart, 'image/svg+xml').toString()),
431
+ );
432
+ }
433
+ return canvasRenderService.renderToBuffer(chart);
434
+ }
435
+
436
+ module.exports = {
437
+ renderChartJs,
438
+ };
lib/google_image_charts.js ADDED
@@ -0,0 +1,958 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Google Image Charts compatibility
2
+
3
+ const DEFAULT_COLOR_WHEEL = ['#4D89F9', '#00B88A', 'red', 'purple', 'yellow', 'brown'];
4
+
5
+ const AXIS_FORMAT_REGEX_CHXS = /^\d(N([^\*]+)?(\*([fpe]+)?(c[A-Z]{3})?(\d)?([zsxy]+)?\*)?(.*))?$/;
6
+
7
+ function parseSize(chs) {
8
+ if (!chs) {
9
+ return {
10
+ width: 500,
11
+ height: 300,
12
+ };
13
+ }
14
+ const size = chs.split('x');
15
+ return {
16
+ width: Math.min(2048, parseInt(size[0], 10)),
17
+ height: Math.min(2048, parseInt(size[1], 10)),
18
+ };
19
+ }
20
+
21
+ function parseBackgroundColor(chf) {
22
+ if (!chf) {
23
+ return 'white';
24
+ }
25
+
26
+ const series = chf.split('|');
27
+
28
+ // For now we don't support any of the series coloring features - just look
29
+ // at the first part.
30
+ const parts = series[0].split(',');
31
+
32
+ if (parts[0] === 'a') {
33
+ // Transparency
34
+ backgroundColor = '#000000' + parts[2].slice(-2);
35
+ } else {
36
+ // Fill
37
+ backgroundColor = '#' + parts[2];
38
+ }
39
+ return backgroundColor;
40
+ }
41
+
42
+ /**
43
+ * Returns a list of series objects. Each series object is a list of values.
44
+ */
45
+ function parseSeriesData(chd, chds) {
46
+ let seriesData;
47
+ const [encodingType, seriesStr] = chd.split(':');
48
+ switch (encodingType) {
49
+ case 't':
50
+ if (chds === 'a') {
51
+ // Basic text format with auto scaling
52
+ seriesData = seriesStr.split('|').map(valuesStr => {
53
+ return valuesStr.split(',').map(val => {
54
+ if (val === '_') {
55
+ return null;
56
+ }
57
+ return parseFloat(val);
58
+ });
59
+ });
60
+ } else {
61
+ // Basic text format with set range
62
+ const seriesValues = seriesStr.split('|');
63
+ const seriesRanges = [];
64
+ if (chds) {
65
+ if (Array.isArray(chds)) {
66
+ // We don't want to support Google weird scaling here per series...
67
+ chds = chds[0];
68
+ }
69
+ const ranges = chds.split(',');
70
+ for (let i = 0; i < ranges.length; i += 2) {
71
+ const min = parseFloat(ranges[i]);
72
+ const max = parseFloat(ranges[i + 1]);
73
+ seriesRanges.push({ min, max });
74
+ }
75
+
76
+ if (seriesRanges.length < seriesValues.length) {
77
+ // Fill out the remainder of ranges for all series, using the last
78
+ // value.
79
+ for (let i = 0; i <= seriesValues.length - seriesRanges.length; i++) {
80
+ seriesRanges.push(seriesRanges[seriesRanges.length - 1]);
81
+ }
82
+ }
83
+ } else {
84
+ // Apply default minimums of 0 and maximums of 100.
85
+ seriesValues.forEach(() => {
86
+ seriesRanges.push({ min: 0, max: 100 });
87
+ });
88
+ }
89
+ seriesData = seriesValues.map((valuesStr, idx) => {
90
+ return valuesStr.split(',').map(val => {
91
+ if (val === '_') {
92
+ return null;
93
+ }
94
+ const floatVal = parseFloat(val);
95
+ if (floatVal < seriesRanges[idx].min) {
96
+ return null;
97
+ }
98
+ if (floatVal > seriesRanges[idx].max) {
99
+ return seriesRanges[idx].max;
100
+ }
101
+ return floatVal;
102
+ });
103
+ });
104
+ }
105
+ break;
106
+ case 's':
107
+ // Simple encoding format
108
+ const SIMPLE_LOOKUP = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
109
+ seriesData = seriesStr.split(',').map(encoded => {
110
+ const vals = [];
111
+ for (let i = 0; i < encoded.length; i++) {
112
+ const char = encoded.charAt(i);
113
+ if (char === '_') {
114
+ vals.push(null);
115
+ } else {
116
+ vals.push(SIMPLE_LOOKUP.indexOf(char));
117
+ }
118
+ }
119
+ return vals;
120
+ });
121
+ break;
122
+ case 'e':
123
+ const EXTENDED_LOOKUP = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-.';
124
+ seriesData = seriesStr.split(',').map(encoded => {
125
+ const vals = [];
126
+ for (let i = 0; i < encoded.length; i += 2) {
127
+ const word = encoded.slice(i, i + 2);
128
+ if (word === '__') {
129
+ vals.push(null);
130
+ } else {
131
+ const idx1 = EXTENDED_LOOKUP.indexOf(word[0]);
132
+ const idx2 = EXTENDED_LOOKUP.indexOf(word[1]);
133
+
134
+ const val = idx1 * EXTENDED_LOOKUP.length + idx2;
135
+ vals.push(val);
136
+ }
137
+ }
138
+ return vals;
139
+ });
140
+ break;
141
+ case 'a':
142
+ // Image Chart "awesome" format
143
+ seriesData = seriesStr.split('|').map(valuesStr => {
144
+ return valuesStr.split(',').map(parseFloat);
145
+ });
146
+ break;
147
+ }
148
+ return seriesData;
149
+ }
150
+
151
+ function setChartType(cht, chartObj) {
152
+ let chartType;
153
+ switch (cht) {
154
+ case 'bhs':
155
+ // Horizontal with stacked bars
156
+ chartObj.type = 'horizontalBar';
157
+ chartObj.options.scales = {
158
+ xAxes: [
159
+ {
160
+ display: false,
161
+ stacked: true,
162
+ gridLines: { display: false },
163
+ },
164
+ ],
165
+ yAxes: [
166
+ {
167
+ display: false,
168
+ stacked: true,
169
+ gridLines: { display: false },
170
+ ticks: {
171
+ beginAtZero: true,
172
+ },
173
+ },
174
+ ],
175
+ };
176
+ break;
177
+ case 'bvs':
178
+ // Vertical with stacked bars
179
+ chartObj.type = 'bar';
180
+ chartObj.options.scales = {
181
+ xAxes: [
182
+ {
183
+ display: false,
184
+ stacked: true,
185
+ gridLines: { display: false },
186
+ },
187
+ ],
188
+ yAxes: [
189
+ {
190
+ display: false,
191
+ stacked: true,
192
+ gridLines: { display: false },
193
+ ticks: {
194
+ beginAtZero: true,
195
+ },
196
+ },
197
+ ],
198
+ };
199
+ break;
200
+ case 'bvo':
201
+ // Vertical stacked in front of each other
202
+ chartObj.type = 'bar';
203
+ chartObj.options.scales = {
204
+ xAxes: [
205
+ {
206
+ stacked: true,
207
+ gridLines: { display: false },
208
+ },
209
+ ],
210
+ yAxes: [
211
+ {
212
+ stacked: false,
213
+ gridLines: { display: false },
214
+ ticks: {
215
+ beginAtZero: true,
216
+ },
217
+ },
218
+ ],
219
+ };
220
+ break;
221
+ case 'bhg':
222
+ // Horizontal with grouped bars
223
+ chartObj.type = 'horizontalBar';
224
+ chartObj.options.scales = {
225
+ xAxes: [
226
+ {
227
+ display: false,
228
+ gridLines: { display: false },
229
+ },
230
+ ],
231
+ yAxes: [
232
+ {
233
+ display: false,
234
+ gridLines: { display: false },
235
+ ticks: {
236
+ beginAtZero: true,
237
+ },
238
+ },
239
+ ],
240
+ };
241
+ break;
242
+ case 'bvg':
243
+ // Vertical with grouped bars
244
+ chartObj.type = 'bar';
245
+ chartObj.options.scales = {
246
+ xAxes: [
247
+ {
248
+ display: false,
249
+ gridLines: { display: false },
250
+ },
251
+ ],
252
+ yAxes: [
253
+ {
254
+ display: false,
255
+ gridLines: { display: false },
256
+ ticks: {
257
+ beginAtZero: true,
258
+ },
259
+ },
260
+ ],
261
+ };
262
+ break;
263
+ case 'lc':
264
+ // TODO(ian): Support 'nda': no default axes to suppress axes
265
+ // https://chart.googleapis.com/chart?cht=lc:nda&chs=200x125&chd=t:40,60,60,45,47,75,70,72
266
+ chartObj.type = 'line';
267
+ chartObj.options.scales = {
268
+ xAxes: [
269
+ {
270
+ display: false,
271
+ gridLines: {
272
+ drawOnChartArea: false,
273
+ drawTicks: false,
274
+ },
275
+ },
276
+ ],
277
+ yAxes: [
278
+ {
279
+ display: false,
280
+ gridLines: {
281
+ drawOnChartArea: false,
282
+ drawTicks: false,
283
+ },
284
+ ticks: {
285
+ beginAtZero: true,
286
+ },
287
+ },
288
+ ],
289
+ };
290
+ break;
291
+ case 'ls':
292
+ // Sparkline
293
+ chartObj.type = 'line';
294
+ chartObj.options.scales = {
295
+ xAxes: [
296
+ {
297
+ display: false,
298
+ gridLines: { display: false },
299
+ },
300
+ ],
301
+ yAxes: [
302
+ {
303
+ display: false,
304
+ gridLines: { display: false },
305
+ ticks: {
306
+ beginAtZero: true,
307
+ },
308
+ },
309
+ ],
310
+ };
311
+ break;
312
+ case 'p':
313
+ case 'p3':
314
+ case 'pc':
315
+ chartObj.type = 'pie';
316
+ chartObj.options.plugins = {
317
+ datalabels: {
318
+ display: false,
319
+ },
320
+ };
321
+ break;
322
+ case 'lxy':
323
+ // TODO(ian): x-y coordinates/scatter chart
324
+ // https://developers.google.com/chart/image/docs/gallery/scatter_charts
325
+ break;
326
+ }
327
+ }
328
+
329
+ function setData(seriesData, chartObj) {
330
+ const lengths = seriesData.map(series => series.length);
331
+ const longestSeriesLength = Math.max(...lengths);
332
+
333
+ // TODO(ian): For horizontal stacked bar charts, indexes are shown top down
334
+ // instead of bottom up.
335
+ chartObj.data.labels = Array(longestSeriesLength)
336
+ .fill(0)
337
+ .map((_, idx) => idx);
338
+
339
+ // Round chart types (e.g. pie) have different color handling.
340
+ const isRound = chartObj.type === 'pie';
341
+
342
+ chartObj.data.datasets = seriesData.map((series, idx) => {
343
+ return {
344
+ data: series,
345
+ fill: false,
346
+ backgroundColor: isRound ? undefined : DEFAULT_COLOR_WHEEL[idx % DEFAULT_COLOR_WHEEL.length],
347
+ borderColor: isRound ? undefined : DEFAULT_COLOR_WHEEL[idx % DEFAULT_COLOR_WHEEL.length],
348
+ borderWidth: 2,
349
+ pointRadius: 0,
350
+ };
351
+ });
352
+
353
+ if (chartObj.type === 'pie') {
354
+ chartObj.data.datasets = chartObj.data.datasets.reverse();
355
+ }
356
+ }
357
+
358
+ function setTitle(chtt, chts, chartObj) {
359
+ if (!chtt) {
360
+ return;
361
+ }
362
+
363
+ let fontColor, fontSize;
364
+ if (chts) {
365
+ const splits = chts.split(',');
366
+ fontColor = `#${splits[0]}`;
367
+ fontSize = parseInt(splits[1], 10);
368
+ }
369
+ chartObj.options.title = {
370
+ display: true,
371
+ text: chtt.replace('|', '\n', 'g'),
372
+ fontSize,
373
+ fontColor,
374
+ };
375
+ }
376
+
377
+ function setDataLabels(chl, chartObj) {
378
+ if (!chl) {
379
+ return;
380
+ }
381
+
382
+ const labels = chl.split('|');
383
+
384
+ // TODO(ian): line charts are supposed to have an effect similar to axis
385
+ // value formatters, rather than data labels.
386
+ chartObj.options.plugins = chartObj.options.plugins || {};
387
+ chartObj.options.plugins.datalabels = {
388
+ display: true,
389
+ color: '#000',
390
+ font: {
391
+ size: 14,
392
+ },
393
+ formatter: (val, ctx) => {
394
+ let labelIdx = 0;
395
+ for (let datasetIndex = 0; datasetIndex < ctx.datasetIndex; datasetIndex++) {
396
+ // Skip over the labels for all the previous datasets.
397
+ labelIdx += chartObj.data.datasets[datasetIndex].data.length;
398
+ }
399
+
400
+ // Skip over the labels for data in the current dataset.
401
+ labelIdx += ctx.dataIndex;
402
+
403
+ if (!labels[labelIdx]) {
404
+ return '';
405
+ }
406
+ return labels[labelIdx].replace('\\n', '\n');
407
+ },
408
+ };
409
+ }
410
+
411
+ function setLegend(chdl, chdlp, chdls, chartObj) {
412
+ if (!chdl) {
413
+ chartObj.options.legend = {
414
+ display: false,
415
+ };
416
+ return;
417
+ }
418
+ chartObj.options.legend = {
419
+ display: true,
420
+ };
421
+ const labels = chdl.split('|');
422
+ labels.forEach((label, idx) => {
423
+ // Note that this overrides 'chl' labels right now
424
+ chartObj.data.datasets[idx].label = label;
425
+ });
426
+
427
+ switch (chdlp || 'r') {
428
+ case 'b':
429
+ chartObj.options.legend.position = 'bottom';
430
+ break;
431
+ case 't':
432
+ chartObj.options.legend.position = 'top';
433
+ break;
434
+ case 'r':
435
+ chartObj.options.legend.position = 'right';
436
+ chartObj.options.legend.align = 'start';
437
+ break;
438
+ case 'l':
439
+ chartObj.options.legend.position = 'left';
440
+ chartObj.options.legend.align = 'start';
441
+ break;
442
+ default:
443
+ // chdlp is not fully supported
444
+ }
445
+
446
+ // Make legend labels smaller.
447
+ chartObj.options.legend.labels = {
448
+ boxWidth: 10,
449
+ };
450
+
451
+ if (chdls) {
452
+ const [fontColor, fontSize] = chdls.split(',');
453
+ chartObj.options.legend.fontSize = parseInt(fontSize, 10);
454
+ chartObj.options.legend.fontColor = `#${fontColor}`;
455
+ }
456
+ }
457
+
458
+ function setMargins(chma, chartObj) {
459
+ const margins = {
460
+ left: 0,
461
+ right: 0,
462
+ top: 10,
463
+ bottom: 0,
464
+ };
465
+
466
+ if (chma) {
467
+ const inputs = chma.split(',').map(x => parseInt(x, 10));
468
+ margins.left = inputs[0];
469
+ margins.right = inputs[1];
470
+ margins.top = inputs[2];
471
+ margins.bottom = inputs[3];
472
+ }
473
+
474
+ chartObj.options.layout = chartObj.options.layout || {};
475
+ chartObj.options.layout.padding = margins;
476
+ }
477
+
478
+ function setColors(chco, chartObj) {
479
+ if (!chco) {
480
+ return null;
481
+ }
482
+
483
+ let seriesColors = chco.split(',').map(colors => {
484
+ if (colors.indexOf('|') > -1) {
485
+ return colors.split('|').map(color => `#${color}`);
486
+ }
487
+ return colors;
488
+ });
489
+
490
+ chartObj.data.datasets.forEach((dataset, idx) => {
491
+ if (Array.isArray(seriesColors[idx])) {
492
+ // Colors behave differently for Chart.js pie chart and bar charts.
493
+ dataset.backgroundColor = dataset.borderColor = seriesColors[idx];
494
+ } else {
495
+ dataset.backgroundColor = dataset.borderColor = '#' + seriesColors[idx];
496
+ }
497
+ });
498
+ }
499
+
500
+ function setAxes(chxt, chxr, chartObj) {
501
+ if (!chxt) {
502
+ return;
503
+ }
504
+
505
+ const enabledAxes = chxt.split(',');
506
+
507
+ if (chxr) {
508
+ // Custom axes range
509
+ const axesSettings = chxr.split('|');
510
+ axesSettings.forEach(axisSetting => {
511
+ const opts = axisSetting.split(',').map(parseFloat);
512
+
513
+ const axisName = enabledAxes[opts[0] /* axisIndex */];
514
+ const minVal = opts[1];
515
+ const maxVal = opts[2];
516
+ const stepVal = opts.length > 3 ? opts[3] : undefined;
517
+
518
+ let axis;
519
+ if (axisName === 'x') {
520
+ // Manually scale X-axis so data values extend the full range:
521
+ // chart.js doesn't respect min/max in categorical axes.
522
+ axis = chartObj.options.scales.xAxes[0];
523
+ axis.type = 'linear';
524
+ chartObj.data.datasets.forEach(dataset => {
525
+ const step = (maxVal - minVal) / dataset.data.length;
526
+ let currentStep = minVal;
527
+ dataset.data = dataset.data.map(dp => {
528
+ const ret = {
529
+ x: currentStep,
530
+ y: dp,
531
+ };
532
+ currentStep += step;
533
+ return ret;
534
+ });
535
+ });
536
+ } else if (axisName === 'y') {
537
+ axis = chartObj.options.scales.yAxes[0];
538
+ }
539
+ axis.ticks = axis.ticks || {};
540
+ axis.ticks.min = minVal;
541
+ axis.ticks.max = maxVal;
542
+ axis.ticks.stepSize = stepVal;
543
+ axis.ticks.maxTicksLimit = Number.MAX_VALUE;
544
+ });
545
+ }
546
+ }
547
+
548
+ function setAxesLabels(chxt, chxl, chxs, chartObj) {
549
+ if (!chxt) {
550
+ return;
551
+ }
552
+
553
+ // e.g. {
554
+ // 0: 'x',
555
+ // 1: 'y',
556
+ // }
557
+ const axisByIndex = {};
558
+
559
+ // e.g. {
560
+ // 'x': ['Jan', 'Feb', 'March'],
561
+ // 'y': ['0', '1', '2'],
562
+ // }
563
+ const axisLabelsLookup = {
564
+ x: [],
565
+ y: [],
566
+ };
567
+
568
+ // e.g. {
569
+ // 'x': Array<Function>,
570
+ // 'y': Array<Function>,
571
+ // }
572
+ const axisValFormatters = {
573
+ x: [],
574
+ y: [],
575
+ };
576
+
577
+ // Parse chxt
578
+ const validAxesLabels = new Set();
579
+ const axes = chxt.split(',');
580
+ axes.forEach((axis, idx) => {
581
+ axisByIndex[idx] = axis;
582
+ validAxesLabels.add(`${idx}:`);
583
+ });
584
+
585
+ if (axes.indexOf('x') > -1) {
586
+ chartObj.options.scales.xAxes[0].display = true;
587
+
588
+ if (chartObj.type === 'horizontalBar') {
589
+ // Horizontal bar charts have x axis ticks.
590
+ chartObj.options.scales.xAxes[0].gridLines = chartObj.options.scales.xAxes[0].gridLines || {};
591
+ chartObj.options.scales.xAxes[0].gridLines.display =
592
+ chartObj.options.scales.xAxes[0].gridLines.display ?? true;
593
+ chartObj.options.scales.xAxes[0].gridLines.drawOnChartArea =
594
+ chartObj.options.scales.xAxes[0].gridLines.drawOnChartArea ?? false;
595
+ chartObj.options.scales.xAxes[0].gridLines.drawTicks =
596
+ chartObj.options.scales.xAxes[0].gridLines.drawTicks ?? true;
597
+ }
598
+
599
+ chartObj.options.scales.xAxes[0].ticks = chartObj.options.scales.xAxes[0].ticks || {};
600
+ chartObj.options.scales.xAxes[0].ticks.autoSkip =
601
+ chartObj.options.scales.xAxes[0].ticks.autoSkip ?? false;
602
+ }
603
+ if (axes.indexOf('y') > -1) {
604
+ chartObj.options.scales.yAxes[0].display = true;
605
+
606
+ // Google Image Charts show yAxes ticks.
607
+ chartObj.options.scales.yAxes[0].gridLines = chartObj.options.scales.yAxes[0].gridLines || {};
608
+ chartObj.options.scales.yAxes[0].gridLines.display =
609
+ chartObj.options.scales.yAxes[0].gridLines.display ?? true;
610
+ chartObj.options.scales.yAxes[0].gridLines.drawOnChartArea =
611
+ chartObj.options.scales.yAxes[0].gridLines.drawOnChartArea ?? false;
612
+ chartObj.options.scales.yAxes[0].gridLines.offsetGridLines =
613
+ chartObj.options.scales.yAxes[0].gridLines.offsetGridLines ?? false;
614
+ chartObj.options.scales.yAxes[0].gridLines.drawTicks =
615
+ chartObj.options.scales.yAxes[0].gridLines.drawTicks ?? true;
616
+ }
617
+
618
+ if (chxs) {
619
+ // TODO(ian): If chxs doesn't have N in front of it, then skip forward to
620
+ // color and labels.
621
+ // https://developers.google.com/chart/image/docs/gallery/bar_charts#axis-label-styles-chxs
622
+ // chxs=0,000000,0,0,_
623
+ const axisRules = chxs.split('|');
624
+ axisRules.forEach(rule => {
625
+ const parts = rule.split(',');
626
+
627
+ // Parse the first character of the first part as axis index.
628
+ const axisIdx = parseInt(parts[0][0], 10);
629
+ const axisName = axisByIndex[axisIdx];
630
+ const axis =
631
+ axisName === 'x' ? chartObj.options.scales.xAxes[0] : chartObj.options.scales.yAxes[0];
632
+
633
+ const hexColor = parts[1];
634
+ const fontSize = parts[2];
635
+ const alignment = parts[3];
636
+ const axisTickVisibility = parts[4];
637
+ const tickColor = parts[5];
638
+ const axisColor = parts[6];
639
+ const skipLabels = parts[7];
640
+
641
+ axis.gridLines = axis.gridLines || {};
642
+ switch (axisTickVisibility) {
643
+ case 'l':
644
+ // Axis line only
645
+ axis.display = true;
646
+ axis.gridLines.drawTicks = false;
647
+ break;
648
+ case 't':
649
+ // Tick marks only
650
+ axis.display = true;
651
+ axis.gridLines.drawTicks = true;
652
+ break;
653
+ case '_':
654
+ // Neither axis nor tick marks
655
+ axis.display = false;
656
+ break;
657
+ case 'lt':
658
+ default:
659
+ // Ticks and axis line
660
+ axis.display = true;
661
+ axis.gridLines.drawTicks = true;
662
+ break;
663
+ }
664
+
665
+ const matchResults = AXIS_FORMAT_REGEX_CHXS.exec(parts[0]);
666
+ if (matchResults && matchResults[1]) {
667
+ // Apply prefix and suffix.
668
+ let tickPrefix = matchResults[2] || '';
669
+ let tickSuffix = matchResults[8] || '';
670
+
671
+ // Apply cryptic formatting rules.
672
+ const valueType = matchResults[4];
673
+ const currency = matchResults[5];
674
+ const numDecimalPlaces = matchResults[6] ? parseInt(matchResults[6], 10) : 2;
675
+ const otherOptions = matchResults[7];
676
+
677
+ if (valueType && valueType.indexOf('p') > -1) {
678
+ // Display a percentage: add % sign and multiply by 100.
679
+ tickSuffix += '%';
680
+ axisValFormatters[axisName].push(val => {
681
+ return val * 100.0;
682
+ });
683
+ } else if (valueType && valueType.indexOf('e') > -1) {
684
+ // Exponential: scientific notation
685
+ axisValFormatters[axisName].push(val => {
686
+ return val.toExponential();
687
+ });
688
+ } else if (currency) {
689
+ const CURRENCY_SYMBOLS = {
690
+ AUD: '$',
691
+ CAD: '$',
692
+ CHF: 'CHF',
693
+ CNY: '元',
694
+ EUR: '€',
695
+ GBP: '£',
696
+ HKD: '$',
697
+ INR: '₹',
698
+ JPY: '¥',
699
+ KRW: '₩',
700
+ MXN: '$',
701
+ NOK: 'kr',
702
+ NZD: '$',
703
+ RUB: '₽',
704
+ SEK: 'kr',
705
+ TRY: '₺',
706
+ USD: '$',
707
+ ZAR: 'R',
708
+ };
709
+ const symbol = CURRENCY_SYMBOLS[currency.slice(1)] || '$';
710
+ tickPrefix += symbol;
711
+ }
712
+
713
+ if (otherOptions && otherOptions.indexOf('s')) {
714
+ // Add thousands separator and apply number of decimal places
715
+ axisValFormatters[axisName].push(val => {
716
+ return val.toLocaleString('en', {
717
+ minimumFractionDigits: numDecimalPlaces,
718
+ });
719
+ });
720
+ } else {
721
+ // Apply number of decimal places
722
+ axisValFormatters[axisName].push(val => {
723
+ return val.toFixed(numDecimalPlaces);
724
+ });
725
+ }
726
+
727
+ axisValFormatters[axisName].push(val => {
728
+ return tickPrefix + val + tickSuffix;
729
+ });
730
+
731
+ // TODO(ian): Support trailing zeroes option 'z'
732
+
733
+ // Apply formatters!
734
+ axis.ticks = axis.ticks || {};
735
+
736
+ let nextLabelIdx = 0;
737
+ axis.ticks.callback = (val, tickIdx, vals) => {
738
+ let retVal = val;
739
+ axisValFormatters[axisName].forEach(formatFn => {
740
+ retVal = formatFn(retVal);
741
+ });
742
+ return retVal;
743
+ };
744
+ }
745
+ });
746
+ }
747
+
748
+ if (chxl) {
749
+ const splits = chxl.split('|');
750
+ let currentAxisIdx, currentAxisName;
751
+ splits.forEach(label => {
752
+ if (validAxesLabels.has(label)) {
753
+ currentAxisIdx = parseInt(label.replace(':', ''), 10);
754
+ currentAxisName = axisByIndex[currentAxisIdx];
755
+ // Placeholder lists already created, below line unnecessary.
756
+ // axisLabelsLookup[currentAxisName] = axisLabelsLookup[currentAxisName] || [];
757
+ } else {
758
+ axisLabelsLookup[currentAxisName].push(label);
759
+ }
760
+ });
761
+
762
+ // These axis ticks override the above automatic formatting axis ticks.
763
+ setAxisTicks('x', axisLabelsLookup, chartObj);
764
+ setAxisTicks('y', axisLabelsLookup, chartObj);
765
+ }
766
+ }
767
+
768
+ function setAxisTicks(axisName, axisLabelsLookup, chartObj) {
769
+ let axisLabels = axisLabelsLookup[axisName];
770
+ if (axisLabels && axisLabels.length > 0) {
771
+ if (axisName === 'y') {
772
+ axisLabels = axisLabels.reverse();
773
+ }
774
+ const axis =
775
+ axisName === 'x' ? chartObj.options.scales.xAxes[0] : chartObj.options.scales.yAxes[0];
776
+ axis.ticks = axis.ticks || {};
777
+
778
+ let nextLabelIdx = 0;
779
+ axis.ticks.callback = (val, tickIdx, vals) => {
780
+ // This needs to be rebuilt every time in callback, because this is the
781
+ // only place we have access to accurate 'vals' (which varies based on
782
+ // axis scale etc).
783
+
784
+ // TODO(ian): Need an odd number of ticks for an odd number of axis
785
+ // labels. Otherwise they won't quite be spaced evenly.
786
+ const numTicks = vals.length;
787
+ const numLabels = axisLabels.length;
788
+
789
+ const idxToLabel = {};
790
+ const stepSize = numTicks / (numLabels - 1);
791
+ for (let i = 0; i < numLabels - 1; i++) {
792
+ const label = axisLabels[i];
793
+ idxToLabel[Math.floor(stepSize * i)] = label;
794
+ }
795
+ idxToLabel[numTicks - 1] = axisLabels[numLabels - 1];
796
+
797
+ return idxToLabel[tickIdx] || '';
798
+ };
799
+ axis.ticks.minRotation = 0;
800
+ axis.ticks.maxRotation = 0;
801
+ axis.ticks.padding = 2;
802
+ }
803
+ }
804
+
805
+ function setMarkers(chm, chartObj) {
806
+ if (!chm) {
807
+ return;
808
+ }
809
+
810
+ const enabledSeriesIndexes = new Set();
811
+ let hideMarkers = false;
812
+ chm.split('|').forEach((markerRule, idx) => {
813
+ const parts = markerRule.split(',');
814
+ const markerType = parts[0];
815
+ const markerColor = parts[1];
816
+
817
+ if (markerType === 'B' || markerType === 'b') {
818
+ chartObj.data.datasets[idx].fill = true;
819
+ chartObj.data.datasets[idx].backgroundColor = '#' + markerColor;
820
+ }
821
+
822
+ // TODO(ian): All of the marker options. See
823
+ // https://developers.google.com/chart/image/docs/chart_params#gcharts_data_point_labels
824
+ const seriesIndex = parts[2];
825
+ const size = parts[4];
826
+ if (parseInt(size, 10) === 0) {
827
+ hideMarkers = true;
828
+ }
829
+
830
+ enabledSeriesIndexes.add(parseInt(seriesIndex, 10));
831
+
832
+ // chm=N,000000,0,,10|N,000000,1,,10|N,000000,2,,10
833
+ });
834
+
835
+ chartObj.options.plugins = {
836
+ datalabels: {
837
+ display: !hideMarkers,
838
+ anchor: 'end',
839
+ align: 'end',
840
+ offset: 0,
841
+ font: {
842
+ size: 10,
843
+ weight: 'bold',
844
+ },
845
+ formatter: (value, context) => {
846
+ if (enabledSeriesIndexes.has(context.datasetIndex)) {
847
+ return value;
848
+ }
849
+ return null;
850
+ },
851
+ },
852
+ };
853
+ }
854
+
855
+ function setGridLines(chg, chartObj) {
856
+ if (!chg) {
857
+ return;
858
+ }
859
+
860
+ const parts = chg.split(',');
861
+
862
+ if (Number(parts[0]) > 0) {
863
+ chartObj.options.scales.xAxes[0].gridLines.display = true;
864
+ chartObj.options.scales.xAxes[0].gridLines.drawOnChartArea = true;
865
+ }
866
+ if (Number(parts[1]) > 0) {
867
+ chartObj.options.scales.yAxes[0].gridLines.display = true;
868
+ chartObj.options.scales.yAxes[0].gridLines.drawOnChartArea = true;
869
+ }
870
+
871
+ if (parts.length >= 2) {
872
+ const numGridLinesX = 100 / parseInt(parts[0], 10);
873
+ const numGridLinesY = 100 / parseInt(parts[1], 10);
874
+
875
+ chartObj.options.scales.xAxes[0].ticks = {
876
+ maxTicksLimit: numGridLinesX,
877
+ };
878
+ chartObj.options.scales.yAxes[0].ticks = {
879
+ maxTicksLimit: numGridLinesY,
880
+ };
881
+ }
882
+
883
+ // TODO(ian): dash sizes etc
884
+ // https://developers.google.com/chart/image/docs/gallery/line_charts
885
+
886
+ // TODO(ian): Full implementation https://developers.google.com/chart/image/docs/chart_params#gcharts_grid_lines
887
+ }
888
+
889
+ function setLineChartOptions(chl, chartObj) {
890
+ // Set options specific to line chart.
891
+ if (!chl) {
892
+ return;
893
+ }
894
+
895
+ const series = chl.split('|');
896
+ series.forEach((serie, idx) => {
897
+ const parts = serie.split(',');
898
+ const thickness = parseInt(parts[0], 10);
899
+ // TODO(ian): Support for dashed line
900
+ // dashLength = parts[1]
901
+ // spaceLength = parts[2]
902
+
903
+ if (!isNaN(thickness)) {
904
+ chartObj.data.datasets[idx].borderWidth = thickness;
905
+ }
906
+ });
907
+ }
908
+
909
+ function toChartJs(query) {
910
+ //renderChart(width, height, backgroundColor, devicePixelRatio, untrustedChart) {
911
+ const { width, height } = parseSize(query.chs);
912
+
913
+ const backgroundColor = parseBackgroundColor(query.chf);
914
+
915
+ // Parse data
916
+ const seriesData = parseSeriesData(query.chd, query.chds);
917
+
918
+ // Start building the chart
919
+ const chartObj = {
920
+ data: {},
921
+ options: {},
922
+ };
923
+
924
+ setChartType(query.cht, chartObj);
925
+ setData(seriesData, chartObj);
926
+ setTitle(query.chtt, query.chts, chartObj);
927
+ setGridLines(query.chg, chartObj);
928
+ setLegend(query.chdl, query.chdlp, query.chdls, chartObj);
929
+ setMargins(query.chma, chartObj);
930
+
931
+ setDataLabels(query.chl, chartObj);
932
+ setColors(query.chco, chartObj);
933
+ setAxes(query.chxt, query.chxr, chartObj);
934
+ setAxesLabels(query.chxt, query.chxl, query.chxs, chartObj);
935
+
936
+ setMarkers(query.chm, chartObj);
937
+ setGridLines(query.chg, chartObj);
938
+
939
+ setLineChartOptions(query.chls, chartObj);
940
+
941
+ // TODO(ian): Bar Width and Spacing chbh
942
+ // Zero Line chp
943
+
944
+ // console.log(JSON.stringify(chartObj, null, 2));
945
+
946
+ return {
947
+ width,
948
+ height,
949
+ backgroundColor,
950
+ chart: chartObj,
951
+ };
952
+ }
953
+
954
+ module.exports = {
955
+ toChartJs,
956
+ parseSeriesData,
957
+ parseSize,
958
+ };
lib/graphviz.js ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const Viz = require('viz.js');
2
+ const { Module, render } = require('viz.js/full.render.js');
3
+
4
+ async function renderGraphviz(graphStr, opts) {
5
+ const { format, engine, width, height } = opts || {};
6
+ const viz = new Viz({ Module, render });
7
+ const result = await viz.renderString(graphStr, {
8
+ // Built-in format options don't work great. Hardcode to svg and convert it
9
+ // to other supported formats later.
10
+ format: 'svg',
11
+ engine,
12
+ });
13
+ if (format === 'png') {
14
+ // Defer require of sharp as it is not supported by docker container.
15
+ const sharp = require('sharp');
16
+ const img = sharp(Buffer.from(result));
17
+ if (width && height) {
18
+ img.resize({
19
+ width,
20
+ height,
21
+ fit: 'contain',
22
+ });
23
+ }
24
+ return img.png().toBuffer();
25
+ }
26
+ return result;
27
+ }
28
+
29
+ module.exports = {
30
+ renderGraphviz,
31
+ };
lib/pdf.js ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const PDFDocument = require('pdfkit');
2
+
3
+ // Minimum margin between image and any edge of PDF.
4
+ const IMAGE_MARGIN = 35;
5
+
6
+ function getPdfBufferFromPng(image, pdfKitImageOptions) {
7
+ const ret = new Promise((resolve, reject) => {
8
+ try {
9
+ const doc = new PDFDocument();
10
+
11
+ const buffers = [];
12
+ doc.on('data', buffers.push.bind(buffers));
13
+ doc.on('end', () => {
14
+ const pdfData = Buffer.concat(buffers);
15
+ resolve(pdfData);
16
+ });
17
+
18
+ doc.image(
19
+ image,
20
+ IMAGE_MARGIN,
21
+ 100,
22
+ Object.assign(
23
+ {
24
+ fit: [doc.page.width - IMAGE_MARGIN * 2, doc.page.height - IMAGE_MARGIN * 2],
25
+ align: 'center',
26
+ },
27
+ pdfKitImageOptions || {},
28
+ ),
29
+ );
30
+ doc.end();
31
+ } catch (err) {
32
+ reject(`PDF generation error: ${err.message}`);
33
+ }
34
+ });
35
+ return ret;
36
+ }
37
+
38
+ function getPdfBufferWithText(text) {
39
+ const ret = new Promise((resolve, reject) => {
40
+ try {
41
+ const doc = new PDFDocument();
42
+
43
+ const buffers = [];
44
+ doc.on('data', buffers.push.bind(buffers));
45
+ doc.on('end', () => {
46
+ const pdfData = Buffer.concat(buffers);
47
+ resolve(pdfData);
48
+ });
49
+
50
+ doc.fontSize(24);
51
+ doc.text(text, 100, 100);
52
+ doc.end();
53
+ } catch (err) {
54
+ reject(`PDF generation error: ${err.message}`);
55
+ }
56
+ });
57
+ return ret;
58
+ }
59
+
60
+ module.exports = {
61
+ getPdfBufferFromPng,
62
+ getPdfBufferWithText,
63
+ };
lib/qr.js ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const qrcode = require('qrcode');
2
+ const toSJIS = require('qrcode/helper/to-sjis');
3
+
4
+ const { logger } = require('../logging');
5
+
6
+ // Default size of QR image in pixels.
7
+ const DEFAULT_QR_SIZE = 150;
8
+
9
+ function renderQr(format, mode, qrData, qrOpts) {
10
+ logger.debug('QR code', format, mode, qrOpts);
11
+
12
+ let finalQrData = qrData;
13
+ const finalQrOpts = qrOpts;
14
+ if (mode === 'sjis') {
15
+ finalQrData = [
16
+ {
17
+ data: qrData,
18
+ mode: 'kanji',
19
+ },
20
+ ];
21
+ finalQrOpts.toSJISFunc = toSJIS;
22
+ }
23
+ finalQrOpts.type = format === 'png' ? 'png' : 'svg';
24
+
25
+ return new Promise((resolve, reject) => {
26
+ if (format === 'svg') {
27
+ qrcode
28
+ .toString(finalQrData, finalQrOpts)
29
+ .then(qrStr => {
30
+ resolve(Buffer.from(qrStr, 'utf8'));
31
+ })
32
+ .catch(err => {
33
+ logger.error('QR render error (SVG)', err);
34
+ reject(new Error(`Could not generate QR\n${err}`));
35
+ });
36
+ } else {
37
+ qrcode
38
+ .toDataURL(finalQrData, finalQrOpts)
39
+ .then(dataUrl => {
40
+ resolve(Buffer.from(dataUrl.split(',')[1], 'base64'));
41
+ })
42
+ .catch(err => {
43
+ logger.error('QR render error (PNG)', err);
44
+ reject(new Error(`Could not generate QR\n${err}`));
45
+ });
46
+ }
47
+ });
48
+ }
49
+
50
+ module.exports = {
51
+ renderQr,
52
+ DEFAULT_QR_SIZE,
53
+ };
lib/svg.js ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ function generateRandomId(length) {
2
+ const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
3
+ let result = '';
4
+ for (let i = 0; i < length; i++) {
5
+ result += characters.charAt(Math.floor(Math.random() * characters.length));
6
+ }
7
+ return result;
8
+ }
9
+
10
+ // From https://blog.jim-nielsen.com/2022/multiple-inline-svgs/
11
+ function uniqueSvg(svg) {
12
+ const id = generateRandomId(10);
13
+ return svg
14
+ .replace(/id="clip/g, `id="${id}__clip`)
15
+ .replace(/clip-path="url\(#clip/g, `clip-path="url(#${id}__clip`);
16
+ }
17
+
18
+ module.exports = {
19
+ uniqueSvg,
20
+ };
lib/util.js ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ function fixNodeVmObject(obj) {
2
+ // Fix for https://github.com/patriksimek/vm2/issues/198
3
+ if (!obj) return;
4
+
5
+ const objKeys = Object.keys(obj);
6
+ for (let i = 0; i < objKeys.length; i++) {
7
+ const key = objKeys[i];
8
+ const val = obj[key];
9
+ if (Array.isArray(val)) {
10
+ obj[key] = Array.from(val);
11
+ obj[key].forEach(arrObj => {
12
+ fixNodeVmObject(arrObj);
13
+ });
14
+ } else if (typeof val === 'object' && val !== null) {
15
+ fixNodeVmObject(val);
16
+ }
17
+ }
18
+ }
19
+
20
+ module.exports = {
21
+ fixNodeVmObject,
22
+ };
logging.js ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ const bunyan = require('bunyan');
2
+
3
+ const logger = bunyan.createLogger({
4
+ name: 'quickchart',
5
+ streams: [{ stream: process.stdout, level: process.env.LOG_LEVEL }],
6
+ });
7
+
8
+ module.exports = {
9
+ logger,
10
+ };
package.json ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "quickchart",
3
+ "version": "1.8.1",
4
+ "main": "index.js",
5
+ "license": "AGPL-3.0",
6
+ "homepage": "https://quickchart.io/",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/typpo/quickchart.git"
10
+ },
11
+ "scripts": {
12
+ "start": "node --max-http-header-size=65536 index.js",
13
+ "format": "prettier --write \"**/*.js\"",
14
+ "test": "PORT=3401 NODE_ENV=test mocha --exit --recursive test/ci/",
15
+ "test:watch": "PORT=2998 NODE_ENV=test chokidar '**/*.js' --initial --ignore node_modules -c 'mocha --exit --recursive test/'"
16
+ },
17
+ "overrides": {
18
+ "canvas": "2.9.3"
19
+ },
20
+ "resolutions": {
21
+ "canvas": "2.9.3"
22
+ },
23
+ "dependencies": {
24
+ "bunyan": "^1.8.12",
25
+ "canvas": "2.9.3",
26
+ "canvas-5-polyfill": "^0.1.5",
27
+ "chart.js": "^2.9.4",
28
+ "chart.js-v3": "npm:chart.js@3.9.1",
29
+ "chart.js-v4": "npm:chart.js@4.0.1",
30
+ "chartjs-adapter-moment": "https://github.com/typpo/chartjs-adapter-moment.git#e9bc92ab6e0e500c91c4a9871db7b14d15b5c2e7",
31
+ "chartjs-chart-box-and-violin-plot": "^2.4.0",
32
+ "chartjs-chart-radial-gauge": "^1.0.3",
33
+ "chartjs-node-canvas": "^3.0.6",
34
+ "chartjs-plugin-annotation": "^0.5.7",
35
+ "chartjs-plugin-colorschemes": "https://github.com/typpo/chartjs-plugin-colorschemes.git#979ef8e599265f65c85d5dae90b543d5589c734a",
36
+ "chartjs-plugin-datalabels": "^0.5.0",
37
+ "chartjs-plugin-doughnutlabel": "^2.0.3",
38
+ "chartjs-plugin-piechart-outlabels": "^0.1.4",
39
+ "deepmerge": "^4.2.2",
40
+ "express": "^4.19.2",
41
+ "express-rate-limit": "^5.0.0",
42
+ "get-image-colors": "^4.0.1",
43
+ "javascript-stringify": "^2.0.0",
44
+ "node-fetch": "^2.6.7",
45
+ "patternomaly": "^1.3.2",
46
+ "pdfkit": "^0.10.0",
47
+ "qrcode": "^1.3.3",
48
+ "qs": "^6.7.0",
49
+ "sharp": "^0.32.6",
50
+ "text2png": "^2.1.0",
51
+ "viz.js": "^2.1.2"
52
+ },
53
+ "devDependencies": {
54
+ "@arkweid/lefthook": "^0.6.3",
55
+ "artillery": "^1.7.9",
56
+ "chokidar-cli": "^2.0.0",
57
+ "eslint": "^5.15.1",
58
+ "eslint-config-airbnb-base": "^13.1.0",
59
+ "eslint-plugin-import": "^2.16.0",
60
+ "image-size": "^0.8.3",
61
+ "jimp": "^0.13.0",
62
+ "mocha": "^6.2.3",
63
+ "pixelmatch": "^5.1.0",
64
+ "prettier": "^1.18.2",
65
+ "qrcode-reader": "^1.0.4",
66
+ "supertest": "^4.0.2"
67
+ },
68
+ "peerDependencies": {
69
+ "chart.js": ">= 2.0.0"
70
+ }
71
+ }
telemetry.js ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const fs = require('fs');
2
+
3
+ const fetch = require('node-fetch');
4
+
5
+ const { logger } = require('./logging');
6
+
7
+ const TELEMETRY_PATH = 'telemetry.log';
8
+
9
+ const PROCESS_ID = Math.floor(Math.random() * 1e10).toString(16);
10
+
11
+ let receivedCount = 0;
12
+ let telemetry = {};
13
+
14
+ function count(label) {
15
+ receive(PROCESS_ID, label, 1);
16
+ }
17
+
18
+ function receive(pid, label, count) {
19
+ if (!pid || !label || !count) {
20
+ return;
21
+ }
22
+ if (!telemetry[pid]) {
23
+ telemetry[pid] = {};
24
+ }
25
+ if (!telemetry[pid][label]) {
26
+ telemetry[pid][label] = 0;
27
+ }
28
+ telemetry[pid][label] += count;
29
+ receivedCount++;
30
+ }
31
+
32
+ function write() {
33
+ if (!receivedCount) {
34
+ return;
35
+ }
36
+
37
+ logger.info(`Writing ${receivedCount} telemetry records...`);
38
+ telemetry.timestamp = new Date().getTime();
39
+ if (process.env.WRITE_TELEMETRY_TO_CONSOLE) {
40
+ logger.info('Telemetry', JSON.stringify(telemetry));
41
+ } else {
42
+ fs.appendFileSync(TELEMETRY_PATH, JSON.stringify(telemetry) + '\n');
43
+ }
44
+ telemetry = {};
45
+ receivedCount = 0;
46
+ }
47
+
48
+ function send() {
49
+ if (!telemetry[PROCESS_ID]) {
50
+ return;
51
+ }
52
+ const data = {
53
+ pid: PROCESS_ID,
54
+ chartCount: telemetry[PROCESS_ID].chartCount,
55
+ qrCount: telemetry[PROCESS_ID].qrCount,
56
+ };
57
+ try {
58
+ fetch('https://quickchart.io/telemetry', {
59
+ method: 'POST',
60
+ body: JSON.stringify(data),
61
+ headers: {
62
+ 'content-type': 'application/json',
63
+ },
64
+ });
65
+ } catch (err) {}
66
+
67
+ telemetry = {};
68
+ }
69
+
70
+ if (process.env.ENABLE_TELEMETRY_WRITE) {
71
+ logger.info('Telemetry writing is enabled');
72
+ setInterval(() => {
73
+ write();
74
+ }, 1000 * 60 * 60 * 1);
75
+ }
76
+ if (!process.env.DISABLE_TELEMETRY) {
77
+ logger.info('Telemetry is enabled');
78
+ setInterval(() => {
79
+ send();
80
+ }, 1000 * 60 * 60 * 12);
81
+ }
82
+
83
+ module.exports = {
84
+ count,
85
+ receive,
86
+ write,
87
+ };
yarn.lock ADDED
The diff for this file is too large to render. See raw diff