├── .gcloudignore ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── app.yaml ├── app ├── .eslintrc.js ├── express-app.js ├── hc-page.js ├── hc-pages.js ├── pdf-option │ ├── default-pdf-option-presets.js │ ├── my-pdf-option-presets.js │ ├── my-pdf-option-presets.js.sample │ ├── pdf-option-lib.js │ └── pdf-option.js └── pdf-server.js ├── fonts ├── empty └── fonts.conf ├── package.json └── test ├── .eslintrc.js ├── express-app.js └── pdf-option-lib.js /.gcloudignore: -------------------------------------------------------------------------------- 1 | Dockerfile 2 | *.sh 3 | node_modules/ 4 | .git 5 | .gitignore 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.sh 2 | fonts/* 3 | !fonts/empty 4 | !fonts/fonts.conf 5 | *.swp 6 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:10-slim 2 | LABEL maintainer="yu.yamazaki85@gmail.com" 3 | 4 | # Update 5 | RUN apt-get update --fix-missing \ 6 | && apt-get upgrade -y \ 7 | && apt-get install -y wget gnupg libxss1 \ 8 | && apt-get clean 9 | 10 | # Locale settings (japanese) 11 | RUN apt-get install -y locales task-japanese \ 12 | && locale-gen ja_JP.UTF-8 \ 13 | && localedef -f UTF-8 -i ja_JP ja_JP 14 | ENV LANG ja_JP.UTF-8 15 | ENV LANGUAGE ja_JP:jp 16 | ENV LC_ALL ja_JP.UTF-8 17 | 18 | # Install stable chrome and dependencies. 19 | RUN wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - \ 20 | && sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' \ 21 | && apt-get update \ 22 | && apt-get install -y google-chrome-stable --no-install-recommends \ 23 | && apt-get clean \ 24 | && rm -rf /var/lib/apt/lists/* \ 25 | && rm -rf /src/*.deb 26 | 27 | # It's a good idea to use dumb-init to help prevent zombie chrome processes. 28 | ADD https://github.com/Yelp/dumb-init/releases/download/v1.2.0/dumb-init_1.2.0_amd64 /usr/local/bin/dumb-init 29 | RUN chmod +x /usr/local/bin/dumb-init 30 | 31 | # if use default chromium installed with puppeteer 32 | ENV HCEP_USE_CHROMIUM true 33 | 34 | # else use chrome enable below settings 35 | #ENV HCEP_USE_CHROMIUM false 36 | #ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD true 37 | #ENV CHROME_BINARY /usr/bin/google-chrome 38 | 39 | # If you want to extend pdf options, rename app/my-pdf-option-presets.js.sample to app/my-pdf-option-presets.js and activate this 40 | ENV HCEP_MY_PDF_OPTION_PRESETS_FILE_PATH="./my-pdf-option-presets" 41 | ENV NODE_ENV production 42 | 43 | RUN mkdir /hcep/ 44 | COPY package.json /hcep/ 45 | WORKDIR /hcep/ 46 | 47 | RUN npm install -u npm && \ 48 | npm install -g mocha eslint && \ 49 | npm install 50 | 51 | # Install fonts 52 | COPY fonts /usr/share/fonts 53 | 54 | COPY app /hcep/app 55 | 56 | RUN chmod -R 777 /hcep/app 57 | 58 | # Test 59 | COPY test /hcep/test 60 | RUN mocha 61 | # RUN rm -rf /hcep/test && npm uninstall -g mocha eslint 62 | 63 | EXPOSE 8000 64 | ENTRYPOINT ["dumb-init", "--"] 65 | CMD ["npm", "start"] 66 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # DEPRECATED 4 | This repository wiil be deprecated. 5 | 6 | Please use [hc-pdf-server](https://github.com/uyamazak/hc-pdf-server) instead. 7 | 8 | Since I hadn't been able to maintain it for a while, I completely rewrote it in TypeScript and changed the library and other things accordingly. 9 | # hcep-pdf-server 10 | 11 | Simple and fast PDF rendering server. Using Headless Chrome & Express & Puppeteer. 12 | 13 | GET URL or POST HTML returns PDF binary. 14 | 15 | You can run this on Docker container, Kubernetes(GKE), or Google App Engine(beta). 16 | 17 | ### Headless Chrome 18 | 19 | 20 | ### Express 21 | 22 | 23 | ### Puppeteer 24 | 25 | 26 | ## Running on Google App Engine Supported (beta) 27 | Instead of Docker container, You can also run this on Google App Engine. 28 | Please use app.yaml and edit it for your purpose. 29 | 30 | ``` 31 | gcloud app deploy --project your-project 32 | ``` 33 | 34 | More detail: 35 | https://cloud.google.com/appengine/docs/standard/nodejs/using-headless-chrome-with-puppeteer 36 | 37 | ## Getting Started 38 | 39 | ### Caution 40 | Since this product is supposed to be used within local network (like Kubernetes, GKE), error control and security measures are minimum, please accept only reliable requests. It does not assume direct disclosure to the outside. 41 | 42 | ### Clone 43 | git clone this repository. 44 | 45 | ### Customize Dockefile (optionary) 46 | Please specify whether to use Chromium bundled with Puppeteer (recommend) or Chrome, and change locale setting etc. as necessary. 47 | 48 | ### Install fonts (optionary) 49 | If you convert pages in Japanese, Chinese or languages other than English, you will need to install each font files. Also, you can use WEB fonts, but since it takes a long time for requesting and downloading them, we recommend that install the font files in the server. 50 | 51 | 52 | ``` 53 | cp AnyFonts.ttf ./fonts/ 54 | ``` 55 | 56 | 57 | ### Build image 58 | 59 | ``` 60 | sudo docker build -t hcep-pdf-server:latest . 61 | ``` 62 | 63 | ### Run 64 | 65 | Below example, run with 8000 port. 66 | 67 | ``` 68 | sudo docker run -it --rm \ 69 | -p 8000:8000 \ 70 | --name hcep-pdf-server \ 71 | hcep-pdf-server:latest 72 | ``` 73 | 74 | ## Example 75 | 76 | ### Get request with url parameter 77 | 78 | ``` 79 | curl "http://localhost:8000?url=http://example.com" -o hcep-pdf-get.pdf 80 | ``` 81 | 82 | ### POST request with html parameter 83 | 84 | ``` 85 | curl -sS http://localhost:8000 -v -d html=hcep-pdf-ok -o hcep-pdf-post.pdf 86 | ``` 87 | 88 | Please note that because the page does not have a URL, when sending html in POST method, you can not use a relative path. 89 | 90 | So, you need to include external files with domain. 91 | 92 | Bad, not working 93 | 94 | ``` 95 | 96 | ``` 97 | 98 | OK 99 | 100 | ``` 101 | 102 | ``` 103 | 104 | ## Test 105 | Execute mocha in the container run with the below command. 106 | 107 | ``` 108 | % sudo docker exec -e DEBUG="" varuna-hcep-pdf-server mocha 109 | SERVER_URL: http://localhost:8000 110 | TAREGT_URL: https://www.google.com 111 | HTML_TEST_STRINGS: ok 112 | 113 | 114 | requests routes 115 | env: development 116 | Listening on: 8000 117 | GET /hc 200 0.987 ms - - 118 | ✓ Health Check GET /hc 119 | GET / 400 0.188 ms - - 120 | ✓ GET / with no url 121 | GET /?url=https://www.google.com 200 798.680 ms - 76794 122 | ✓ GET / with url https://www.google.com (801ms) 123 | POST / 200 104.470 ms - 8374 124 | ✓ POST / html=ok (106ms) 125 | GET /screenshot?url=https://www.google.com 200 354.910 ms - 27974 126 | ✓ GET /screenshot with url https://www.google.com (356ms) 127 | POST /screenshot 200 1911.266 ms - 3732 128 | ✓ POST /screenshot html=ok (1912ms) 129 | 130 | default pdf options 131 | ✓ empty return default 132 | ✓ not exists return default 133 | ✓ A4 in default presets 134 | ✓ A3 in default presets 135 | 136 | myPdfOptionPresets set 137 | ✓ format in A4ShowPageNumberFooter is matched 138 | ✓ displayHeaderFooter in A4ShowPageNumberFooter is matched 139 | ✓ headerTemplate in A4ShowPageNumberFooter is matched 140 | ✓ footerTemplate in A4ShowPageNumberFooter is matched 141 | 142 | 143 | 14 passing (3s) 144 | 145 | testing express-app complete! process.exit() 146 | 147 | ``` 148 | 149 | ## Env variables 150 | 151 | ### Browser settings 152 | #### HCEP_USE_CHROMIUM 153 | Whether to use chromium attached to puppeteer. 154 | If you want to run this on Google App Engine, you must set it to "true". 155 | 156 | default: false (use installed Chrome by Dockerfile) 157 | 158 | #### HCEP_CHROME_BINARY 159 | The path of installed google-chrome binary. 160 | If HCEP_USE_CHROMIUM is true, this value is ignored 161 | 162 | default: /usr/bin/google-chrome 163 | 164 | #### HCEP_PAGE_TIMEOUT_MSEC 165 | Timeout milliseconds of the browser's Page 166 | default: 10000 167 | 168 | ### Server settings 169 | #### HCEP_PORT 170 | Listen Port by the express app 171 | 172 | default: 8000 173 | 174 | #### HCEP_APP_TIMEOUT_MSEC 175 | Timeout milliseconds of the express app 176 | 177 | default: 30000 178 | 179 | #### HCEP_MAX_REQUEST_SIZE 180 | default: 10MB 181 | 182 | 183 | ### PDF settings 184 | 185 | #### HCEP_MY_PDF_OPTION_PRESETS_FILE_PATH 186 | If you want to extend the PDF option presets yourself, create a file with reference to "app/my-pdf-option-presets.js.sample" and specify the file path in this variable. 187 | 188 | default: none 189 | 190 | example: "./my-pdf-options" 191 | 192 | app/my-pdf-options.js 193 | ``` 194 | module.exports.myPdfOptionPresets = { 195 | 'A4ShowPageNumberFooter': { 196 | format: 'A4', 197 | displayHeaderFooter: true, 198 | headerTemplate: '', 199 | footerTemplate: `
200 | / 201 |
` 202 | } 203 | } 204 | 205 | ``` 206 | 207 | 208 | You can make your PDF options. Read the puppeteer API's docs. 209 | 210 | 211 | 212 | #### HCEP_PDF_DEFAULT_MARGIN 213 | default: 18mm 214 | 215 | #### HCEP_DEFAULT_PDF_OPTION_KEY 216 | default: A4 217 | 218 | ### Test settings 219 | #### HCEP_TEST_SERVER_URL 220 | default: 'http://localhost:8000' 221 | 222 | #### HCEP_TEST_TAREGT_URL 223 | default: 'https://www.google.com' 224 | 225 | ## Author 226 | uyamazak:[blog](http://uyamazak.hatenablog.com/) 227 | 228 | This project has been maintained under the support of yagish履歴書 and is actually used for PDF generation. 229 | 230 | ### yagish履歴書( bizocean co.,Ltd ) 231 | https://rirekisho.yagish.jp/ 232 | -------------------------------------------------------------------------------- /app.yaml: -------------------------------------------------------------------------------- 1 | runtime: nodejs8 2 | instance_class: F2 3 | automatic_scaling: 4 | max_instances: 5 5 | min_instances: 0 6 | target_cpu_utilization: 0.75 7 | env_variables: 8 | FONTCONFIG_PATH: "/srv/fonts/" 9 | HCEP_USE_CHROMIUM: "true" 10 | HCEP_PORT: 8080 11 | -------------------------------------------------------------------------------- /app/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "env": { 3 | "node": true, 4 | "es6": true 5 | }, 6 | "extends": "eslint:recommended", 7 | "parserOptions": { 8 | "ecmaVersion": 8, 9 | "sourceType": "module" 10 | }, 11 | "rules": { 12 | "indent": [ 13 | "error", 14 | 2 15 | ], 16 | "linebreak-style": [ 17 | "error", 18 | "unix" 19 | ], 20 | "quotes": [ 21 | "error", 22 | "single" 23 | ], 24 | "semi": [ 25 | "error", 26 | "never" 27 | ] 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /app/express-app.js: -------------------------------------------------------------------------------- 1 | module.exports.expressApp = pages => { 2 | const pagesNum = pages.length 3 | console.log(`pages.length: ${pages.length}`) 4 | let currentPageNo = 0 5 | const getSinglePage = () => { 6 | currentPageNo++; 7 | if (currentPageNo >= pagesNum) { 8 | currentPageNo = 0 9 | } 10 | debug(`pagesNum:${pagesNum} currentPageNo:${currentPageNo}`) 11 | return pages[currentPageNo] 12 | } 13 | const bodyParser = require('body-parser') 14 | const debug = require('debug')('hcepPdfServer:expressApp') 15 | const express = require('express') 16 | const morgan = require('morgan') 17 | const timeout = require('connect-timeout') 18 | const { getPdfOption } = require('./pdf-option/pdf-option-lib') 19 | const appTimeoutMsec = process.env.HCEP_APP_TIMEOUT_MSEC || 10000 20 | const pageTimeoutMsec = process.env.HCEP_PAGE_TIMEOUT_MSEC || 10000 21 | const listenPort = process.env.HCEP_PORT || 8000 22 | /* bytes or string for https://www.npmjs.com/package/bytes */ 23 | const maxRquestSize = process.env.HCEP_MAX_REQUEST_SIZE || '10MB' 24 | 25 | const app = express() 26 | const env = app.get('env') 27 | console.log('env:', env) 28 | if (env == 'production') { 29 | app.use(morgan('combined')) 30 | } else { 31 | app.use(morgan('dev')) 32 | } 33 | 34 | app.use(bodyParser.urlencoded({ 35 | extended: false, 36 | limit: maxRquestSize 37 | })) 38 | app.use(timeout(appTimeoutMsec)) 39 | 40 | function handlePageError(e, option) { 41 | console.error('Page error occurred! process.exit()') 42 | console.error('error:', e) 43 | console.error('option:', option) 44 | process.exit() 45 | } 46 | 47 | app.route('/') 48 | /** 49 | * get() 50 | * Receive get request with target page's url 51 | * @req.query.url {String} page's url 52 | * @req.query.pdf_option {String} a key of pdfOptions 53 | * @return binary of PDF or error response (400 or 500) 54 | */ 55 | .get(async (req, res) => { 56 | const url = req.query.url 57 | if (!url) { 58 | res.status(400) 59 | res.end('get parameter "url" is not set') 60 | return 61 | } else { 62 | const page = getSinglePage() 63 | try { 64 | await page.goto( 65 | url, { 66 | timeout: pageTimeoutMsec, 67 | waitUntil: ['load', 'domcontentloaded'] 68 | } 69 | ) 70 | // Wait for web font loading completion 71 | // await page.evaluateHandle('document.fonts.ready') 72 | const pdfOption = getPdfOption(req.query.pdf_option) 73 | // debug('pdfOption', pdfOption) 74 | const buff = await page.pdf(pdfOption) 75 | res.status(200) 76 | res.contentType('application/pdf') 77 | res.send(buff) 78 | res.end() 79 | return 80 | } catch (e) { 81 | res.status(500) 82 | res.contentType('text/plain') 83 | res.end() 84 | handlePageError(e, url) 85 | return 86 | } 87 | } 88 | }) 89 | /** 90 | * post() 91 | * Receive post request with target html 92 | * @req.body.html {String} page's html content 93 | * @req.body.pdf_option {String} a key of pdfOptions 94 | * @return binary of PDF or error response (400 or 500) 95 | */ 96 | .post(async (req, res) => { 97 | const html = req.body.html 98 | if (!html) { 99 | res.status(400) 100 | res.contentType('text/plain') 101 | res.end('post parameter "html" is not set') 102 | } else { 103 | const page = getSinglePage() 104 | try { 105 | await page.setContent(html) 106 | // Wait for web font loading completion 107 | // await page.evaluateHandle('document.fonts.ready') 108 | const pdfOption = getPdfOption(req.body.pdf_option) 109 | // debug('pdfOption', pdfOption) 110 | const buff = await page.pdf(pdfOption) 111 | res.status(200) 112 | res.contentType('application/pdf') 113 | res.send(buff) 114 | res.end() 115 | return 116 | } catch (e) { 117 | res.status(500) 118 | res.contentType('text/plain') 119 | res.end() 120 | handlePageError(e, 'html.length:' + html.length) 121 | return 122 | } 123 | } 124 | }) 125 | 126 | app.route('/screenshot') 127 | /** 128 | * get() 129 | * Receive get request with target page's url 130 | * @req.query.url {String} page's url 131 | * @return binary of PNG or error response (400 or 500) 132 | */ 133 | .get(async (req, res) => { 134 | const url = req.query.url 135 | if (!url) { 136 | res.status(400) 137 | res.contentType('text/plain') 138 | res.end('get parameter "url" is not set') 139 | } else { 140 | const page = getSinglePage() 141 | try { 142 | await page.goto( 143 | url, { 144 | timeout: pageTimeoutMsec, 145 | waitUntil: ['load', 'domcontentloaded'] 146 | } 147 | ) 148 | const buff = await page.screenshot({ 149 | fullPage: true 150 | }) 151 | res.status(200) 152 | res.contentType('image/png') 153 | res.send(buff) 154 | res.end() 155 | } catch (e) { 156 | console.error(e) 157 | res.status(500) 158 | res.contentType('text/plain') 159 | res.end() 160 | } 161 | } 162 | }) 163 | /** 164 | * post() 165 | * Receive post request with target html 166 | * @req.body.html {String} page's html content 167 | * @return binary of PNG or error response (400 or 500) 168 | */ 169 | .post(async (req, res) => { 170 | const html = req.body.html 171 | if (!html) { 172 | await res.status(400) 173 | res.end('post parameter "html" is not set') 174 | return 175 | } else { 176 | const page = getSinglePage() 177 | try { 178 | await page.setContent(html) 179 | const buff = await page.screenshot({ 180 | fullPage: true 181 | }) 182 | res.status(200) 183 | res.contentType('image/png') 184 | res.send(buff) 185 | res.end() 186 | } catch (e) { 187 | console.error(e) 188 | res.status(500) 189 | res.end() 190 | } 191 | } 192 | }) 193 | 194 | /** 195 | * Health Check 196 | */ 197 | app.get('/hc', async (req, res) => { 198 | debug('health check ok') 199 | res.status(200) 200 | res.end('ok') 201 | }) 202 | 203 | const appServer = app.listen(listenPort, () => { 204 | console.log('Listening on:', listenPort) 205 | }) 206 | return appServer 207 | } 208 | -------------------------------------------------------------------------------- /app/hc-page.js: -------------------------------------------------------------------------------- 1 | const debug = require('debug')('hcepPdfServer:hcPage') 2 | const generateLaunchOptions = () => { 3 | const options = { 4 | args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu'] 5 | } 6 | if (process.env.HCEP_USE_CHROMIUM === 'true') { 7 | debug('use Chromium') 8 | } else { 9 | const chromeBinary = process.env.HCEP_CHROME_BINARY || '/usr/bin/google-chrome' 10 | options['executablePath'] = chromeBinary 11 | debug('use chromeBinary:', chromeBinary) 12 | } 13 | return options 14 | } 15 | module.exports.hcPage = async () => { 16 | const puppeteer = require('puppeteer') 17 | const launchOptions = generateLaunchOptions() 18 | debug('launchOptions:', launchOptions) 19 | // launch browser and page only once 20 | const browser = await puppeteer.launch(launchOptions) 21 | const chromeVersion = await browser.version() 22 | debug('chromeVersion:', chromeVersion) 23 | const page = await browser.newPage() 24 | return page 25 | } 26 | -------------------------------------------------------------------------------- /app/hc-pages.js: -------------------------------------------------------------------------------- 1 | const debug = require('debug')('hcepPdfServer:hcPage') 2 | const generateLaunchOptions = () => { 3 | const options = { 4 | args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu'] 5 | } 6 | if (process.env.HCEP_USE_CHROMIUM === 'true') { 7 | debug('use Chromium') 8 | } else { 9 | const chromeBinary = process.env.HCEP_CHROME_BINARY || '/usr/bin/google-chrome' 10 | options['executablePath'] = chromeBinary 11 | debug('use chromeBinary:', chromeBinary) 12 | } 13 | return options 14 | } 15 | module.exports.hcPages = async (pagesNum) => { 16 | if (!pagesNum) { 17 | pagesNum = 1 18 | } 19 | const puppeteer = require('puppeteer') 20 | const launchOptions = generateLaunchOptions() 21 | debug('launchOptions:', launchOptions) 22 | // launch browser and page only once 23 | const browser = await puppeteer.launch(launchOptions) 24 | const chromeVersion = await browser.version() 25 | debug('chromeVersion:', chromeVersion) 26 | const pages = [] 27 | for(let i=0; i < pagesNum; i++){ 28 | debug('page launched No.' + i) 29 | pages.push(await browser.newPage()) 30 | } 31 | return pages 32 | } 33 | -------------------------------------------------------------------------------- /app/pdf-option/default-pdf-option-presets.js: -------------------------------------------------------------------------------- 1 | module.exports.defaultPdfOptionPresets = { 2 | A3: { 3 | format: 'A3' 4 | }, 5 | A3Full: { 6 | format: 'A3', 7 | margin: '0mm' 8 | }, 9 | A3Landscape: { 10 | format: 'A3', 11 | landscape: true 12 | }, 13 | A3LandscapeFull: { 14 | format: 'A3', 15 | landscape: true, 16 | margin: '0mm' 17 | }, 18 | A4: { 19 | format: 'A4' 20 | }, 21 | A4Full: { 22 | format: 'A4', 23 | margin: '0mm' 24 | }, 25 | A4Landscape: { 26 | format: 'A4', 27 | landscape: true 28 | }, 29 | A4LandscapeFull: { 30 | format: 'A4', 31 | landscape: true, 32 | margin: '0mm' 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /app/pdf-option/my-pdf-option-presets.js: -------------------------------------------------------------------------------- 1 | module.exports.myPdfOptionPresets = { 2 | 'A4ShowPageNumberFooter': { 3 | format: 'A4', 4 | displayHeaderFooter: true, 5 | headerTemplate: '', 6 | footerTemplate: '
/
' 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /app/pdf-option/my-pdf-option-presets.js.sample: -------------------------------------------------------------------------------- 1 | /** 2 | In headerTemplate and footerTemplate, 3 | HTML template for the print header. Should be valid HTML markup with following classes used to inject printing values into them 4 | 5 | date formatted print date 6 | title document title 7 | url document location 8 | pageNumber current page number 9 | totalPages total pages in the document 10 | 11 | see more: https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md#pagepdfoptions 12 | */ 13 | module.exports.myPdfOptionPresets = { 14 | 'A4ShowPageNumberFooter': { 15 | format: 'A4', 16 | displayHeaderFooter: true, 17 | headerTemplate: '', 18 | footerTemplate: `
/
`, 19 | preferCSSPageSize: false, 20 | margin: '18mm', 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /app/pdf-option/pdf-option-lib.js: -------------------------------------------------------------------------------- 1 | const debug = require('debug')('hcepPdfServer:getPdfOption') 2 | const defaultPdfOptionKey = process.env.HCEP_DEFAULT_PDF_OPTION_KEY || 'A4' 3 | const { defaultPdfOptionPresets } = require('./default-pdf-option-presets') 4 | const { PdfOption } = require('./pdf-option') 5 | const myPdfOptionPresetsFilePath = process.env.HCEP_MY_PDF_OPTION_PRESETS_FILE_PATH 6 | 7 | let pdfOptionPresets = defaultPdfOptionPresets 8 | const loadMyPdfOptionPresets = () => { 9 | if (!myPdfOptionPresetsFilePath) { 10 | return null 11 | } 12 | debug('myPdfOptionPresetsFilePath:', myPdfOptionPresetsFilePath) 13 | const { myPdfOptionPresets } = require(myPdfOptionPresetsFilePath) 14 | return myPdfOptionPresets 15 | } 16 | 17 | const myPdfOptionPresets = loadMyPdfOptionPresets() 18 | if (myPdfOptionPresets) { 19 | const mergeOptions = require('merge-options') 20 | pdfOptionPresets = mergeOptions(defaultPdfOptionPresets, myPdfOptionPresets) 21 | debug('pdfOptionPresets merged:', pdfOptionPresets) 22 | } 23 | 24 | const pdfOptionExists = key => { 25 | return (key in pdfOptionPresets) 26 | } 27 | 28 | const getPdfOption = key => { 29 | if (!key) { 30 | debug('use defaultPdfOption:', defaultPdfOptionKey) 31 | key = defaultPdfOptionKey 32 | } else if (!pdfOptionExists(key)) { 33 | debug('key', key, 'is not exists in pdfOptionPresets') 34 | key = defaultPdfOptionKey 35 | } 36 | debug('use pdfOption', key) 37 | return new PdfOption(pdfOptionPresets[key]) 38 | } 39 | 40 | module.exports.loadMyPdfOptionPresets = loadMyPdfOptionPresets 41 | module.exports.pdfOptionPresets = pdfOptionPresets 42 | module.exports.pdfOptionExists = pdfOptionExists 43 | module.exports.getPdfOption = getPdfOption 44 | module.exports.defaultPdfOptionKey = defaultPdfOptionKey 45 | -------------------------------------------------------------------------------- /app/pdf-option/pdf-option.js: -------------------------------------------------------------------------------- 1 | const mergeOptions = require('merge-options') 2 | const defaultMargin = process.env.HCEP_DEFAULT_MARGIN || '18mm' 3 | const defaultOption = { 4 | scale: 1, 5 | displayHeaderFooter: false, 6 | headerTemplate: '', 7 | footerTemplate: '', 8 | printBackground: true, 9 | landscape: false, 10 | pageRanges: '', 11 | format: '', 12 | width: '', 13 | height: '', 14 | margin: defaultMargin, 15 | marginTop: '', 16 | marginRight: '', 17 | marginBottom: '', 18 | marginLeft: '', 19 | preferCSSPageSize: true 20 | } 21 | module.exports.defaultOption = defaultOption 22 | 23 | /** 24 | * PdfOption more detail 25 | * https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md#pagepdfoptions 26 | * 27 | */ 28 | module.exports.PdfOption = class { 29 | constructor(options) { 30 | /** 31 | Since this application does not save the generated PDF to the disk, 32 | "path" should not be set. 33 | */ 34 | if(!options) options = {} 35 | options = mergeOptions(defaultOption, options) 36 | this.scale = options.scale 37 | this.displayHeaderFooter = options.displayHeaderFooter 38 | this.headerTemplate = options.headerTemplate 39 | this.footerTemplate = options.footerTemplate 40 | this.printBackground = options.printBackground 41 | this.landscape = options.landscape 42 | this.pageRanges = options.pageRanges 43 | this.format = options.format 44 | this.width = options.width 45 | this.height = options.height 46 | this.margin = { 47 | top: options.marginTop || options.margin, 48 | right: options.marginRight || options.margin, 49 | bottom: options.marginBottom || options.margin, 50 | left: options.marginLeft || options.margin 51 | } 52 | this.preferCSSPageSize = options.preferCSSPageSize 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /app/pdf-server.js: -------------------------------------------------------------------------------- 1 | //const { hcPage } = require('./hc-page') 2 | const { hcPages } = require('./hc-pages') 3 | const { expressApp } = require('./express-app') 4 | const LAUNCH_HC_PAGES_NUM = Number(process.env.LAUNCH_HC_PAGES_NUM) || 30 5 | console.error('LAUNCH_HC_PAGES_NUM', LAUNCH_HC_PAGES_NUM) 6 | process.on('unhandledRejection', function(e){ 7 | console.error('unhandledRejection. process.exit', e) 8 | process.exit() 9 | }) 10 | 11 | const main = async () => { 12 | const browserPages = await hcPages(LAUNCH_HC_PAGES_NUM) 13 | expressApp(browserPages) 14 | } 15 | main() 16 | -------------------------------------------------------------------------------- /fonts/empty: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uyamazak/hcep-pdf-server/a92e776e7dee8f5cc936aeed3ea7990fbec23e95/fonts/empty -------------------------------------------------------------------------------- /fonts/fonts.conf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | /srv/fonts 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hcep-pdf-server", 3 | "version": "1.11.0", 4 | "description": "Simple PDF rendering server using Headless Chrome & Express & Puppeteer", 5 | "main": "app/pdf-server.js", 6 | "dependencies": { 7 | "body-parser": "^1.18.3", 8 | "chai": "^4.2.0", 9 | "connect-timeout": "^1.9.0", 10 | "debug": "^3.1.0", 11 | "express": "^4.16.4", 12 | "merge-options" : "^1.0.1", 13 | "mocha": "^5.2.0", 14 | "morgan": "^1.9.1", 15 | "npm": "^6.5.0", 16 | "puppeteer": "1.11.0", 17 | "supertest": "^3.1.0" 18 | }, 19 | "devDependencies": { 20 | }, 21 | "scripts": { 22 | "start": "node app/pdf-server.js", 23 | "test": "mocha", 24 | "lint": "eslint *", 25 | "fix": "eslint --fix *" 26 | }, 27 | "repository": { 28 | "type": "git", 29 | "url": "git+https://github.com/uyamazak/hcep-pdf-server.git" 30 | }, 31 | "author": "uyamazak", 32 | "license": "SEE LICENSE IN LICENSE", 33 | "bugs": { 34 | "url": "https://github.com/uyamazak/hcep-pdf-server/issues" 35 | }, 36 | "homepage": "https://github.com/uyamazak/hcep-pdf-server#readme" 37 | } 38 | -------------------------------------------------------------------------------- /test/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "env": { 3 | "node": true, 4 | "es6": true 5 | }, 6 | "extends": "eslint:recommended", 7 | "parserOptions": { 8 | "ecmaVersion": 8, 9 | "sourceType": "module" 10 | }, 11 | "rules": { 12 | "indent": [ 13 | "error", 14 | 2 15 | ], 16 | "linebreak-style": [ 17 | "error", 18 | "unix" 19 | ], 20 | "quotes": [ 21 | "error", 22 | "single" 23 | ], 24 | "semi": [ 25 | "error", 26 | "never" 27 | ] 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /test/express-app.js: -------------------------------------------------------------------------------- 1 | const request = require('supertest') 2 | const { hcPages } = require('../app/hc-pages') 3 | const { expressApp } = require('../app/express-app') 4 | const SERVER_URL = process.env.HCEP_TEST_SERVER_URL || 'http://localhost:8000' 5 | const TAREGT_URL = process.env.HCEP_TEST_TAREGT_URL || 'https://www.google.com' 6 | const LAUNCH_HC_PAGES_NUM = Number(process.env.LAUNCH_HC_PAGES_NUM) || 5 7 | const HTML_TEST_STRINGS = 'ok' 8 | console.log('SERVER_URL:', SERVER_URL) 9 | console.log('TAREGT_URL:', TAREGT_URL) 10 | console.log('HTML_TEST_STRINGS:', HTML_TEST_STRINGS) 11 | 12 | const sleep = (waitSeconds, someFunction) => { 13 | return new Promise(resolve => { 14 | setTimeout(() => { 15 | resolve(someFunction()) 16 | }, waitSeconds) 17 | }) 18 | } 19 | 20 | describe('requests routes', function () { 21 | this.timeout(2000 * LAUNCH_HC_PAGES_NUM) 22 | let app 23 | before(beforeDone => { 24 | (async() => { 25 | const browserPages = await hcPages(LAUNCH_HC_PAGES_NUM) 26 | app = await expressApp(browserPages) 27 | beforeDone() 28 | })() 29 | }) 30 | 31 | after(afterDone => { 32 | /* 33 | しばらくコネクションが残るため 34 | expressのプロセスが終わらないので 35 | テスト終了後、少し待って明示的に終了 36 | */ 37 | (async ()=> { 38 | await app.close() 39 | sleep(5000, () => { 40 | console.log('testing express-app complete! process.exit()') 41 | process.exit() 42 | }) 43 | afterDone() 44 | })() 45 | }) 46 | 47 | const req = request(SERVER_URL) 48 | 49 | it('Health Check GET /hc', async () => { 50 | await req.get('/hc') 51 | .expect(200, 'ok') 52 | }) 53 | 54 | it('GET / with no url', async () => { 55 | await req.get('/') 56 | .expect(400, 'get parameter "url" is not set') 57 | }) 58 | 59 | it('GET / with url ' + TAREGT_URL, async () => { 60 | await req.get('/?url=' + TAREGT_URL) 61 | .expect('Content-Type', 'application/pdf') 62 | .expect((res) => { 63 | const contentLength = Number(res.headers['content-length']) 64 | if (contentLength < 1024) { 65 | throw new Error('content-length is less than 1KB ' + contentLength) 66 | } 67 | }) 68 | .expect(200) 69 | }) 70 | 71 | it('POST / html=' + HTML_TEST_STRINGS, async () => { 72 | await req.post('/') 73 | .send('html=' + encodeURI(HTML_TEST_STRINGS)) 74 | .expect('Content-Type', 'application/pdf') 75 | .expect((res) => { 76 | const contentLength = Number(res.headers['content-length']) 77 | if (contentLength < 1024) { 78 | throw new Error('content-length is less than 1KB ' + contentLength) 79 | } 80 | }) 81 | .expect(200) 82 | }) 83 | 84 | it('LAUNCH_HC_PAGES_NUM of concurrent access to POST / html=' + HTML_TEST_STRINGS, async () => { 85 | function task() { 86 | return new Promise(async function(resolve) { 87 | await req.post('/') 88 | .send('html=' + encodeURI(HTML_TEST_STRINGS)) 89 | .expect('Content-Type', 'application/pdf') 90 | .expect(200) 91 | resolve() 92 | }) 93 | } 94 | const tasks = [] 95 | for (let i=0; i { 102 | await req.get('/screenshot?url=' + TAREGT_URL) 103 | .expect('Content-Type', 'image/png') 104 | .expect(200) 105 | }) 106 | 107 | it('POST /screenshot html=' + HTML_TEST_STRINGS, async () => { 108 | await req.post('/screenshot') 109 | .send('html=' + encodeURI(HTML_TEST_STRINGS)) 110 | .expect('Content-Type', 'image/png') 111 | .expect(200) 112 | }) 113 | }) 114 | -------------------------------------------------------------------------------- /test/pdf-option-lib.js: -------------------------------------------------------------------------------- 1 | const {getPdfOption, 2 | loadMyPdfOptionPresets, 3 | defaultPdfOptionKey} = require('../app/pdf-option/pdf-option-lib') 4 | const assert = require('assert') 5 | 6 | describe('default pdf options', () => { 7 | it('empty return default', done => { 8 | const result = getPdfOption('') 9 | assert.equal(result.format, defaultPdfOptionKey) 10 | assert.equal(result.displayHeaderFooter, false) 11 | done() 12 | }) 13 | it('not exists return default', done => { 14 | const result = getPdfOption('NotExistsPresetName') 15 | assert.equal(result.format, defaultPdfOptionKey) 16 | done() 17 | }) 18 | it('A4 in default presets', done => { 19 | const result = getPdfOption('A4') 20 | assert.equal(result.format, 'A4') 21 | done() 22 | }) 23 | it('A3 in default presets', done => { 24 | const result = getPdfOption('A3') 25 | assert.equal(result.format, 'A3') 26 | done() 27 | }) 28 | }) 29 | 30 | const myPdfOptionPresets = loadMyPdfOptionPresets() 31 | if (myPdfOptionPresets) { 32 | describe('myPdfOptionPresets set', () => { 33 | for (const presetName of Object.keys(myPdfOptionPresets)) { 34 | const preset = myPdfOptionPresets[presetName] 35 | const result = getPdfOption(presetName) 36 | for(const itemKey of Object.keys(preset)){ 37 | it(`${itemKey} in ${presetName} is matched`, done => { 38 | assert.equal(preset[itemKey], result[itemKey]) 39 | done() 40 | }) 41 | } 42 | } 43 | }) 44 | } else { 45 | console.log('myPdfOptionPresets is not set') 46 | } 47 | --------------------------------------------------------------------------------