OpenType JS and FabricJS text convert to path

1.2k views Asked by At

I'm using FabricJS to allow a user to design an SVG in the browser. When I'm looking to save I'm trying to use OpenType JS to convert the textbox (Fabric) into an SVG Path using OpenType.

Problem I'm seeing is the location of my textbox is not translating through to the new path addition to the canvas.

AND

When I add the new path to the canvas, then call toSVG() it disappears in the resulting SVG I save.

Code:

async function convertTextToPaths() {
        ungroup();
        var _all = canvas.getObjects();
        for(i=0;i<_all.length;i++) {
            var activeObject = _all[i];
            if(activeObject.type=="textbox") {
                const font = await opentype.load('fonts/'+activeObject.fontFamily+'.ttf');
                debugger;
                console.log(activeObject.type, activeObject.left, activeObject.top+activeObject.height, activeObject.fontSize);
                const path = font.getPath(activeObject.text, activeObject.left, activeObject.top+activeObject.height, activeObject.fontSize);
                const outlinetextpath = new fabric.Path(path.toPathData(3));
                activeObject.dirty=true;
                canvas.remove(activeObject);
                canvas.insertAt(outlinetextpath,2);
                canvas.renderAll();
            }
        }
    }

Make any sense or can someone share some thoughts?

thank you

1

There are 1 answers

1
herrstrietzel On BEST ANSWER

Fabric.js generated boundingBoxes differ from those generated by opentype.js

You need to calculate some scaling factors/ratios according to your font's metrics for appropriate vertical alignments.

Calling font.getPath(string, x, y, fontSize) will "draw" a path from bottom to top:

Example: draw text element at x=500, y=250
(canvas size: 1000×500px; font-family: Fira Sans; font-size: 100px;)

fabric.js

let activeObject = new fabric.Textbox('Hamburg', {
    left: 500,
    top: 250,
    fontFamily: 'Fira Sans',
    fontSize: 100
});

opentype.js

font.getPath('Hamburg', 500, 250, 100)

fabric vs. opentype.js

Red: opentype.js path; Black: fabric generated textBox
Left: top: 250
Right: object.top+object.height

The opentype.js generated element is vertically aligned to 250 px using the font's baseline as a reference point.
Whereas fabric.js aligns the textBox element according to it's top (boundary box) y coordinate.

Working example

(Download function won't work on SO due to content security policies)

const canvas = new fabric.Canvas("canvas");
const fontFileUrl = 'https://fonts.gstatic.com/s/firasans/v16/va9E4kDNxMZdWfMOD5Vvl4jO.ttf';
const [textBoxX, textBoxY] = [500, 250];
const fontName = "Fira Sans";
const fontWeight = 400;
const fontSizeCanvas = 100;
const textboxString = "Hamburg";
const btnDownload = document.querySelector('.btn-download')


convertTextToPaths();
async function convertTextToPaths() {
  //parse font file with opentype.js
  const font = await opentype.load(fontFileUrl);

  //draw textbox on canvas
  let activeObject = new fabric.Textbox(textboxString, {
    left: textBoxX,
    top: textBoxY,
    fontFamily: fontName,
    fontWeight: fontWeight,
    fontSize: fontSizeCanvas
  });
  canvas.add(activeObject);

  // get properties of fabric.js object
  let [type, string, fontSize] = [activeObject.type, activeObject.text, activeObject.fontSize];
  let [left, top, height, width] = [activeObject.left, activeObject.top, activeObject.height, activeObject
    .width
  ];

  /**
   * Get metrics and ratios from font
   * to calculate absolute offset values according to font size 
   */
  let unitsPerEm = font.unitsPerEm;
  let ratio = fontSize / unitsPerEm;
  // font.descender is a negative value - hence Math.abs()
  let [ascender, descender] = [font.ascender, Math.abs(font.descender)];
  let ascenderAbs = Math.ceil(ascender * ratio);
  let descenderAbs = Math.ceil(descender * ratio);
  let lineHeight = (ascender + descender) * ratio;

  /**
   * calculate difference between font path bounding box and 
   * canvas bbox (including line height)
   */
  let font2CanvasRatio = 1 / lineHeight * height
  let baselineY = top + ascenderAbs * font2CanvasRatio;

  // Create path object from font
  path = font.getPath(string, left, baselineY, fontSize);
  //path = font.getPath(string, left, top, fontSize);
  let pathData = path.toPathData();
  // render on canvas
  const outlinetextpath = new fabric.Path(pathData, {
    fill: 'red'
  });
  canvas.add(outlinetextpath);


  //optional: just for illustration: render center and baseline
  canvas.add(new fabric.Line([0, 250, 1000, 250], {
    stroke: 'red'
  }));
  canvas.add(new fabric.Line([0, baselineY, 1000, baselineY], {
    stroke: 'purple'
  }));


  // Download/export svg
  upDateSVGExport(canvas);
  canvas.on('object:modified', function(e) {
    //console.log('changed')
    upDateSVGExport(canvas);
  });

}

function upDateSVGExport(canvas) {
  let svgOut = canvas.toSVG();
  let svgbase64 = 'data:image/svg+xml;base64,' + btoa(svgOut);
  btnDownload.href = svgbase64;
}
@font-face {
  font-family: "Fira Sans";
  font-style: normal;
  font-weight: 400;
  src: url(https://fonts.gstatic.com/s/firasans/v16/va9E4kDNxMZdWfMOD5Vvl4jO.ttf) format("truetype");
}

* {
  box-sizing: border-box;
}

body {
  font-family: "Fira Sans";
  font-weight: 400;
  background: #000;
  margin: 0;
  padding: 1em;
}

.fabricContainer {
  display: block;
  width: 100%;
  height: auto;
  max-width: 100%!important;
  position: relative;
  background: #fff;
  aspect-ratio: 2/1;
}

.canvas-container {
  width: 100%!important;
  height: auto!important;
}

.canvas-container>canvas {
  height: auto !important;
  max-width: 100%;
}

canvas {
  display: block;
  width: 100% !important;
  font-family: inherit;
}

h3 {
  font-weight: 400
}

.btn-download {
  text-decoration: none;
  background: #777;
  color: #fff;
  padding: 0.3em;
  position: absolute;
  width: auto !important;
  bottom: 0.5em;
  right: 0.5em;
  display: block;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/fabric.js/4.5.0/fabric.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/opentype.js@latest/dist/opentype.min.js"></script>
<div class="fabricContainer" id="fabricContainer">
  <canvas id="canvas" width="1000" height="500"></canvas>
  <a class="btn-download" href="#" download="fabric2Svg.svg">Download svg</a>
</div>

Codepen example

How it works:

Essentially, we need to compare heights as rendered by fabrics.js testBox objects with ideally rendered boundaries based on font metrics.
First we need to get some ratios to translate font units to pixels.
Most importantly we need to calculate a ratio/factor to translate relative font metric values to absolute font size related pixel values:

1. Font metrics: font size to font unit ratio
let unitsPerEm = font.unitsPerEm; let ratio = fontSize / unitsPerEm;

Most webfonts have a 1000 unitsPerEm value.
However, traditional truetype fonts (so not particularly optimised for web usage) usually use 2048 units per em.

2. Font metrics: ascender and descender

// font.descender is a negative value - hence Math.abs()
let [ascender, descender] = [font.ascender, Math.abs(font.descender)];
let ascenderAbs = Math.ceil(ascender * ratio);
let descenderAbs = Math.ceil(descender * ratio);
let lineHeight = (ascender + descender) * ratio;

With regards to the font's metrics an ideal bBox would have the height of

(ascender + descender) * FontSize2unitsPerEmRatio.

3. Font metrics to canvas coordinates

Fabric.js bBox is slightly bigger – so we need to compare their heights to get the perfect scaling factor.

let font2CanvasRatio = 1 / lineHeight * height   

Now we get the right y offset when using getPath()

let baselineY = top + ascenderAbs * font2CanvasRatio;
path = font.getPath(string, left, baselineY, fontSize);