Simulate kerning for a bitmapped font with Fabric JS
Asked Answered
T

2

6

I am trying to create an effect with Fabric JS where letters appear to be "embroidered" on a sweater like this:

I can achieve this effect in Photoshop by using this action.

My idea for getting it into a <canvas> is to render out a png from Photoshop of every embroidered letter. Then, I will take each letter and place it on the canvas based on what the user types.

However this approach will not have correct kerning.

To fix this, I was trying to write out text in Fabric using the same font and then overlay each embroidered png on top of the letter it is replacing (and then hide the text itself).

Here's how I render the text:

window.chest_text = new fabric.IText("NYC", {
      fill: '#000',
      fontSize: 12,
      left: 210,
      top: 100,
      fontFamily: 'Graphik',
      fontWeight: 500,
      lineHeight: 1,
      originX: 'center',
    });

And then here's how I render the embroidered letters:

  var n_url = 'https://res.cloudinary.com/tricot/image/upload/v1598820746/tmp/n-embroidery-test.png'
  var y_url = 'https://res.cloudinary.com/tricot/image/upload/v1598820745/tmp/y-embroidery-test.png'
  var c_url = 'https://res.cloudinary.com/tricot/image/upload/v1598820745/tmp/c-embroidery-test.png'
  
  fabric.Image.fromURL(n_url, function(img) {
    img.set({
      left: Math.round(window.chest_text.aCoords.bl.x),
      top: window.chest_text.top
    })
    
    img.scaleToHeight(Math.floor(window.chest_text.__charBounds[0][0].height / 1.13), true)
    
    canvas.add(img);
  })
  
  fabric.Image.fromURL(y_url, function(img) {
    img.set({
      left: Math.round(window.chest_text.aCoords.bl.x + window.chest_text.__charBounds[0][1].left),
      top: window.chest_text.top
    })
    
    img.scaleToHeight(Math.floor(window.chest_text.__charBounds[0][1].height / 1.13), true)
    
    canvas.add(img);
  })
  
  fabric.Image.fromURL(c_url, function(img) {
    img.set({
      left: Math.round(window.chest_text.aCoords.bl.x + window.chest_text.__charBounds[0][2].left),
      top: window.chest_text.top
    })
    
    img.scaleToHeight(Math.floor(window.chest_text.__charBounds[0][2].height / 1.13), true)
    
    canvas.add(img);
  })
  
  window.chest_text.opacity = 0.5
  
  window.canvas.renderAll()

However I can't get the embroidered letters to EXACTLY overlay the regular text (even though it's the same font):

enter image description here

How can I achieve this? Is there a better way of getting kerning to work correctly?

Trapezoid answered 30/8, 2020 at 23:48 Comment(8)
This might help https://mcmap.net/q/1777425/-bitmaptext-using-fabricjsPinxit
@Pinxit that's interesting but in that case they aren't trying to simulate kerning but rather just render out a fixed-width font. But maybe it's a good approach still! Thank youTrapezoid
Yes, although the example uses a fixed width font it'd be simple to add correct kerning to the example. The subclassing approach also has the benefit of not requiring overlaying and hiding of text characters.Pinxit
@Pinxit but without the hiding of characters (which I agree is a mess!) how will know where to place my bitmapped characters so they are correctly kerned? Or I guess I could set it up so that, right when I'm about to write out the normal character, I stop, look where it's going to go, and write out the bitmapped one instead?Trapezoid
By subclassing the text class you can essentially change the draw action to draw each character from an image/sprite rather than from the font itself. You'd still use the font to measure each character. The example I linked to overrides the measure char function to return a fixed number but you can take a look at the original function in the fabric src to see how it works.Pinxit
Interesting good call! I will check it out more closely.Trapezoid
If ok and possible creating new Font then this would help calligraphr.com Download a png template, which is a grid with A-Z and 0-9. Paste in the letter images you have. Download the ttf file.Theaterintheround
Embroidery looks close to the font you need. Might work if you use a font instead of images.Reiff
I
5

Using line dash as the stitch and ctx.globalCompositeOperation = "source-atop" to draw only inside the text. Vary the stroke width to build stitches from inside of font out.

Unfortunately the line dash spacing is only true for the stroke center so the approach works for some characters but not all.

Can be improved upon as there was no effort to round stitchs (highlight and shadow color per stitch) but as there is no control over positioning of stitches at line joins i could not see the point of refining it further.

Will work with any font.

See snippet for demo and code.

function stitchIt(text, stitchLen, stitchOffset, threadThickness, size, font, col1, col2 , shadowColor, offset, blur) {
    const can = document.createElement("canvas");
    const ctx = can.getContext("2d");
    ctx.font = size + "px "+font;
    const width = ctx.measureText(text).width;
    can.width = width;
    can.height = size;
    ctx.font = size + "px "+font;
    ctx.textAlign = "center";
    ctx.textBaseline = "middle";
    ctx.globalCompositeOperation = "source-over";
    ctx.lineCap = "butt";
    ctx.lineJoin = "bevel";
    ctx.fillStyle = col2;
    ctx.setTransform(1,0,0,1,width / 2, size / 2);
    ctx.fillText(text, 0, 0);
    ctx.setLineDash([stitchLen, stitchLen]);
    var w = size, off = 0;
    ctx.globalCompositeOperation = "source-atop"
    while (w > 0) {
        ctx.lineWidth = w;
        ctx.strokeStyle = col1;
        ctx.lineDashOffset = off; 
        ctx.strokeText(text, 0, 0);
        if (w > threadThickness) {
            w -= threadThickness / 2;
            ctx.lineWidth = w;
            ctx.lineDashOffset = off + stitchLen; 
            ctx.strokeStyle = col2;
            ctx.strokeText(text, 0, 0);
            off += stitchLen * stitchOffset;
            w -= threadThickness / 2;
        } else {
            break;
        }
    }
    ctx.globalCompositeOperation = "destination-out";
    ctx.globalAlpha = 0.5;
    ctx.strokeStyle = col2;
    ctx.lineWidth = threadThickness / 2;
    ctx.lineDashOffset = off + stitchLen; 
    ctx.strokeText(text, 0, 0);
    ctx.globalCompositeOperation = "destination-over";
    ctx.save();
    ctx.shadowColor = "#000";
    ctx.shadowOffsetX = offset;
    ctx.shadowOffsetY = offset;
    ctx.shadowBlur = blur;
    ctx.fillText(text, 0, 0);
    ctx.restore();    
    ctx.globalCompositeOperation = "source-over";
    return can;
}
ctx = canvas.getContext("2d");
textEl.addEventListener("input", update);
sLenEl.addEventListener("change", update);
sOffEl.addEventListener("change", update);
sThreadEl.addEventListener("change", update);
sFontEl.addEventListener("change", update);
colEl.addEventListener("click", () => {
    color = colors[colIdx++ % colors.length];
    update();
});

// update debounces render
var tHdl;
function update() {
    clearTimeout(tHdl);
    tHdl = setTimeout(draw, 200);
}
const colors=[["#DDD","#888"],["#FFF","#666"],["#F88","#338"],["#8D8","#333"]];
var colIdx = 0;
var color = colors[colIdx++];
stitchIt("STITCH",5, 1.4, 4, 160,"Arial Black","#DDD","#888","#0004" , 4, 5);
function draw() {
    ctx.clearRect(0,0,1500,180);
    if (textEl.value) {
        const image = stitchIt(
            textEl.value,
            Number(sLenEl.value), 
            sOffEl.value / 100 + 0.8, 
            Number(sThreadEl.value), 
            Number(sFontEl.value),
            "Arial Black",
            color[0],
            color[1],
            "#0004" , 
            4, 
            5
        );
        ctx.drawImage(image,0,90-image.height / 2); 
   }
}
draw();
canvas {
    background: #49b;
    border: 2px solid #258;
    position: absolute;
    top: 0px;
    left: 230px;
}
<div>
    <input id="textEl" type="text" value="STITCH"></input><br>
    <button id="colEl">change Color</button><br>
    
    <input id="sLenEl" type="range" min="2" max="16" value="5"><label for="sLenEl">Stitch length</label><br>
    <input id="sOffEl" type="range" min="0" max="100" value="50"><label for="sOffEl">Stitch offset</label><br>
    <input id="sThreadEl" type="range" min="2" max="10" value="4"><label for="sThreadEl">Thread size</label><br>
    <input id="sFontEl" type="range" min="20" max="180" value="140"><label for="sFontEl">Font size</label><br>
</div>
<canvas id="canvas" width="1500" height="180"></canvas>
Indiana answered 10/9, 2020 at 8:32 Comment(0)
R
1

If you manage to find the font you could use a font.

There is a font that might be closer to what you need, NCD Embroidery but you have to contact the designer to get a license.

You might even find the font in one of the Photoshop libraries (I'm not that familiar with Photoshop, so this is just a guess)

Registered the font in .css file.

@font-face {
    font-family:'stitchfont';
    src:url('./fonts/fs-mom.ttf') format('truetype');
}
@font-face {
    font-family:'pencilfont';
    src:url('./fonts/fs-ariapenciroman.ttf') format('truetype');
}
@font-face {
    font-family:'fabricon';
    src:url('./fonts/stf-fabricon-cross-section.ttf') format('truetype');
}

.stitch{
    font-family: 'stitchfont';
}
.pencil {
    font-family: 'pencilfont';
}

.fabri {
    font-family: 'fabricon';
}

and the code:

<body>    
    <canvas id="stitchText" width="400" height="200"></canvas>
    <canvas id="pencilText" width="400" height="200"></canvas>
    <canvas id="fabricText" width="400" height="200"></canvas>

    <div class="stitch" style="visibility: hidden;">if the font is not used it doesn't seem to load consistently</div>
    <div class="pencil" style="visibility: hidden;">so assign the font to any element</div>
    <div class="fabri" style="visibility: hidden;">set the style visibility to hidden , dont use the html hidden tag</div>

    <script src="./lib/fabric.js/fabric.js"></script>
    <script>
        let stitchCanvas = new fabric.Canvas("stitchText")
        let stitchText = new fabric.Text("NYC", {
            fill: '#EEE',
            fontFamily: 'stitchfont',
            fontSize: 60,
            left: 190,
            top: 90,
            originX: 'center',
            originY: 'center'
        });
        stitchCanvas.add(stitchText);
        stitchCanvas.setBackgroundImage("images/embroid.png", stitchCanvas.renderAll.bind(stitchCanvas), { opacity: 0.8, scaleX: 0.28, scaleY: 0.28 });

        let pencilCanvas = new fabric.Canvas("pencilText")
        let pencilText = new fabric.Text("NYC", {
            fill: '#EEE',
            fontFamily: 'pencilfont',
            fontSize: 80,
            left: 190,
            top: 85,
            originX: 'center',
            originY: 'center'
        });
        pencilCanvas.add(pencilText);
        pencilCanvas.setBackgroundImage("images/embroid.png", pencilCanvas.renderAll.bind(pencilCanvas), { opacity: 0.8, scaleX: 0.28, scaleY: 0.28 });

        let fabricCanvas = new fabric.Canvas("fabricText")
        let fabricText = new fabric.Text("NYC", {
            fill: '#EEE',
            fontFamily: 'fabricon',
            fontSize: 40,
            left: 195,
            top: 85,
            originX: 'center',
            originY: 'center'
        });
        fabricCanvas.add(fabricText);
        fabricCanvas.setBackgroundImage("images/embroid.png", fabricCanvas.renderAll.bind(fabricCanvas), { opacity: 0.8, scaleX: 0.28, scaleY: 0.28 });

    </script>
</body>

The results from the fonts:

fs Mom license

fmMomFont

FS Ariapenciroman license

pencilFont

Fabric license

fabricFont

Reiff answered 10/9, 2020 at 10:8 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.