blog:2020:0709_nervdraw_initial_implementation

Action disabled: register

NervDraw: Building a RaphaëlJS rendering web app

I desperately need a way to render SVG content quickly to PNG or another raster format to generate a logo. But I couldn't find any free tool online to achieve that. This makes me terribly angry at the world… lol… and at the same time this is giving me enough energy to try to build that missing app myself! ;-). Let's get to work!

  • I will call that app nvdraw
  • Creating the app:
    nv_cmd
    ng new nvdraw
I'm using angular routing and scss scripts in this project
  • Let's serve the app already:
    cd nvdraw
    ng serve --no-open --port 8183
  • We add the main page:
    ng g c page/main
  • We add the proper routing:
    const routes: Routes = [
      { path: '', component: MainComponent },
    ];
  • We add a small canvas on the main page:
    <div id="canvas"></div>
  • And we use some minimal styling to see something:
    #canvas {
        background-color: red;
        width: 300px;
        height: 200px;
    }

OK Working so far.

  • Now we add the raphael js dependency in the project:
    npm install raphael --save
  • Also installing the types:
    npm install @types/raphael --save
  • And now adding the Raphael script in angujar.json:
                "scripts": [
                  "node_modules/raphael/raphael.js"
                ]
  • And now we can render our Raphel paper already :-) with this:
    import { Component, OnInit, ElementRef } from '@angular/core';
    import { RaphaelPaper } from 'raphael';
    
    declare var Raphael: any;
    
    @Component({
      selector: 'app-main',
      templateUrl: './main.component.html',
      styleUrls: ['./main.component.scss']
    })
    export class MainComponent implements OnInit {
    
      protected paper: RaphaelPaper = null;
    
      constructor() {
    
      }
    
      ngOnInit(): void {
        // We create the raphaeljs paper on the "canvas" element:
        this.paper = Raphael("canvas", 640, 480);
        this.paper.clear();
        this.paper.rect(0, 0, 640, 480, 10).attr({fill: "#4f0", stroke: "none"});
      }
    
    }

⇒ This will display a big greenish rectangle just as expected.

While searching for an existing component to use as text area I found this amazing package which looks really promising: https://github.com/ngstack/code-editor

⇒ let's try to install that to see what it looks like for real:

npm install @ngstack/code-editor --save

Then I updated my main component to display that editor with the html code:

<div id="canvas"></div>
<ngs-code-editor
  [theme]="theme"
  [codeModel]="codeModel"
  [options]="options"
  (valueChanged)="onCodeChanged($event)"
>
</ngs-code-editor>

And the typescript component code

export class MainComponent implements OnInit {

  protected paper: RaphaelPaper = null;

  theme = 'vs-dark';

  codeModel: CodeModel = {
    language: 'json',
    uri: 'main.json',
    value: '{}',
  };

  options = {
    contextmenu: true,
    minimap: {
      enabled: true,
    },
  };

  onCodeChanged(value: any) {
    console.log('CODE', value);
  }

  // More stuff here.
}

And this seems to be working just fine! I have an editor window displayed under my canvas.

⇒ Let's just fix the display now: because I would like the editor window next to the canvas area, and both filling the page together.

  • Let's install jquery already:
    npm install jquery --save
    npm install @types/jquery --save
  • Next we load the jquery script in angular.json:
                "scripts": [
                  "node_modules/jquery/dist/jquery.js",
                  "node_modules/raphael/raphael.js"
                ]

OK the display is now correct, and I'm dynamically resizing the canvas with the window on resize event as follow:

  public updatePaper()
  {
    let el = $("#canvas")
    let ww = el.width();
    let hh = el.height();
    console.log("canvas size: "+ww+"x"+hh)
    this.drawPaper(this.paper, ww, hh)
  }

  public drawPaper(paper:RaphaelPaper, ww: number, hh: number)
  {
    paper.setSize(ww, hh);
    paper.clear();
    paper.rect(0, 0, ww, hh, 10).attr({fill: "#4f0", stroke: "none"});
  }

  ngOnInit(): void {
    // We create the raphaeljs paper on the "canvas" element:
    // this.paper = Raphael("canvas", 640, 480);
    this.paper = Raphael("canvas");
    
    $(window).on('resize', () => {
      this.updatePaper();
    })

    this.updatePaper()
  }

Now it's time to do some code injection from the user provided inputs… The basic idea is to use a script element in the document where we will inject our code dynamically. I would really like to support typescript there but I'm not completely sure I can do that easily enough, let's check this…

Or, in fact… maybe it's possible ? (see this page)

  • First I should then move my typescript dev dependency as production dependency:
    npm install typescript --save-prod
  • Next I try a simple compilation:
        const source = "let x: string  = 'string'";
    
        let result = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS }});
    
        console.log("Compiled javascript: '"+JSON.stringify(result)+"'");
  • And this will indeed produce a valid output:
    Compiled javascript: '{"outputText":"var x = 'string';\r\n","diagnostics":[]}'

So it seems we are on a good path!

Yet, there is one annoying warning now when building the app package:

WARNING in ./node_modules/typescript/lib/typescript.js 5554:41-60
Critical dependency: the request of a dependency is an expression

According to this page, it seems we could be able to fix this issue by changing the version of the request module. Lets' try that:

npm remove request
npm install request@2.79.0 --save
After the installation of the downgraded package we now have a vulnerability apparently: “found 300 vulnerabilities (294 low, 5 moderate, 1 high)”… not sure how critical this could be. And anyway, the build warning is still there! So let's just revert all this mess.

Hmmm… and actually using the typescript language in this “monaco editor” is really not as easy as one could expect… But I found another package (ie. rather another integration layer of the monaco editor into angular): https://github.com/atularen/ngx-monaco-editor, so let's give that one a try:

npm remove @ngstack/code-editor
npm install ngx-monaco-editor --save

…And that is not really better! So let's revert to the previous editor: at least the display was correct with that one.

And now I'm starting to get a bit less error since I fixed some stupid syntax mistakes. I use the following code as template:

function nvDraw(paper: RaphaelPaper, width: number, height: number) : void
{
  // Add draw code here.
  console.log("Drawing paper with size "+width+"x"+height);
}

And with this I only get errors on “RaphaelPaper” which is not known, and “console” too. How can I fix these… (?)

⇒ Maybe if I install the monaco editor for offline support ?

npm install monaco-editor --save
  • Adding the asset rule in angular.json:
    {
      "glob": "**/*",
      "input": "../node_modules/monaco-editor/min",
      "output": "./assets/monaco"
    },
    {
      "glob": "**/*.js",
      "input": "../node_modules/@ngstack/code-editor/workers",
      "output": "./assets/workers"
    }
  • And updating the Editor module config:
        CodeEditorModule.forRoot({
          baseUrl: 'assets/monaco',
          typingsWorkerUrl: 'assets/workers/typings-worker.js'
        })

⇒ but this is failing lamentably… (cannot find the asset files when serving the app from memory without build, not good at all.)

And now I'm really desperating to get this working. So let's stop here and just use plain javascript instead. [Come on Manu…you know you won't accept this lol…]

… or… in fact, what I really need is to be able to load some typings definition from index.d.ts files as a strings to inject into monaco with the addExtraLib() function.

⇒ So maybe I could take those raw index.d.ts files and generate the string I need myself manually (with a lua script for instance) ?

Side note: before I move to the lua script I think I should mention here that initially I was thinking I should dynamically inject a script element in the document, so I prepared the following function for this:

private getOrCreateScriptElement(ename: string) {
		console.log("Should retrieve script element: "+ename)
		var el = $('#'+ename);
		if(el.length == 0) {
			console.log("Creating script element "+ename)
			el = $("<script id='"+ename+"' type='text/javascript'></script>").appendTo('body');
		}
		return el;
  }

Then I intended to load some script content by changing the inner html value:

this.getOrCreateScriptElement("drawing_code").html(value);

But it turns out that this option will not work actually: because when injecting code this way, the script is not executed (just “added” as an element to the DOM), what we really need instead is to call eval() on the script string, and thus, we do not need to add any element to the DOM.

Okay so I wrote a nice lua script to clean the content of the Raphael index.d.ts file and write the result as an exported string:

local Class = createClass{name="TSDclGen", bases="app.AppBase"}

local path = import 'pl.path'

function Class:__init(args)
    Class.super.__init(self, args)
end

function Class:setupArgParser()
    self.argParser:option("-i --input", "Input file to start with")
    self.argParser:option("-o --output", "Output file to write", "decl_gen.ts")
    self.argParser:option("-n --varname", "Variable name", "declstr")
end

function Class:processFileContent(content, declname)
    -- Convert spaces:
    content = content:gsub("\r\n", "\n")

    -- Remove comments:
    content = content:gsub("//.-\n", "")
    content = content:gsub("/%*.-%*/", "")

    -- Reduce number of lines:
    content = content:gsub("\n+", "\n")
    
    -- replace starting/ending newlines:
    content = content:gsub("^\n", "")
    content = content:gsub("\n$", "")

    -- Write content as single string:
    content = "const "..declname.." = `" .. content .."`;\nexport default "..declname..";"
    return content
end

function Class:run()
    local args = self:getArgs()
    local ifile = args.input
    local ofile = args.output
    local dname = args.varname

    ifile = path.abspath(path.normpath(ifile))
    logDEBUG("Should process input file: ", ifile)

    local content = nv.readFile(ifile)

    content = self:processFileContent(content, dname)

    local file = io.open(ofile, "w")
    file:write(content)
    file:close()

    logDEBUG("Done running app.")
end

return Class
</sxhjs>

This works just fine and will produce this kind of result: <sxh typescript; highlight: []>
const raphael_dcl = `export type VMLElement = Element;
export type VMLCircleElement = VMLElement;
export type VMLEllipseElement = VMLElement;
export type VMLImageElement = VMLElement;
export type VMLPathElement = VMLElement;
export type VMLRectElement = VMLElement;
export type VMLTextElement = VMLElement;
export type RaphaelShapeType = "circle" | "ellipse" | "image" | "rect" | "text" | "path" | "set";
export type RaphaelTechnology = "" | "SVG" | "VML";
export type RaphaelFontOrigin = "baseline" | "middle";
export type RaphaelDashArrayType = "" | "-" | "." | "-." | "-.." | ". " | "- " | "--" | "- ." | "--." | "--..";
export type RaphaelLineCapType = "butt" | "square" | "round";

// (...More stuff here...)

declare const R: RaphaelStatic;
export default R;
declare global {
    
    const Raphael: RaphaelStatic;
}`;
export default raphael_dcl;

… And all this with a very simple command line:

nv_seed tsgen -i node_modules/\@types/raphael/index.d.ts -o src/app/typings/raphael.ts -n raphael_dcl

Then I load that string and later I try to inject it in the monaco editor:

import rdcl from "../../../app/typings/raphael";

    monaco.languages.typescript.typescriptDefaults.setCompilerOptions({
      // target: monaco.languages.typescript.ScriptTarget.ES2015,
      target: monaco.languages.typescript.ScriptTarget.ES2016,
      // target: monaco.languages.typescript.ScriptTarget.ES5,
      moduleResolution: monaco.languages.typescript.ModuleResolutionKind.NodeJs,
      module: monaco.languages.typescript.ModuleKind.CommonJS,
      noEmit: true,
      noLib : false,
      lib : ['es2018', "dom"],
      allowNonTsExtensions: true,
      typeRoots: [ "node_modules/@types" ]
    });

    monaco.languages.typescript.typescriptDefaults.addExtraLib(
      rdcl,
      "node_modules/@types/raphael.d.ts"
    );

But of course… this is once more just failing on the editor side with some cryptic error! Grrrr! What a joke…

[Man… I'm really starting to feel desperated here…]

I spent some good time trying to investigate that one without much progress: basically it seemed that whenever I tried to call monaco.languages.typescript.typescriptDefaults.addExtraLib() to inject some typescript definition I would then only get an error in the javascript execution:

Uncaught Error: Debug Failure. False expression: Paths must either both be absolute or both be relative

But then I eventually found this page: this looked really similar to the problem I was facing, so I tried to follow the indications there carefully. And it turned out that this was really it! The key seems to be to ensure that we use a model to setup the editor, so I use the following:

  onInit(editor) {
    // let line = editor.getPosition();
    // console.log(line);
    // console.log("Assigning monaco model.")
    let model = monaco.editor.createModel(this.code,"typescript", monaco.Uri.parse("a://main.ts"))
    editor.setModel(model)
  }

This “onInit” function is called on the init of the ngx-monaco-editor [⇒ and yes, I eventually switched again to that other package ;-)]
Note the URI in the model above: a://main.ts: “a://” doesn't really mean anything here as far as I understand but it is absolutely necessary!

And then we also need to ensure that we use the same prefix when defining our extra lib file path:

    monaco.languages.typescript.typescriptDefaults.addExtraLib(
      raphael_dcl,
      'a://node_modules/@types/raphael/index.d.ts'
    );

And with these change, we can finaly use typescript with correct code completion and syntax checks, etc… feeeeewwwww! [This was an hard one! :-)]

Before we start considering writing any image file, I should provide a menu bar with action buttons. So I'm going to install bootstrap for that:

npm install bootstrap --save

Then we load the corresponding styles/scripts in angular.json:

            "styles": [
              "node_modules/bootstrap/dist/css/bootstrap.css",
              "src/styles.scss"
            ],
            "scripts": [
              "node_modules/jquery/dist/jquery.js",
              "node_modules/raphael/raphael.js",
              "node_modules/bootstrap/dist/js/bootstrap.js"
            ]

And now we can add a menu bar: OK

Another key element to be able to export raphael content is that we need the Raphael.Export plugin available from: https://github.com/AliasIO/Raphael.Export ⇒ installing and loading this. OK

I also need to install canvg:

npm install canvg --save

And also the Canvas2Image package:

npm install canvas2image --save

⇒ Actually I don't think I'm going to use Canvas2Image at all: it works but the download name doesn't contain any extension and cannot be changed programmatically: instead we now have the FileSaver package which seems better:

npm install file-saver --save
npm install @types/file-saver --save-dev

And with these packages, writing our raphael drawing to png is as easy as this:

  public async exportPNG()
  {
    // console.log("Should export PNG here.")
    let svg = (this.paper as any).toSVG();

    const canvas = new OffscreenCanvas(this.width, this.height);
    const ctx = canvas.getContext('2d');
    const v = await Canvg.from(ctx, svg, presets.offscreen());

    // Render only first frame, ignoring animations and mouse.
    await v.render();

    const blob = await canvas.convertToBlob();
    saveAs(blob, "image.png");
  }

That's amazing!

Currently we are rendering the canvas each time the editor content is changed. Or at least we are trying to: the typescript code will be converted to javascript and the generated javascript code is evaluated. If there is no exception in the process and the “nvDraw” function is found, then it is called.

⇒ But of course, while you are typing code, for most new character that you add the code will not compile and produce an exception… it might even just freeze your web browser window lol [that happened to me multiple times already]

So I now think I should rather add a button to perform the compilation/rendering only on user request. Let's handle that. OK All good, now we can also disable that render button if the code didn't change since the last successful rendering with:

  public isCodeRendered() : boolean
  {
    return this.renderedCode == this.code
  }

and:

<button class="btn btn-outline-success my-2 my-sm-0" (click)="render()" [disabled]="isCodeRendered()">Render</button>

Okay, so, this project is not complete yet, but still I think I could now publish a first draft version ;-) So let's build the app now:

ng build --prod

Now I need to setup a new nginx server config:

server {
  listen 80;
  server_name   draw.nervtech.org;
  
  rewrite       ^ https://$server_name$request_uri? permanent;
}

server {
    listen 443;
    server_name draw.nervtech.org;

    # ... Some SSL related stuff here ...

    client_max_body_size 20m;

    access_log            /var/log/nginx/nvdraw.access.log;
    error_log            /var/log/nginx/nvdraw.error.log;

    add_header Strict-Transport-Security max-age=15768000;
    
  location / {
    root /mnt/web/nvdraw;
    try_files $uri $uri/ /index.html;
  }
}

And I then copied everything required in the /mnt/web/nvdraw folder. Now I just restart the nginx server…

… And I should also regenerate my SSL certificate to include this new “draw.nervtech.org” domain name, of course:

service nginx stop
certbot certonly --standalone -d nervtech.org -d api.nervtech.org -d gitlab.nervtech.org -d draw.nervtech.org
service nginx start

But of course, this doesn't work anymore: and now I remember I received an email from let's encrypt about this a few weeks ago: the protocol used in certbot is not supported anymore, crap! [Why is everything also so hard to do in the real world ? :-)]

⇒ So I need to find a replacement for that certbot software.

Note: I currently have certbot 0.10 installed:

root@nginxserver:~# certbot --version
certbot 0.10.2

⇒ Maybe I can try to upgrade that package ? I'm trying to use certbot-auto for this:

wget https://dl.eff.org/certbot-auto
sudo mv certbot-auto /usr/local/bin/certbot-auto
sudo chown root /usr/local/bin/certbot-auto
sudo chmod 0755 /usr/local/bin/certbot-auto
/usr/local/bin/certbot-auto --help
/usr/local/bin/certbot-auto --install-only -v

The commands above are not working because I have a problem with my apt-get source list. So following the indications on this page I updated my /etc/apt/sources.list with this content now:

#deb http://httpredir.debian.org/debian jessie main
#deb http://httpredir.debian.org/debian jessie-updates main

deb http://deb.debian.org/debian/ jessie main
deb http://security.debian.org jessie/updates main
deb http://nginx.org/packages/mainline/debian/ jessie nginx

#deb http://ftp.debian.org/debian jessie-backports main

And with this, the command apt-get update will not produce an error: just a missing public key warning. Let's try the certbot-auto again now.

Cool, this time certbot installation went fine! Let's check the version we have now:

root@nginxserver:~# certbot --version
certbot 0.10.2

Hmmm… not quite what I expected :-) but it seems this is now a python app somehow, so let's check how I'm supposed to use it. Ohh, okay: actually you are supposed to use “certbot-auto” directly now:

root@nginxserver:~# certbot-auto --version
certbot 1.6.0

And it is also finding my current certificate:

root@nginxserver:~# certbot-auto certificates
Saving debug log to /var/log/letsencrypt/letsencrypt.log

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Found the following certs:
  Certificate Name: nervtech.org
    Serial Number: 356c7622f630991677e3c7a501c82d6b584
    Domains: nervtech.org api.nervtech.org gitlab.nervtech.org
    Expiry Date: 2020-08-30 08:02:31+00:00 (VALID: 51 days)
    Certificate Path: /etc/letsencrypt/live/nervtech.org/fullchain.pem
    Private Key Path: /etc/letsencrypt/live/nervtech.org/privkey.pem
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

Good, let's try regenerating the certificate again now:

service nginx stop
certbot-auto certonly --standalone -d nervtech.org -d api.nervtech.org -d gitlab.nervtech.org -d draw.nervtech.org
service nginx start

Oouufff! it works!… It failed the first time, but then it worked: that was scary :-).

⇒ I should also update my update_certs.sh script to use certbot-auto now: Done

And now we finally have it ready! The first version of this free online drawing tool is available at: https://draw.nervtech.org LOL

As I said already, there is still a lot that could be added here, but I think I can start with this for now and I will add more features when I need them.

  • blog/2020/0709_nervdraw_initial_implementation.txt
  • Last modified: 2020/07/10 12:11
  • by 127.0.0.1