Repository: RelaxedJS/ReLaXed Branch: master Commit: 9e31cd446150 Files: 61 Total size: 71.1 KB Directory structure: gitextract_qbfk4w8g/ ├── .github/ │ └── workflows/ │ └── npmpublish.yml ├── .gitignore ├── .npmignore ├── .travis.yml ├── LICENCE.txt ├── README.md ├── changelog.md ├── package.json ├── src/ │ ├── builtin_plugins/ │ │ ├── chartjs/ │ │ │ ├── index.js │ │ │ └── template.pug │ │ ├── flowchart/ │ │ │ ├── index.js │ │ │ └── template.pug │ │ ├── index.js │ │ ├── katex/ │ │ │ ├── head.html │ │ │ └── index.js │ │ ├── markdown/ │ │ │ └── index.js │ │ ├── mathjax/ │ │ │ └── index.js │ │ ├── mermaid/ │ │ │ ├── index.js │ │ │ └── template.pug │ │ ├── scss/ │ │ │ └── index.js │ │ ├── table/ │ │ │ ├── index.js │ │ │ └── template.pug │ │ └── vegalite/ │ │ ├── index.js │ │ └── template.pug │ ├── cli-tools/ │ │ ├── pdf2gif.js │ │ └── pretty-pdf-thumbnail.js │ ├── index.js │ ├── masterToPDF.js │ ├── parseLocals.js │ └── plugins.js └── test/ ├── samples/ │ ├── interactive_example/ │ │ ├── diagrams/ │ │ │ ├── diagram.mermaid │ │ │ └── plot.vegalite.json │ │ ├── master.pug │ │ └── report.scss │ ├── pug/ │ │ ├── absolute_path/ │ │ │ └── master.pug │ │ ├── basic_example/ │ │ │ └── master.pug │ │ ├── data_locals/ │ │ │ └── master.pug │ │ ├── data_locals_file/ │ │ │ ├── data.json │ │ │ └── master.pug │ │ ├── data_require/ │ │ │ ├── data.json │ │ │ └── master.pug │ │ ├── error/ │ │ │ └── master.pug │ │ ├── header_and_footer/ │ │ │ └── master.pug │ │ ├── katex/ │ │ │ ├── config.yml │ │ │ └── master.pug │ │ ├── letter/ │ │ │ ├── letter.scss │ │ │ └── master.pug │ │ ├── local_plugin/ │ │ │ ├── config.yml │ │ │ ├── master.pug │ │ │ └── say-my-name.plugin.js │ │ ├── mathjax/ │ │ │ ├── config.yml │ │ │ └── master.pug │ │ └── utf8-characters/ │ │ └── master.pug │ └── special_renderings/ │ ├── chartjs/ │ │ └── donut.chart.js │ ├── htable_csv/ │ │ ├── diff.txt │ │ ├── expected.pug │ │ └── sample.md.htable.csv │ └── table_csv/ │ ├── diff.txt │ ├── expected.pug │ └── sample.md.table.csv └── test.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/npmpublish.yml ================================================ name: Node.js Package on: release: types: [created] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v1 - uses: actions/setup-node@v1 with: node-version: 12 - run: npm ci - run: npm test publish-npm: needs: build runs-on: ubuntu-latest steps: - uses: actions/checkout@v1 - uses: actions/setup-node@v1 with: node-version: 12 registry-url: https://registry.npmjs.org/ - run: npm ci - run: npm publish env: NODE_AUTH_TOKEN: ${{secrets.npm_token}} publish-gpr: needs: build runs-on: ubuntu-latest steps: - uses: actions/checkout@v1 - uses: actions/setup-node@v1 with: node-version: 12 registry-url: https://npm.pkg.github.com/ scope: '@your-github-username' - run: npm ci - run: npm publish env: NODE_AUTH_TOKEN: ${{secrets.GITHUB_TOKEN}} ================================================ FILE: .gitignore ================================================ # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* # Runtime data pids *.pid *.seed *.pid.lock # Directory for instrumented libs generated by jscoverage/JSCover lib-cov # Coverage directory used by tools like istanbul coverage # nyc test coverage .nyc_output # OS specific temp files .DS_Store Thumbs.db # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) .grunt # Bower dependency directory (https://bower.io/) bower_components # node-waf configuration .lock-wscript # Compiled binary addons (https://nodejs.org/api/addons.html) build/Release # Dependency directories node_modules/ jspm_packages/ # Typescript v1 declaration files typings/ # Optional npm cache directory .npm # Optional eslint cache .eslintcache # Optional REPL history .node_repl_history # Output of 'npm pack' *.tgz # Yarn Integrity file .yarn-integrity # dotenv environment variables file .env # next.js build output .next test/samples/*/*/master.pdf test/samples/*/*/master.png test/samples/*/*/last_test_* test/samples/*/*/diff.png test/samples/*/*/master_temp.htm *.htm package-lock.json # linux .directory .directory # VS code .vscode .eslintrc.* *.local.bak ================================================ FILE: .npmignore ================================================ # https://docs.npmjs.com/misc/developers#keeping-files-out-of-your-package #tests test coverage #build tools .travis.yml ================================================ FILE: .travis.yml ================================================ language: node_js node_js: - "lts/*" - "stable" before_install: - sudo apt-get -qq update - sudo apt-get install imagemagick ghostscript poppler-utils graphicsmagick before_script: - npm link ================================================ FILE: LICENCE.txt ================================================ ISC License (ISC) Copyright 2018, Zulko Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ================================================ FILE: README.md ================================================

# ReLaXed [![Build Status](https://travis-ci.org/RelaxedJS/ReLaXed.svg?branch=master)](https://travis-ci.org/RelaxedJS/ReLaXed) ReLaXed creates PDF documents interactively using HTML or [Pug](https://pugjs.org/api/getting-started.html) (a shorthand for HTML). It allows complex layouts to be defined with CSS and JavaScript, while writing the content in a friendly, minimal syntax close to Markdown or LaTeX. Here it is in action in the Atom editor:

And here are a few output examples:
Book - source / PDF Letter - Source / PDF Resume - Source / PDF Visit card - Source / PDF
Slides - Source / PDF Report - Source / PDF Paper - Source / PDF Poster - Source / PDF
ReLaXed has support for Markdown, LaTeX-style mathematical equations (via [MathJax](https://www.mathjax.org/)), CSV conversion to HTML tables, plot generation (via [Vega-Lite](https://vega.github.io/vega-lite/) or [Chart.js](https://www.chartjs.org/)), and diagram generation (via [mermaid](https://mermaidjs.github.io/)). Many more features can be added simply by importing an existing JavaScript or CSS framework. ## Installing ReLaXed Install ReLaXed via [NPM](https://www.npmjs.com/) with this command (do not use ``sudo``): ``` npm i -g relaxedjs ``` This will provide your system with the ``relaxed`` command. If the installation fails, refer to the [troubleshooting page](https://github.com/RelaxedJS/ReLaXed/wiki/Troubleshooting). You can also use ReLaXed via Docker (see [this repository](https://github.com/jonathanasquier/ReLaXed-docker/blob/master/Dockerfile)) ## Getting started To start a project, create a new document ``my_document.pug`` with the following Pug content: ```pug h1 My document's title p A paragraph in my document ``` Then start ReLaXed from a terminal: ``` relaxed my_document.pug ``` ReLaXed will generate ``my_document.pdf`` from ``my_document.pug``, then watch its directory and subdirectories so that every time a file changes, ``my_document.pdf`` will be re-generated. It is also possible to generate the PDF file just once, with no sub-sequent file-watching, with this command: ``` relaxed my_document.pug --build-once ``` To go further: - Read more about [usage and options](https://github.com/RelaxedJS/ReLaXed/wiki/Command-line-options) of the ``relaxed`` command. - Learn more about the capabilities of the [Pug language](https://pugjs.org/api/getting-started.html) - Learn how to use or write [ReLaXed plugins](https://github.com/RelaxedJS/ReLaXed/wiki/Plugins) - Browse the [examples](https://github.com/RelaxedJS/ReLaXed-examples) - Read about our [recommended setup](https://github.com/RelaxedJS/ReLaXed/wiki/Tips-and-recommendations) to use ReLaXed - read about [special file rendering](https://github.com/RelaxedJS/ReLaXed/wiki/Special-file-renderings) in ReLaxed - Read [these comparisons](https://github.com/RelaxedJS/ReLaXed/wiki/ReLaXed-vs-other-solutions) between ReLaXed and other document-editing systems ## Why yet another PDF document creator? Many of us prefer markup languages (Markdown, LaTeX, etc.) to GUI document-editors like MS Office or Google Docs. This is because markup languages make it easier to quickly write documents in a consistent style. However, Markdown is limited to the title/sections/paragraphs structure, and LaTeX has obscure syntax and errors that also make it difficult to stray from the beaten track. On the other hand, web technologies have never looked so good. - Beautiful CSS frameworks make sure your documents look clean and modern. - There are JavaScript libraries for pretty much anything: plotting, highlight code, rendering equations... - Millions of people (and growing) know how to use these. - Shorthand languages like Pug and SCSS are finally making it fun to write HTML and CSS. - (Headless) web browsers can easily turn web documents into PDF, on any platform. ReLaXed is an attempt at finding the most comfortable way to leverage this for desktop PDF creation. ## How ReLaXed works ReLaXed consists of a few lines of code binding together other software. It uses [Chokidar](https://github.com/paulmillr/chokidar) to watch the file system. When a file is changed, several JavaScript libraries are used to compile SCSS, Pug, Markdown, and diagram files (mermaid, flowchart.js, Chart.js) into an HTML page which is then printed to a PDF file by a headless instance of Chromium (via [Puppeteer](https://github.com/GoogleChrome/puppeteer)).

## Using it as a Node Module **MasterToPDF.js** is exposed by default as main package, which can be used directly. An Example: ```javascript const { masterToPDF } = require('relaxedjs'); const puppeteer = require('puppeteer'); const plugins = require('relaxedjs/src/plugins'); const path = require('path'); class HTML2PDF { constructor() { this.puppeteerConfig = { headless: true, args: [ '--no-sandbox', '--disable-translate', '--disable-extensions', '--disable-sync', ], }; this.relaxedGlobals = { busy: false, config: {}, configPlugins: [], }; this._initializedPlugins = false; } async _initializePlugins() { if (this._initializedPlugins) return; // Do not initialize plugins twice for (const [i, plugin] of plugins.builtinDefaultPlugins.entries()) { plugins.builtinDefaultPlugins[i] = await plugin.constructor(); } await plugins.updateRegisteredPlugins(this.relaxedGlobals, '/'); const chrome = await puppeteer.launch(this.puppeteerConfig); this.relaxedGlobals.puppeteerPage = await chrome.newPage(); this._initializedPlugins = true; } async pdf(templatePath, json_data, tempHtmlPath, outputPdfPath) { await this._initializePlugins(); if (this._initializedPlugins) { // Paths must be absolute const defaultTempHtmlPath = tempHtmlPath || path.resolve('temp.html'); const defaultOutputPdfPath = outputPdfPath || path.resolve('output.pdf'); await masterToPDF( templatePath, this.relaxedGlobals, defaultTempHtmlPath, defaultOutputPdfPath, json_data ); } } } module.exports = new HTML2PDF(); ``` Usage: ```javascript const HTML2PDF = require('./HTML2PDF.js'); (async () => { await HTML2PDF.pdf('./template.pug', {"a":"b", "c":"d"}); })(); ``` ## Contribute! ReLaXed is an open-source framework originally written by [Zulko](https://github.com/Zulko) and released on [Github](https://github.com/RelaxedJS/ReLaXed) under the ISC licence. Everyone is welcome to contribute! For bugs and feature requests, open a Github issue. For support or Pug/HTML-related questions, ask on Stackoverflow or on the brand new [reddit/r/relaxedjs](https://www.reddit.com/r/relaxedjs/) forum, which can be used for any kind of discussion. **Projects members:** - [@Zulko](https://github.com/Zulko) (Owner) - [@Drew-S](https://github.com/Drew-S) (architecture, plugins) - [@DanielRuf](https://github.com/DanielRuf) - [@benperiton](https://github.com/benperiton) ## License [ISC](https://github.com/RelaxedJS/ReLaXed/blob/master/LICENCE.txt) ================================================ FILE: changelog.md ================================================ # Changelog ## v0.1.6 Features: - Now exposing ``require`` for use in in-Pug javascript - Bibliography system, built-in. - Shorthand ``--bo`` for ``--build-once`` Fixes: - fixed typo breaking special converters (mermaid, etc.) - faster PDF rendering by using chromium's DOM instead of cheerio. - better (faster) stepSVG mixin Internal: - Lots of code reorganization, mostly by @Drew-S, and marks for future plugins. - A test suite ! But not for interactive usage yet. - Added project members to readme. ## v 0.1.5 - Some console output fixes - Now avoiding new renderings when already busy. - New "built-in" mixin ``stepsSVG`` for including progressive (i.e. animated) SVGs into slides - New command-line utility ``pretty-pdf-thumbnail`` shipped with ReLaXed. ## v 0.1.4 Important release with speed and features improvements. Breaking API changes: - Now using ``template#page-footer``, ``template#page-header`` to define page header and footer. - MathJax de-activated by default New features: - Command-line parameter ``--build-once`` for one-time builds - New exposed javascript globals in templates: - Packages: ``fs``, ``cheerio`` - Variables: ``basedir`` (indicating the base path of the master file) - Experimental Katex filter now available - Files with extension ``.o.svg`` are automatically converted to optimized svgs (``*_optimized.svg``). - Console now shows a breakdown of rendering time. Other changes in the code: - First test suite !! - Removed some jstransformer dependencies. - Refactoring with utils.py ================================================ FILE: package.json ================================================ { "name": "relaxedjs", "version": "0.2.6", "description": "Create PDF documents using web technologies (PDF/SCSS)", "main": "src/masterToPDF.js", "bin": { "relaxed": "src/index.js", "pretty-pdf-thumbnail": "src/cli-tools/pretty-pdf-thumbnail.js", "pdf2gif": "src/cli-tools/pdf2gif.js" }, "scripts": { "test": "mocha" }, "author": "Zulko", "homepage": "https://github.com/RelaxedJS", "license": "ISC", "dependencies": { "@iktakahiro/markdown-it-katex": "^4.0.1", "cheerio": "^1.1.2", "chokidar": "^4.0.3", "colors": "^1.4.0", "commander": "^14.0.0", "csvtojson": "^2.0.10", "deptree": "^1.0.0", "filesize": "^11.0.2", "html2jade": "^0.8.6", "jimp": "^1.6.0", "js-yaml": "^4.1.0", "jstransformer-highlight": "^2.0.0", "jstransformer-markdown-it": "^3.0.0", "katex": "^0.16.22", "markdown-it": "^14.1.0", "markdown-it-footnote": "^4.0.0", "mathjax-node-page": "^3.2.0", "mermaid": "^11.9.0", "pug": "^3.0.3", "puppeteer": "^24.15.0", "sass": "^1.89.2" }, "devDependencies": { "diff": "^8.0.2", "mocha": "^11.7.1", "pdf-image": "^2.0.0", "pixel-diff": "^1.0.1" } } ================================================ FILE: src/builtin_plugins/chartjs/index.js ================================================ const pug = require('pug') const fs = require('fs') const path = require('path') exports.constructor = async function (params) { return { watchers: [ { extensions: ['.chart.js'], handler: chartjsHandler } ] } } var chartjsHandler = async function (chartjsPath, page) { var chartSpec = fs.readFileSync(chartjsPath, 'utf8') var html = pug.renderFile(path.join(__dirname, 'template.pug'), { chartSpec }) var tempHTML = chartjsPath + '.htm' fs.writeFileSync(tempHTML, html) await page.setContent(html) await page.waitForFunction(() => window.pngData) const dataUrl = await page.evaluate(() => window.pngData) const { buffer } = parseDataUrl(dataUrl) var pngPath = chartjsPath.substr(0, chartjsPath.length - '.chart.js'.length) + '.png' fs.writeFileSync(pngPath, buffer, 'base64') } // Scrape (pull) images from the web // from https://intoli.com/blog/saving-images/ var parseDataUrl = function (dataUrl) { const matches = dataUrl.match(/^data:(.+);base64,(.+)$/) if (matches.length !== 3) { throw new Error('Could not parse data URL.') } return { mime: matches[1], buffer: Buffer.from(matches[2], 'base64') } } ================================================ FILE: src/builtin_plugins/chartjs/template.pug ================================================ script(src='https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.7.2/Chart.min.js') #chartContainer canvas#myChart script. window.pngData = false var canvas = document.getElementById("myChart") var container = document.getElementById("chartContainer") function onComplete () { window.pngData = canvas.toDataURL(); } var config=!{chartSpec}; config.options.animation = {duration: 0, onComplete: onComplete} if (config.options.width) { canvas.style.width = config.options.width container.style.width = config.options.width } if (config.options.height) { canvas.style.height = config.options.height container.style.height = config.options.height } var ctx = document.getElementById("myChart").getContext('2d'); var myChart = new Chart(ctx, config) ================================================ FILE: src/builtin_plugins/flowchart/index.js ================================================ const pug = require('pug') const fs = require('fs') const path = require('path') exports.constructor = async function (params) { return { watchers: [ { extensions: ['.flowchart', '.flowchart.json'], handler: flowchartHandler } ] } } var flowchartHandler = async function (flowchartPath, page) { if (flowchartPath.endsWith('.flowchart.json')) { flowchartPath = flowchartPath.substr(0, flowchartPath.length - 5) } var flowchartSpec = fs.readFileSync(flowchartPath, 'utf8') var flowchartConf = '{}' var possibleConfs = [ path.join(path.resolve(flowchartPath, '..'), 'flowchart.default.json'), flowchartPath + '.json' ] for (var myPath of possibleConfs) { if (fs.existsSync(myPath)) { flowchartConf = fs.readFileSync(myPath, 'utf8') } } var html = pug.renderFile(path.join(__dirname, 'template.pug'), { flowchartSpec, flowchartConf }) await page.setContent(html) await page.waitForSelector('#chart svg') var svg = await page.evaluate(function () { var el = document.querySelector('#chart svg') el.removeAttribute('height') el.removeAttribute('width') el.classList.add('flowchart-svg') return el.outerHTML }) var svgPath = flowchartPath.substr(0, flowchartPath.lastIndexOf('.')) + '.svg' fs.writeFileSync(svgPath, svg) } ================================================ FILE: src/builtin_plugins/flowchart/template.pug ================================================ script(src='http://cdnjs.cloudflare.com/ajax/libs/raphael/2.2.0/raphael-min.js') script(src='http://cdnjs.cloudflare.com/ajax/libs/jquery/1.11.0/jquery.min.js') script(src='http://flowchart.js.org/flowchart-latest.js') script. $(document).ready(function() { var chart = flowchart.parse(`!{flowchartSpec}`) var conf = {'symbols': []} for (var c of ['start', 'end', 'operation', 'subroutine', 'condition', 'inputoutput']) { conf.symbols[c] = {'class': c + '-element'} } conf = Object.assign({}, conf, !{flowchartConf}) chart.drawSVG('chart', conf) }); body #chart ================================================ FILE: src/builtin_plugins/index.js ================================================ const chartjs = require('./chartjs') const table = require('./table') const mathjax = require('./mathjax') const markdown = require('./markdown') const vegalite = require('./vegalite') const mermaid = require('./mermaid') const flowchart = require('./flowchart') const scss = require('./scss') const katex = require('./katex') // THESE ARE PLUGINS THAT CAN BE LOADED VIA CONFIG.PY // WE WILL CERTAINLY TAKE OUT MOST OF THEM, AS SEPARATE PLUGINS exports.plugins = { mathjax, katex, markdown } // THESE ARE PLUGINS ADDING NO OVERHEAD, SO SAFE TO BE USED BY DEFAULT // WE WILL ALSO CERTAINLY TAKE OUT MOST OF THEM, AS SEPARATE PLUGINS, // TO AVOID TOO MANY DEPENDENCIES exports.defaultPlugins = [ table, chartjs, vegalite, flowchart, mermaid, scss, markdown ] ================================================ FILE: src/builtin_plugins/katex/head.html ================================================ ================================================ FILE: src/builtin_plugins/katex/index.js ================================================ const katex = require('katex') const fs = require('fs') const path = require('path') exports.constructor = async function (params) { return { headElements: fs.readFileSync(path.join(__dirname, 'head.html')), pugFilters: { katex (text, options) { return katex.renderToString(text) } } } } ================================================ FILE: src/builtin_plugins/markdown/index.js ================================================ var hljs = require('highlight.js') // https://highlightjs.org/ var markdown = require('markdown-it') var mdFootnote = require('markdown-it-footnote') var mdKatex = require('@iktakahiro/markdown-it-katex') exports.constructor = async function (params) { var href = "https://cdn.jsdelivr.net/npm/katex@0.11.1/dist/katex.min.css" return { pugFilters: { markdown: MarkdownPugFilter }, headElements: `` } } function MarkdownPugFilter (text, options) { var md = markdown({ ...options, highlight: function (str, lang) { if (lang && hljs.getLanguage(lang)) { try { return '
' +
                  hljs.highlight(lang, str, true).value +
                  '
'; } catch (__) {} } return '
' + md.utils.escapeHtml(str) + '
' } }) md.use(mdFootnote) md.use(mdKatex) return md.render(text) } ================================================ FILE: src/builtin_plugins/mathjax/index.js ================================================ const mjpage = require('mathjax-node-page') // TODO: would this work better if applied to the page instead of the HTML ? exports.constructor = async function (params) { return { htmlModifiers: [ asyncMathjax ] } } var asyncMathjax = async function (html) { return new Promise(resolve => { mjpage.mjpage(html, { format: ['TeX'] }, { mml: true, css: true, html: true }, response => resolve(response)) }) } ================================================ FILE: src/builtin_plugins/mermaid/index.js ================================================ const pug = require('pug') const fs = require('fs') const path = require('path') exports.constructor = async function (params) { return { watchers: [ { extensions: ['.mermaid'], handler: mermaidHandler } ] } } var mermaidHandler = async function (mermaidPath, page) { var mermaidSpec = fs.readFileSync(mermaidPath, 'utf8') var html = pug.renderFile(path.join(__dirname, 'template.pug'), { mermaidSpec }) await page.setContent(html) await page.waitForSelector('#graph svg') var svg = await page.evaluate(function () { var el = document.querySelector('#graph svg') el.removeAttribute('height') el.classList.add('mermaid-svg') return el.outerHTML }) var svgPath = mermaidPath.substr(0, mermaidPath.lastIndexOf('.')) + '.svg' fs.writeFileSync(svgPath, svg) } ================================================ FILE: src/builtin_plugins/mermaid/template.pug ================================================ script(src="https://cdnjs.cloudflare.com/ajax/libs/mermaid/7.1.2/mermaid.min.js") #graph.mermaid #{mermaidSpec} ================================================ FILE: src/builtin_plugins/scss/index.js ================================================ const sass = require('sass') exports.constructor = async function (params) { return { pugFilters: { scss: ScssPugFilter } } } function ScssPugFilter (text, options) { if (options.filename.endsWith('scss')) return sass.compile(options.filename).css return sass.compileString(text).css } ================================================ FILE: src/builtin_plugins/table/index.js ================================================ const pug = require('pug') const fs = require('fs') const path = require('path') const csv = require('csvtojson') var markdown = require('markdown-it') const html2jade = require('html2jade') exports.constructor = async function (params) { return { watchers: [ { extensions: ['.table.csv', '.htable.csv', '.htable.md.csv'], async handler (tablePath, page) { await csvTtableToPug(tablePath) } } ] } } var csvTtableToPug = async function (tablePath) { const rows = await csv({output: 'csv', noheader: true, delimiter: 'auto'}).fromFile(tablePath) var isMarkdown = tablePath.includes('.md.') if (isMarkdown) { console.log('ah') md = markdown({html: true}) rows.forEach(row => { row.forEach((data, i) => { row[i] = md.render(data) }) }) } var extension, header if (tablePath.endsWith('.htable.csv')) { extension = '.htable.csv' header = rows.shift() } else { extension = '.table.csv' header = null } var html = await pug.renderFile(path.join(__dirname, 'template.pug'), { header: header, tbody: rows }) var pugPath = tablePath.substr(0, tablePath.length - extension.length - 3*isMarkdown) + '.pug' var jade = await new Promise(resolve => { html2jade.convertHtml(html, { bodyless: true }, function (err, jade) { if (err) { console.log('Error:', err) resolve(false) } else { resolve(jade) } }) }) fs.writeFileSync(pugPath, jade) } ================================================ FILE: src/builtin_plugins/table/template.pug ================================================ if header thead for th in header th!= th tbody for tr in tbody tr for td in tr td!= td ================================================ FILE: src/builtin_plugins/vegalite/index.js ================================================ const pug = require('pug') const fs = require('fs') const path = require('path') exports.constructor = async function (params) { return { watchers: [ { extensions: ['.vegalite.json'], handler: vegaliteHandler } ] } } var vegaliteHandler = async function (vegalitePath, page) { var vegaliteSpec = fs.readFileSync(vegalitePath, 'utf8') var html = pug.renderFile(path.join(__dirname, 'template.pug'), { vegaliteSpec }) await page.setContent(html) await page.waitForSelector('#vis svg') var svg = await page.evaluate(function () { var el = document.querySelector('#vis svg') el.removeAttribute('height') el.removeAttribute('width') return el.outerHTML }) var svgPath = vegalitePath.substr(0, vegalitePath.length - '.vegalite.json'.length) + '.svg' fs.writeFileSync(svgPath, svg) } ================================================ FILE: src/builtin_plugins/vegalite/template.pug ================================================ script(src='https://cdn.jsdelivr.net/npm/vega@5.21.0') script(src='https://cdn.jsdelivr.net/npm/vega-lite@5.2.0') script(src='https://cdn.jsdelivr.net/npm/vega-embed@6.20.2') #vis script(type='text/javascript'). vegaEmbed('#vis', !{vegaliteSpec}, {'renderer': 'svg'}); ================================================ FILE: src/cli-tools/pdf2gif.js ================================================ #!/usr/bin/env node const colors = require('colors') const program = require('commander') const path = require('path') const { spawn } = require('child_process') const version = require('../../package.json').version var input, output program .version('From ReLaXed ' + version) .usage(' [output] [options]') .arguments(' [output] [options]') .option('--width, -w', 'width in pixels') .option('--delay, -d', 'delay between frames') .option('--colors, -c', 'number of colors') .action(function (inp, out) { input = inp output = out }) program.parse(process.argv) output = output || (input.slice(0, input.length - 4) + '.gif') var width = (program.width || 400).toString() var delay = (100 * (program.delay || 1.0)).toString() var ncolors = (program.colors || 256).toString() var subprocess = spawn('convert', [ '-delay', delay, '-resize', width, '-colors', ncolors, '-layers', 'optimize', input, output ]) subprocess .on('data', function (data) { console.log(data) }) .on('close', async function (code) { if (code) { console.log(code) } else { console.log('...done.') } }) ================================================ FILE: src/cli-tools/pretty-pdf-thumbnail.js ================================================ #!/usr/bin/env node const colors = require('colors') const program = require('commander') const path = require('path') const { spawn } = require('child_process') const version = require('../../package.json').version var input, output program .version('From ReLaXed ' + version) .usage(' [output] [options]') .arguments(' [output] [options]') .option('--width, -w', 'width in pixels') .option('--shadow, -s', 'shadow size in pixels', []) .action(function (inp, out) { input = inp output = out }) program.parse(process.argv) program.shadow = program.shadow || 15 var subprocess = spawn('convert', [ '-density', '300', input + '[0]', '-resize', ((program.size || 600) - 4 * program.shadow).toString(), `(`, '+clone', '-background', 'black', '-shadow', `${program.shadow}x${program.shadow}+1+1`, `)`, '+swap', '-background', 'white', '-layers', 'merge', '+repage', output ]) subprocess .on('data', function (data) { console.log(data) }) .on('close', async function (code) { if (code) { console.log(code) } else { console.log('...done.') } }) ================================================ FILE: src/index.js ================================================ #!/usr/bin/env node const colors = require('colors/safe') const { program } = require('commander') const chokidar = require('chokidar') const puppeteer = require('puppeteer') const yaml = require('js-yaml') const { performance } = require('perf_hooks') const path = require('path') const fs = require('fs') const plugins = require('./plugins') const { masterToPDF } = require('./masterToPDF.js') const { parseLocals, isLastLocalJsonPath } = require('./parseLocals'); const { exec } = require('child_process') var input, output const version = require('../package.json').version program .version(version) .usage(' [output] [options]') .arguments(' [output] [options]') .option('--no-sandbox', 'disable puppeteer sandboxing') .option('-w, --watch ', 'Watch other locations', []) .option('-t, --temp [location]', 'Directory for temp file') .option('--bo, --build-once', 'Build once only, do not watch') .option('-l, --locals ', 'Json locals for pug rendering, string or path to .json file') .option('--basedir ', 'Base directory for absolute paths, e.g. /') .action(function (inp, out) { input = inp output = out }) // ARGUMENTS PARSING AND SETUP program.parse(process.argv) const options = program.opts() if (!input || fs.lstatSync(input).isDirectory()) { input = autodetectMasterFile(input) } const inputPath = path.resolve(input) const inputDir = path.resolve(inputPath, '..') const inputFilenameNoExt = path.basename(input, path.extname(input)) var configPath for (var filename of ['config.yml', 'config.json']) { let possiblePath = path.join(inputDir, filename) if (fs.existsSync(possiblePath)) { configPath = possiblePath } } // Output file, path, and temp html path if (!output) { output = path.join(inputDir, inputFilenameNoExt + '.pdf') } const outputPath = path.resolve(output) var tempDir if (options.temp) { var validTempPath = fs.existsSync(options.temp) && fs.statSync(options.temp).isDirectory() if (validTempPath) { tempDir = path.resolve(options.temp) } else { console.error(colors.red('ReLaXed error: Could not find specified --temp directory: ' + options.temp)) process.exit(1) } } else { tempDir = inputDir } const tempHTMLPath = path.join(tempDir, inputFilenameNoExt + '_temp.htm') // Default and additional watch locations let watchLocations = [inputDir] if (options.watch) { watchLocations = watchLocations.concat(options.watch) } let locals = parseLocals(options.locals, inputDir) // Google Chrome headless configuration const puppeteerConfig = { headless: true, args: (!options.sandbox ? ['--no-sandbox'] : []).concat([ '--disable-translate', '--disable-extensions', '--disable-sync' ]) } /* * ============================================================== * MAIN * ============================================================== */ const relaxedGlobals = { busy: false, config: {}, configPlugins: [], basedir: options.basedir || inputDir } var updateConfig = async function () { if (configPath) { console.log(colors.magenta('... Reading config file')) var data = fs.readFileSync(configPath, 'utf8') if (configPath.endsWith('.json')) { relaxedGlobals.config = JSON.parse(data) } else { relaxedGlobals.config = yaml.load(data) } } await plugins.updateRegisteredPlugins(relaxedGlobals, inputDir) } async function main () { console.log(colors.magenta.bold('Launching ReLaXed...')) // LOAD BUILT-IN "ALWAYS-ON" PLUGINS for (var [i, plugin] of plugins.builtinDefaultPlugins.entries()) { plugins.builtinDefaultPlugins[i] = await plugin.constructor() } await updateConfig() const browser = await puppeteer.launch(puppeteerConfig) relaxedGlobals.puppeteerPage = await browser.newPage() relaxedGlobals.puppeteerPage.on('pageerror', function (err) { console.log(colors.red('Page error: ' + err.toString())) }).on('error', function (err) { console.log(colors.red('Error: ' + err.toString())) }) const buildError = await build(inputPath) if (program.buildOnce) { process.exit(buildError ? 1 : 0) } else { watch() } } /* * ============================================================== * BUILD * ============================================================== */ async function build (filepath) { var shortFileName = filepath.replace(inputDir, '') if ((path.basename(filepath) === 'config.yml') || (filepath.endsWith('.plugin.js'))) { await updateConfig() return } var updatedLocals = false if (isLastLocalJsonPath(filepath)) { locals = parseLocals(options.locals, inputDir) updatedLocals = true } var page = relaxedGlobals.puppeteerPage // Ignore the call if ReLaXed is already busy processing other files. if (!updatedLocals && !(relaxedGlobals.watchedExtensions.some(ext => filepath.endsWith(ext)))) { if (!(['.pdf', '.htm'].some(ext => filepath.endsWith(ext)))) { console.log(colors.grey(`No process defined for file ${shortFileName}.`)) } return } if (relaxedGlobals.busy) { console.log(colors.grey(`File ${shortFileName}: ignoring trigger, too busy.`)) return } console.log(colors.magenta.bold(`\nProcessing ${shortFileName}...`)) relaxedGlobals.busy = true var t0 = performance.now() var taskPromise = null for (var watcher of relaxedGlobals.pluginHooks.watchers) { if (watcher.instance.extensions.some(ext => filepath.endsWith(ext))) { taskPromise = watcher.instance.handler(filepath, page) break } } var generatingPDF = !taskPromise if (generatingPDF) { taskPromise = masterToPDF(inputPath, relaxedGlobals, tempHTMLPath, outputPath, locals) } const generateError = await taskPromise var duration = ((performance.now() - t0) / 1000).toFixed(2) console.log(colors.magenta.bold(`... Done in ${duration}s`)) if (generatingPDF && relaxedGlobals.config.after && !generateError) { console.log(colors.magenta.bold("Running 'after' command...")) var subprocess = exec(relaxedGlobals.config.after, cwd=relaxedGlobals.basedir) subprocess.stdout.on('data', (data) => { console.log(`after-stdout: ${data}`); }); subprocess.stderr.on('data', (data) => { console.error(`after-stderr: ${data}`); }); var promise = new Promise(resolve => { subprocess.on('close', async function (code) { resolve() }) }) await promise console.log(colors.magenta.bold("...done running 'after' command.")) } relaxedGlobals.busy = false return generateError } /** * Watch `watchLocations` paths for changes and continuously rebuild * * @param {puppeteer.Page} page */ /* * ============================================================== * WATCH * ============================================================== */ function watch () { console.log(colors.magenta(`\nNow idle and waiting for file changes.`)) chokidar.watch(watchLocations, { awaitWriteFinish: { stabilityThreshold: 50, pollInterval: 100 } }).on('change', build) } function autodetectMasterFile (input) { var dir = input || '.' var files = fs.readdirSync(dir).filter((name) => name.endsWith('.pug')) var filename if (files.length === 1) { filename = files[0] } else if (files.indexOf('master.pug') >= 0) { filename = 'master.pug' } else { var error if (input) { error = `Could not find a master file in the provided directory ${input}` } else { error = `No input provided and could not find a master file in the current directory` } console.log(colors.red.bold(error)) program.help() process.exit(1) } return path.join(dir, filename) } main() ================================================ FILE: src/masterToPDF.js ================================================ const pug = require('pug') const colors = require('colors/safe') const cheerio = require('cheerio') const fs = require('fs') const { filesize } = require('filesize') const path = require('path') const { performance } = require('perf_hooks') // Returns undefined if successful or an error object on failure exports.masterToPDF = async function (masterPath, relaxedGlobals, tempHTMLPath, outputPath, locals) { var t0 = performance.now() var page = relaxedGlobals.puppeteerPage /* * Generate HTML */ var pluginHooks = relaxedGlobals.pluginHooks var html if (masterPath.endsWith('.pug')) { var pluginPugHeaders = [] for (var pugHeader of pluginHooks.pugHeaders) { pluginPugHeaders.push(pugHeader.instance) } pluginPugHeaders = pluginPugHeaders.join('\n\n') var pugFilters = Object.assign(...pluginHooks.pugFilters.map(o => o.instance)) try { var masterPug = fs.readFileSync(masterPath, 'utf8') html = pug.render(pluginPugHeaders + '\n' + masterPug, Object.assign({}, locals ? locals : {}, { filename: masterPath, fs: fs, basedir: relaxedGlobals.basedir, cheerio: cheerio, __root__: path.dirname(masterPath), path: path, require: require, performance: performance, filters: pugFilters })) } catch (error) { console.log(error.message) console.error(colors.red('There was a Pug error (see above)')) return error } } else if (masterPath.endsWith('.html')) { html = fs.readFileSync(masterPath, 'utf8') } /* * MODIFY HTML */ var head = pluginHooks.headElements.map(e => e.instance).join(`\n\n`) html = ` ${head} ${html} ` for (var htmlModifier of pluginHooks.htmlModifiers) { html = await htmlModifier.instance(html) } fs.writeFileSync(tempHTMLPath, html) var tHTML = performance.now() console.log(colors.magenta(`... HTML generated in ${((tHTML - t0) / 1000).toFixed(1)}s`)) /* * LOAD HTML */ try { await page.goto('file:' + tempHTMLPath, { waitUntil: ['load', 'domcontentloaded'], timeout: 1000 * (relaxedGlobals.config.pageRenderingTimeout || 30) }) } catch(error) { console.log(error.message) console.error(colors.red('There was a page loading error.')) if (error.message.indexOf('Timeout') > 0) { console.log('Hey this looks like a timeout. Your project must be big. ' + 'Increase the timeout by writing "pageRenderingTimeout: 60" ' + 'at the top of your config.yml. Default is 30 (seconds).') } return error } var tLoad = performance.now() console.log(colors.magenta(`... Document loaded in ${((tLoad - tHTML) / 1000).toFixed(1)}s`)) await waitForNetworkIdle(page, 200) var tNetwork = performance.now() console.log(colors.magenta(`... Network idled in ${((tNetwork - tLoad) / 1000).toFixed(1)}s`)) // Get header/footer template var header = await page.$eval('#page-header', element => element.innerHTML) .catch(error => '') var footer = await page.$eval('#page-footer', element => element.innerHTML) .catch(error => '') if (header !== '' && footer === '') { footer = '' } if ((footer !== '') && (header === '')) { header = '' } /* * Create PDF options */ var options = { path: outputPath, displayHeaderFooter: !!(header || footer), headerTemplate: header, footerTemplate: footer, printBackground: true } function getMatch (string, query) { var result = string.match(query) if (result) { result = result[1] } return result } var width = getMatch(html, /-relaxed-page-width: (\S+);/m) if (width) { options.width = width } var height = getMatch(html, /-relaxed-page-height: (\S+);/m) if (height) { options.height = height } var size = getMatch(html, /-relaxed-page-size: (\S+);/m) if (size) { options.size = size } for (var pageModifier of pluginHooks.pageModifiers) { await pageModifier.instance(page) } for (pageModifier of pluginHooks.page2ndModifiers) { await pageModifier.instance(page) } // TODO: add option to output full html from page /* * PRINT PAGE TO PDF */ await page.pdf(options) var tPDF = performance.now() let duration = ((tPDF - tNetwork) / 1000).toFixed(1) let pdfSize = filesize(fs.statSync(outputPath).size) console.log(colors.magenta(`... PDF written in ${duration}s (${pdfSize})`)) } // Wait for all the content on the page to finish loading function waitForNetworkIdle (page, timeout, maxInflightRequests = 0) { page.on('request', onRequestStarted) page.on('requestfinished', onRequestFinished) page.on('requestfailed', onRequestFinished) let inflight = 0 let fulfill let promise = new Promise(x => fulfill = x) let timeoutId = setTimeout(onTimeoutDone, timeout) return promise function onTimeoutDone () { page.off('request', onRequestStarted) page.off('requestfinished', onRequestFinished) page.off('requestfailed', onRequestFinished) fulfill() } function onRequestStarted () { ++inflight if (inflight > maxInflightRequests) { clearTimeout(timeoutId) } } function onRequestFinished () { if (inflight === 0) { return } --inflight if (inflight === maxInflightRequests) { timeoutId = setTimeout(onTimeoutDone, timeout) } } } ================================================ FILE: src/parseLocals.js ================================================ const fs = require('fs') const path = require('path') const colors = require('colors/safe') let lastLocalJsonPath function logError(error, message) { console.error(error) console.error(colors.red(message)) } function readJsonFileAsString(jsonPath) { try { return fs.readFileSync(jsonPath, { encoding: 'utf-8' }) } catch (error) { logError(error, `ReLaXed error: Could not read .json file at: ${jsonPath}`) } } function parseJson(str) { try { return JSON.parse(str) } catch (error) { logError(error, 'ReLaXed error: Could not parse locals JSON, see error above.') } } function isPathToJsonFile(filePath) { return path.extname(filePath) === ".json" } function isLastLocalJsonPath(filePath) { return lastLocalJsonPath === filePath } function parseLocals(locals, inputDir) { if (!locals) { return } jsonString = locals if (isPathToJsonFile(locals)) { lastLocalJsonPath = path.join(inputDir, locals) jsonString = readJsonFileAsString(lastLocalJsonPath) } return parseJson(jsonString) } exports.isLastLocalJsonPath = isLastLocalJsonPath exports.parseLocals = parseLocals ================================================ FILE: src/plugins.js ================================================ const path = require('path') const colors = require('colors/safe') const builtinPlugins = require('./builtin_plugins') const fs = require('fs') const { execSync } = require('child_process') exports.builtinDefaultPlugins = builtinPlugins.defaultPlugins // Function to get the global npm root directory var getGlobalNpmRoot = function () { try { return execSync('npm root -g', { encoding: 'utf8', stdio: 'pipe' }).trim() } catch (error) { // Fallback to common paths if npm command fails return null } } var createConfigPlugin = async function (pluginName, parameters, localPath) { // for each plugin, look for a local definition, a built-in definition, or // a module-provided definition (module relaxed-pluginName) var origin var plugin = builtinPlugins.plugins[pluginName] if (plugin) { origin = `ReLaXed ${pluginName} built-in plugin` } else { var possiblePaths = [ { location: path.join(localPath, `${pluginName}.plugin.js`), origin: `local file ${pluginName}.plugin.js` }, { location: path.join(localPath, pluginName), origin: `local plugin ${pluginName}` }, { location: path.join(localPath, `relaxed-${pluginName}`), origin: `local relaxed-${pluginName}` }, { location: `relaxed-${pluginName}`, origin: `relaxed-${pluginName}` } ] // Add global npm root path if available var globalNpmRoot = getGlobalNpmRoot() if (globalNpmRoot) { possiblePaths.push({ location: path.join(globalNpmRoot, `relaxed-${pluginName}`), origin: `global relaxed-${pluginName}` }) } // Add common fallback paths var fallbackPaths = [ { // macOS homebrew (Apple Silicon) location: `/opt/homebrew/lib/node_modules/relaxed-${pluginName}`, origin: `relaxed-${pluginName}` }, { // linux & macOS homebrew (Intel) location: `/usr/local/lib/node_modules/relaxed-${pluginName}`, origin: `relaxed-${pluginName}` }, { // travis location: `/home/travis/build/RelaxedJS/relaxed-${pluginName}`, origin: `relaxed-${pluginName}` } ] possiblePaths = possiblePaths.concat(fallbackPaths) for (var possiblePath of possiblePaths) { try { plugin = require(possiblePath.location) origin = possiblePath.origin break } catch (error) { var expected = `Cannot find module '${possiblePath.location}'` if (error.message.indexOf(expected) === -1) { console.error(error) } } } } if (!plugin) { throw Error(`Plugin ${pluginName} not found !`) } var configuratedPlugin = await plugin.constructor(parameters) configuratedPlugin.origin = origin return configuratedPlugin } var listPluginHooks = function (pluginList) { var pluginHooks = {} var hooks = [ 'watchers', 'pugHeaders', 'pugFilters', 'headElements', 'htmlModifiers', 'pageModifiers', 'page2ndModifiers', 'postPDF' ] for (var hook of hooks) { var hookInstances = [] for (var plugin of pluginList) { try { var thisPluginHooks = plugin[hook] if (thisPluginHooks) { if (!Array.isArray(thisPluginHooks)) { thisPluginHooks = [thisPluginHooks] } for (var pluginHook of thisPluginHooks) { hookInstances.push({ instance: pluginHook, origin: plugin.origin }) } } } catch (error) { console.log(`In hook ${hook} of plugin [${plugin.origin}]:`) console.log(error.message) throw error } } pluginHooks[hook] = hookInstances } // TODO: order watchers by watched extension inclusion. return pluginHooks } var updateRegisteredPlugins = async function (relaxedGlobals, inputDir) { if (relaxedGlobals.config.plugins) { console.log(colors.magenta('... Loading config plugins')) var plugin, pluginName, params for (var pluginDefinition of relaxedGlobals.config.plugins) { try { if (typeof (pluginDefinition) === 'string') { [pluginName, params] = [pluginDefinition, {}] } else { [pluginName, params] = Object.entries(pluginDefinition)[0] } console.log(colors.magenta(` - ${pluginName} plugin`)) plugin = await createConfigPlugin(pluginName, params, inputDir) relaxedGlobals.configPlugins.push(plugin) } catch (error) { console.log(error.message) console.error(colors.bold.red(`Could not load plugin ${pluginName}`)) } } } var allPlugins = relaxedGlobals.configPlugins.concat(builtinPlugins.defaultPlugins) relaxedGlobals.pluginHooks = listPluginHooks(allPlugins) // TODO: remove some of these extensions as they get covered by plugins. relaxedGlobals.watchedExtensions = [ '.pug', '.md', '.html', '.css', '.scss', '.svg', '.png', '.jpeg', '.jpg' ] for (var watcher of relaxedGlobals.pluginHooks.watchers) { var exts = watcher.instance.extensions relaxedGlobals.watchedExtensions = relaxedGlobals.watchedExtensions.concat(exts) } } exports.updateRegisteredPlugins = updateRegisteredPlugins exports.listPluginHooks = listPluginHooks exports.createConfigPlugin = createConfigPlugin ================================================ FILE: test/samples/interactive_example/diagrams/diagram.mermaid ================================================ graph LR mermaid --> svg vega-lite --> svg svg --> html pug --> html html -->|Chrome| pdf ================================================ FILE: test/samples/interactive_example/diagrams/plot.vegalite.json ================================================ { "$schema": "https://vega.github.io/schema/vega-lite/v2.0.json", "data": { "values": [ {"a": "A","b": 28}, {"a": "B","b": 55}, {"a": "C","b": 43}, {"a": "D","b": 91}, {"a": "E","b": 81}, {"a": "F","b": 53}, {"a": "G","b": 19}, {"a": "H","b": 87}, {"a": "I","b": 52}, {"a": "J","b": 19}, {"a": "K","b": 87}, {"a": "L","b": 52}, {"a": "M","b": 19}, {"a": "N","b": 87}, {"a": "O","b": 52} ] }, "mark": "bar", "encoding": { "x": {"field": "a", "type": "ordinal"}, "y": {"field": "b", "type": "quantitative"}, "color": {"value": "#b4588c"} } } ================================================ FILE: test/samples/interactive_example/master.pug ================================================ .report-sidebar: p A sidebar will always make your document look nicer ! h1#title Beautiful PDF documents with web technologies .summary.ui.piled.segment: span. We tend to prefer mark-up languages (Markdown, LaTeX, etc.) to interfaced document editors like MS Office or Google Docs, because they make it easier to quickly write documents with consistent style. However, Markdown is limited to the title/sections/paragraphs structure, and LaTeX has obscure syntax and errors that also make it difficult to go off the beaten tracks. This report introduces ReLaXed, a solution using Pug and SCSS for document definition and Google Chrome for rendering. :markdown-it ## Web technologies have never looked so good Plenty of CSS frameworks will make sure your documents will look clean and modern. Javascript frameworks can plot schemas, highlight code, or render maths equations the same way LaTeX does. Millions of people (and growing) are now fluent in these technologies. Shorthand languages like Pug and SCSS are finally making it fun to write HTML and CSS. (Headless) web browsers can easily turn all these technologies into PDF, on any platform. As an illustration, it took just one line to import the Semantic UI framework and style this document. Now look at this gorgeous table (don't pay attention to the content, it's place-holder) figure.block-center.width-15cm table.ui.celled.table thead tr th Feature th Framework th Notes tbody tr td Mathematical equations td MathJax td.positive #[i.icon.checkmark] Totally working tr td Plots td Vegalite td Needs testing tr.negative td Simple installation td NPM td #[i.icon.close] Problematic tr td Flowcharts td Mermaid.js td.positive #[i.icon.checkmark] Beautifully working figcaption .reference Table 1 .caption. There is not much to say about this table but hey this is a caption. Captions are cool. p Here is another cool component provided by Semantic UI: .ui.container .ui.icon.message.yellow.block-center i.lightbulb.outline.icon .content .header Give it a try ! p | The ReLaXed homepage is at a(href='https://github.com/RelaxedJS/ReLaXed') github.com/RelaxedJS/ReLaXed figure.float-left.width-8cm.ui.raised.segment .panel .label A include diagrams/plot.svg .panel .label B .top-5mm include diagrams/diagram.svg figcaption .reference Figure 1 .title Examples of graphics generated by web frameworks. .caption. This also demonstrates figure composition into panels - suck it, markdown ! #[b A. ] Graph defined as a JSON and transformed to SVG using Vega-lite and Chrome. #[b B. ] Graph generated using Mermaid and Chrome. :markdown-it(html=true) Next we will have a look at some differences between ReLaXed and other frameworks. ## ReLaXed vs other solutions Here are a few highlights of what you may win, or lose, using ReLaXed instead of another solution. This section is of course open to contributions. Let us start with Markdown. This widely supported language (Github, NPM, etc.) became very popular due to its simple and friendly syntax close to plain text. Markdown also has cool editors and extensions. One example is [``markdown-preview-enhanced``](https://atom.io/packages/markdown-preview-enhanced) which can render plots, flowcharts, and equations. ReLaXed has been specially extended so that it could support plot, flowchart, and equations (using the same underlying libraries as markdown-preview-enhanced), as illustrated in Figure 1. In addition, ReLaXed allows you to write any kind of layout with boxes, sidebars, etc. HTML elements are more fun to write with Pug (in markdown, HTML elements must be written in plain HTML). You can define macros, use conditionals and loops, use computed expressions using Javascript , and ReLaXed supports (S)CSS which is pretty cool. Last but not least, you can write parts in markdown if you want to . Yep, that was an emoji. Cost us one line of code, to import [Emoji CSS](https://afeld.github.io/emoji-css/) as a stylesheet. Now what about LaTeX ? Sure, LaTeX is wide-spread in academic and publishing communities, where it's typography and layout optimizations, and its bibliography management tool are very appreciated. But LaTeX is also known for its cryptic errors, its complex advanced syntax which not many make the effort to learn, and as a consequence not many LaTeX venture on the creative side with their own themes and layouts. Certainly the letter and paragraph spacings won't be as nice in ReLaXed (but Google Chrome is still doing a very good job), but the syntax, clear error messages, etc. will certainly make you happier. ReLaXed is also possibly faster to render big documents (not entirely sure though ). template#page-footer style(type='text/css'). .pdfheader { font-size: 10px; font-family: Helvetica; font-weight: bold; width: 1000px; border-top: 1px solid black; margin-left: 10%; margin-right: 10%; padding-top: 1mm; margin-bottom: -1mm; text-align: center; } .pdfheader Page #[span.pageNumber] / #[span.totalPages] style include:scss report.scss ================================================ FILE: test/samples/interactive_example/report.scss ================================================ @import 'https://cdnjs.cloudflare.com/ajax/libs/semantic-ui/2.3.0/semantic.min.css'; @import 'https://afeld.github.io/emoji-css/emoji.css'; $primary_color: #b4588c; $page-width: 17cm; @page { margin: 1cm 1cm 2cm 0cm; } html { padding-left: 1cm; padding-right: 1cm; font-family: 'Lato'; font-size: 14px !important; } body { margin-left: 1cm; } #title { font-weight: bold; font-size: 2.5em; } .summary { background-color: lighten($primary_color, 33%); } h2 { font-size: 1.8em; } p { line-height: 1.5em; font-size: 1.1em !important; text-align: justify; } a { text-decoration: underline; color: inherit; } $sidebar-width: 0.25in; $sidebar-height: 943px; .report-sidebar { position: fixed; left: -$sidebar-width; top: 0; width: $sidebar-width; height: $sidebar-height; writing-mode: vertical-rl; border-top: $sidebar-width solid transparent; border-left: $sidebar-width solid lighten($primary_color, 10%); border-bottom: $sidebar-width solid transparent; p { text-align: center; color: lighten($primary_color, 30%); margin-top: -1.55em; } } .block-center { display: block; margin: 0 auto; } .mermaid-svg { max-width: 100%; height: auto; display: block; .node { rect { fill: none; rx: 5px; ry: 5px; stroke: black; } } .edgelabel { background: white; color: #444; } } .message, figure { page-break-inside: avoid; } .message { margin-bottom: 1em !important; } figure { margin-top: 0.5em; margin-bottom: 1em; padding: 0.5em; &> .panel { position: relative; display: inline-block; vertical-align: top; width: 100%; &> .label { font-weight: bold; position: absolute; top: 0; left: 0; } } &.float-left { margin-left: -1em !important; margin-right: 2em !important; margin-top: 1em; margin-bottom: 1em; float: left; } figcaption { font-size: 0.75em; text-align: justify; width: 100%; margin-top: 1em; &> .reference, .title { font-weight: bold; display: inline; margin-right: 0.5em; } &> .reference { color: darken($primary_color, 50%); &::after { content: ': '; } } &> .title { color: darken($primary_color, 40%); } &> .caption { display: inline; } } } @for $i from 1 to 20 { .width-#{$i}cm { width: #{10 * $i}mm !important; } } @for $i from 1 to 20 { .top-#{$i}mm { margin-top: #{$i}mm; } } ================================================ FILE: test/samples/pug/absolute_path/master.pug ================================================ h1 Hello ! style include:scss /tmp/absolute_path_test.scss ================================================ FILE: test/samples/pug/basic_example/master.pug ================================================ h1 Hello ! style. h1 { font-size: 140px; text-align:center; font-family: "Arial"; margin-top: 5cm; } ================================================ FILE: test/samples/pug/data_locals/master.pug ================================================ h1 You're a #{ occupation }, #{ name } !!!! style. body { font-family: "Arial"; font-size: 14px; } h1 { font-size: 140px; margin-top: 100px; text-align:center; } ================================================ FILE: test/samples/pug/data_locals_file/data.json ================================================ { "name": "Harry", "occupation": "Wazzzard" } ================================================ FILE: test/samples/pug/data_locals_file/master.pug ================================================ h1 You're a #{ occupation }, #{ name } !!!! style. body { font-family: "Arial"; font-size: 14px; } h1 { font-size: 140px; margin-top: 100px; text-align:center; } ================================================ FILE: test/samples/pug/data_require/data.json ================================================ { "name": "Harry", "occupation": "Wazzzard" } ================================================ FILE: test/samples/pug/data_require/master.pug ================================================ - var data = require(path.join(basedir, 'data.json')) h1 You're a #{ data.occupation }, #{ data.name } !!!! style. body { font-family: "Arial"; font-size: 14px; } h1 { font-size: 140px; margin-top: 100px; text-align:center; } ================================================ FILE: test/samples/pug/error/master.pug ================================================ : Pug syntax error ================================================ FILE: test/samples/pug/header_and_footer/master.pug ================================================ h1 bla template#page-header style(type='text/css'). .page-header { font-size: 16px; font-family: "Arial"; font-weight: bold; width: 1000px; border-top: 1px solid black; margin-left: 10%; margin-right: 10%; padding-top: 1mm; margin-bottom: -1mm; text-align: center; } .page-header Page #[span.pageNumber] / #[span.totalPages] template#page-footer style(type='text/css'). .page-footer { font-size: 16px; color: green; font-family: 'Arial'; font-weight: bold; width: 1000px; border-top: 1px solid black; margin-left: 10%; margin-right: 10%; padding-top: 1mm; margin-bottom: -1mm; text-align: center; } .page-footer Page #[span.pageNumber] / #[span.totalPages] style. @page { margin: 3cm 2cm 3cm 2cm; } body { font-family: 'Arial'; font-size: 14px; } ================================================ FILE: test/samples/pug/katex/config.yml ================================================ plugins: - katex ================================================ FILE: test/samples/pug/katex/master.pug ================================================ h1 Hello Katex

#[:katex \sum{ \sqrt{x^2 + \alpha}} ] style. body { font-family: "Arial"; font-size: 14px; } h1 { font-size: 60px; margin-top: 4cm; text-align: center; } ================================================ FILE: test/samples/pug/letter/letter.scss ================================================ html { padding: 1cm 1.5cm 1cm 1.5cm; } body { padding-left: 1cm; padding-right: 1cm; font-family: 'Arial'; } .header { margin-left: -1cm; height: 80px; .logo { height: 2cm; width:auto; position:absolute; } } .signature { height: 2cm; margin-left: 1cm; width: auto; } .recipient { text-align: right; margin: 1em auto 2em; } .main-text { text-align: justify; } .opening, .closing { margin: 50px 0 20px 0px; } ================================================ FILE: test/samples/pug/letter/master.pug ================================================ .header img.logo(src='./relaxed-uni-logo.svg') .recipient .name Dr. Eddie Thor .institute Predatory Publishing Group .address John Nicholson Street .city EH1 RX2 Edinburgh .opening Dear editor, .main-text :markdown-it We are submitting our manuscript entitled *"On the creation of beautiful PDF files using yet another HTML converter but this one has a cool name"* to your *Journal of Incremental Software*, which we believe is the best indicated to publish this work (now that every bigger journal has said no). In this paper we present ReLaXed, a new framework for the interactive generation of PDFs using web technologies, in particular the *Pug* language. While many other software exist to produce high quality PDF files, they generally lack the cool packaging and novelty of Relaxed.JS (and by "novelty", we really mean "recentness"). At this time, Relaxed.JS already has one active user, and we expect this number to grow several-fold after publication in your journal. We believe that this new piece of software would find an important echo in your audience, in particular among developers interested in the creation of beautiful PDF files using web technologies, in particular the *Pug* language. Please publish us pretty please. .closing Yours sincelerely, img.signature(src='signature.svg') .sender .name Axel Red .title Head of Communications .institute Relaxed University .address United Kingdom style include:scss letter.scss ================================================ FILE: test/samples/pug/local_plugin/config.yml ================================================ plugins: - say-my-name: name: Heisenberg ================================================ FILE: test/samples/pug/local_plugin/master.pug ================================================ div(style="text-align:center; font-size:58px; margin-top: 3cm") p Say my name with a mixin !
#{name} p Again, with a HTML filter !
INSERT_NAME_HERE style. body { font-family: "Arial"; font-size: 14px; } p { text-align:center; font-size:50px; font-weight: bold; margin-top: 3cm; } ================================================ FILE: test/samples/pug/local_plugin/say-my-name.plugin.js ================================================ const fs = require('fs') exports.constructor = async function (pluginDefinition) { return { pugHeaders: [ `- var name = "${pluginDefinition.name}"` ], htmlModifiers: [ function (html) { return html.replace('INSERT_NAME_HERE', pluginDefinition.name) } ] } } ================================================ FILE: test/samples/pug/mathjax/config.yml ================================================ plugins: - mathjax ================================================ FILE: test/samples/pug/mathjax/master.pug ================================================ h1 Hello Mathjax
$$ \sum{ \sqrt{x^2 + \alpha} } $$ style. body { font-family: "Arial"; font-size: 14px; } h1 { font-size: 60px; margin-top: 4cm; text-align: center } ================================================ FILE: test/samples/pug/utf8-characters/master.pug ================================================ h1 ★ ▹ ☀ h1 Ü ß //- h1 漢 字 style. body { font-family: "Arial"; font-size: 14px; } h1 { font-size: 150px; margin-top: 3cm; text-align: center } ================================================ FILE: test/samples/special_renderings/chartjs/donut.chart.js ================================================ { type: 'doughnut', data: { datasets: [{ data: [10, 20, 30], backgroundColor:["#db7575", "#dbc575", "#75b0db"] }], labels: ['Ms Word', 'Google Docs', 'ReLaXed'] }, options: { width:350, height:350, devicePixelRatio: 0.8, legend: { position: 'bottom' } } }; ================================================ FILE: test/samples/special_renderings/htable_csv/diff.txt ================================================ thead th,<< >>, ,<< p >>,H1 th,<< >>, ,<< p >>,H2 th,<< >>, ,<< p >>,H3 tbody tr td,<< >>, ,<< p >>,A1 td,<< >>, ,<< p >>,A2 td,<< >>, ,<< p >>,A3 tr td,<< >>, ,<< p >>,B1 td,<< >>, ,<< p >>,B2 td,<< >>, ,<< p >>,B3 tr td,<< >>, ,<< p >>,C1 td,<< >>, ,<< p >>,C2 td,<< >>, ,<< p >>,C3 ================================================ FILE: test/samples/special_renderings/htable_csv/expected.pug ================================================ thead th p H1 th p H2 th p H3 tbody tr td p A1 td p A2 td p A3 tr td p B1 td p B2 td p B3 tr td p C1 td p C2 td p C3 ================================================ FILE: test/samples/special_renderings/htable_csv/sample.md.htable.csv ================================================ H1,H2,H3 A1,A2,A3 B1,B2,B3 C1,C2,C3 ================================================ FILE: test/samples/special_renderings/table_csv/diff.txt ================================================ tbody tr td,<< >>, ,<< p >>,A1 td,<< >>, ,<< p >>,A2 td,<< >>, ,<< p >>,A3 tr td,<< >>, ,<< p >>,B1 td,<< >>, ,<< p >>,B2 td,<< >>, ,<< p >>,B3 tr td,<< >>, ,<< p >>,C1 td,<< >>, ,<< p >>,C2 td,<< >>, ,<< p >>,C3 ================================================ FILE: test/samples/special_renderings/table_csv/expected.pug ================================================ tbody tr td p A1 td p A2 td p A3 tr td p B1 td p B2 td p B3 tr td p C1 td p C2 td p C3 ================================================ FILE: test/samples/special_renderings/table_csv/sample.md.table.csv ================================================ A1,A2,A3 B1,B2,B3 C1,C2,C3 ================================================ FILE: test/test.js ================================================ const { spawn } = require('child_process') const path = require('path') const fs = require('fs') // const { pdfToPngThumbnail } = require('./pdf2png.js') const PDFImage = require('pdf-image').PDFImage const PixelDiff = require('pixel-diff') const JsDiff = require('diff') const assert = require('assert') fs.writeFileSync("/tmp/absolute_path_test.scss", "h1 {color: red; font-size: 140px; text-align:center;}") const relaxed = path.join(__dirname, './../src/index.js') describe('Sample tests', function () { var tests = [ { sampleName: 'basic_example', timeout: 10000 }, { sampleName: 'local_plugin', timeout: 10000 }, { sampleName: 'data_locals', timeout: 10000, cmdOptions: ['--locals', '{ "name": "Harry", "occupation": "Wazzzard" }'] }, { sampleName: 'data_locals_file', timeout: 10000, cmdOptions: ['--locals', './data.json'] }, { sampleName: 'data_require', timeout: 10000 }, { sampleName: 'mathjax', timeout: 10000 }, { sampleName: 'katex', timeout: 10000 }, { sampleName: 'header_and_footer', timeout: 10000 }, { sampleName: 'utf8-characters', timeout: 10000 }, { sampleName: 'absolute_path', timeout: 10000, cmdOptions: ['--basedir', '/'] } ] tests.forEach(function (test) { it('renders sample "' + test.sampleName + '" correctly', function (done) { this.timeout(test.timeout) var basedir = path.join(__dirname, 'samples', 'pug', test.sampleName) var paths = { master: path.join(basedir, 'master.pug'), expected: path.join(basedir, 'expected.png'), diff: path.join(basedir, 'diff.png'), pdf: path.join(basedir, 'master.pdf'), lastTestPNG: path.join(basedir, 'last_test_result.png'), html: path.join(basedir, 'master_temp.htm') } var process = spawn( relaxed, [paths.master, '--build-once', '--no-sandbox'].concat(test.cmdOptions || []) ) process.on('close', async function (code) { assert.equal(code, 0) var pdfImage = new PDFImage(paths.pdf, { combinedImage: true, graphicsMagick: true }) try { var imgPath = await pdfImage.convertFile() } catch (error) { done(error) } var diff = new PixelDiff({ imageAPath: paths.expected, imageBPath: imgPath, thresholdType: PixelDiff.THRESHOLD_PERCENT, threshold: 0.01, // 1% threshold imageOutputPath: paths.diff }) diff.run((error, result) => { fs.unlinkSync(paths.pdf) fs.unlinkSync(paths.html) fs.renameSync(imgPath, paths.lastTestPNG) if (error) { console.error(error) } else { assert(diff.hasPassed(result.code)) } done() }) }) }) }) }) describe('Error tests', function () { var tests = [ { sampleName: 'error', timeout: 10000 }, ] tests.forEach(function (test) { it('fails to render sample "' + test.sampleName + '"', function (done) { this.timeout(test.timeout) var basedir = path.join(__dirname, 'samples', 'pug', test.sampleName) var process = spawn( relaxed, [path.join(basedir, 'master.pug'), '--build-once', '--no-sandbox'].concat(test.cmdOptions || []) ) process.on('close', function (code) { assert.equal(code, 1) done() }) }) }) }) describe('Special rendering tests', function () { var tests = [ { sampleName: 'table_csv', master: 'sample.md.table.csv', output: 'sample.pug', expected: 'expected.pug', outputType: 'text', timeout: 10000 }, { sampleName: 'htable_csv', master: 'sample.md.htable.csv', output: 'sample.pug', expected: 'expected.pug', outputType: 'text', timeout: 10000 }, { sampleName: 'chartjs', master: 'donut.chart.js', output: 'donut.png', expected: 'expected.png', outputType: 'image', timeout: 10000 } ] tests.forEach(function (test) { it('renders sample "' + test.sampleName + '" correctly', function (done) { this.timeout(test.timeout) var basedir = path.join(__dirname, 'samples', 'special_renderings', test.sampleName) var extensions = { text: 'txt', image: 'png' } var diffExtension = extensions[test.outputType] var paths = { master: path.join(basedir, test.master), expected: path.join(basedir, test.expected), output: path.join(basedir, test.output), diff: path.join(basedir, 'diff.' + diffExtension), lastOutput: path.join(basedir, 'last_test_' + test.output) } var process = spawn(relaxed, [ paths.master, '--build-once' ]) process.on('close', async function (code) { assert.equal(code, 0) if (test.outputType === 'text') { var expected = fs.readFileSync(paths.expected, 'utf8') var output = fs.readFileSync(paths.output, 'utf8') var isDifferent = (output !== expected) if (isDifferent) { var diff = JsDiff.diffChars(expected, output) var parts = [] diff.forEach(function (part) { if (part.added) { parts.push(`[[${part.value}]]`) } else if (part.removed) { parts.push(`<<${part.value}>>`) } else { parts.push(part.value) } }) fs.writeFileSync(paths.diff, parts.join()) fs.renameSync(paths.output, paths.lastOutput) done(Error('Output differs from expectations')) } else { fs.renameSync(paths.output, paths.lastOutput) done() } } else if (test.outputType === 'image') { let diff = new PixelDiff({ imageAPath: paths.expected, imageBPath: paths.output, thresholdType: PixelDiff.THRESHOLD_PERCENT, threshold: 0.01, // 1% threshold imageOutputPath: paths.diff }) diff.run((error, result) => { fs.renameSync(paths.output, paths.lastOutput) if (error) { done(error) } else { assert(diff.hasPassed(result.code)) done() } }) } }) }) }) }) describe('Interactive tests', function () { var basedir = path.join(__dirname, 'samples', 'interactive_sample') var paths = [ { diagramData: 'diagram.mermaid', output: ['diagram.svg'], timeout: 10000 }, { diagramData: 'plot.vegalite.json', output: ['plot.svg'], timeout: 10000 } ] // var process = spawn('relaxed', [ path.join(basedir, 'master.pug') ]) it('renders mermaid diagram interactively correctly (STUB)' , function (done) { // TODO: Implement tests done() }) })