Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

How to isolate each SVG from a TTF font so the symbols are all scaled properly? #592

Open
lancejpollard opened this issue Apr 12, 2023 · 4 comments

Comments

@lancejpollard
Copy link

After browsing through several issues on the opentype.js repo and fontkit, I landed on this code which isolates the SVGs for each glyph it seems like (mixed in here is some Next.js API code):

/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import formidable, { File } from 'formidable'
import fs, { mkdtemp } from 'node:fs/promises'
import fsBasic from 'fs'
import { NextApiRequest, NextApiResponse } from 'next'
import applyRateLimit from '~/utils/rateLimit'
import archiver from 'archiver'
import opentype from 'opentype.js'
import cuid from '@paralleldrive/cuid2'

export const config = {
  api: {
    bodyParser: false,
  },
}

const post = async (req: NextApiRequest, res: NextApiResponse) => {
  const form = new formidable.IncomingForm()
  return new Promise(finish => {
    form.parse(req, async function (err, fields, files) {
      if (err) {
        res
          .status(404)
          .send(
            "An error occurred while uploading the font, you can try again if you'd like.",
          )
        finish(null)
        return
      }
      try {
        const tmpPath = await saveFile(files.file as File)
        const inStream = fsBasic.createReadStream(tmpPath)
        const stat = await fs.stat(tmpPath)

        res.writeHead(200, {
          'Content-Length': stat.size,
          'Content-Type': 'application/zip',
        })

        inStream.pipe(res).on('close', async () => {
          await fs.unlink(tmpPath)
        })
      } catch (e) {
        res
          .status(404)
          .send(
            'An error occurred while extracting the SVGs from the font.',
          )
      }
      finish(null)
    })
  })
}

const saveFile = async (file: File): Promise<string> => {
  return new Promise(async (resolve, reject) => {
    const tmpDir = await mkdtemp(`/tmp/${cuid.createId()}`)
    const tmpPath = `${tmpDir}/${cuid.createId()}.zip`

    const output = fsBasic.createWriteStream(tmpPath)

    const archive = archiver('zip', {
      zlib: { level: 7 }, // Sets the compression level.
    })

    archive.on('error', async err => {
      await fs.unlink(tmpPath)
      reject(err)
    })

    output.on('close', () => {
      resolve(tmpPath)
    })

    // pipe archive data to the output file
    archive.pipe(output)

    const content = await fs.readFile(file.filepath)
    const font = opentype.parse(content.buffer)

    let i = 0
    while (i < font.glyphs.length) {
      const glyph = font.glyphs.get(i++)
      if (glyph.unicode == null) {
        continue
      }

      const unicode = glyph.unicode
        .toString(16)
        .padStart(4, '0')
        .toUpperCase()

      const { x1, x2, y1, y2 } = glyph.getBoundingBox()
      const w = x2 - x1
      const h = y2 - y1
      const svgDocument = `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="${x1} ${y1} ${w} ${h}">
  ${glyph.toSVG()}
</svg>`

      archive.append(svgDocument, { name: `${unicode}.svg` })
    }

    // wait for streams to complete
    archive.finalize()

    await fs.unlink(file.filepath)
  })
}

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse,
) {
  try {
    await applyRateLimit(req, res)
  } catch {
    res.status(429).json({ error: 'Rate limit exceeded' })
    return
  }

  req.method === 'POST'
    ? await post(req, res)
    : res.status(404).send('')
}

It appears when looking at the SVG renderings, that the symbols have a tight bounding box. This seems to mean if you were to place them side by side with each the same width and height container, they would be all jumbled. How do you get the SVGs to output so they are all proportionally laid out correctly? So if you were to put them in a square for example, they would all be the appropriate relative size, not scaled strangely. I'm not sure my code will do that, but am looking to get the SVGs as they would appear in the font, so if you lay them out, you could put several SVG "letters" next to each other and it would read like normal text renderings.

Any help would be greatly appreciated, thanks!

Also, since it appears opentype.js hasn't been published in a few years, I forked it and added a version number to the package.json, and added the dist folder to the repo so I could git clone the commit with yarn. That seems to give me the latest version. Hoping we can get a more official release though so that hack isn't necessary :)

@lancejpollard
Copy link
Author

lancejpollard commented Apr 12, 2023

This is what I am seeing when doing <img src="an.svg" width="32" /> for the first 400 or so visual glyphs from NotoSansMono-Regular. Notice how some are way out of proportion. But overall they are not like you would write text exactly. For example, they appear to be visibly aligned at the "bottom" of the glyph, whatever is the furthest point, but not oriented according to typographic principles with tails dangling below and stuff above the center of mass, etc.. How do I get the SVG to render that way instead?

Screenshot 2023-04-11 at 11 42 12 PM

Screenshot 2023-04-11 at 11 42 24 PM

@lancejpollard
Copy link
Author

When I use the fontforge utility, it appears it is giving better results, with the bounding boxes so the letters line up properly. Not 100% sure though, here is from fontforge.

fontforge -lang=ff -c 'Open($1); SelectWorthOutputting(); foreach Export("svg"); endloop;' NotoSansMono-Regular.ttf

Screenshot 2023-04-11 at 11 54 52 PM

@Connum
Copy link
Contributor

Connum commented Apr 12, 2023

getBoundingBox() will only give you the bounding box of the path itself. Right now, you will have to add ascender and descender yourself for the height, and calculate glyph spacing manually.
You can take a look at our test-render script which outputs text as SVG for comparison with other libraries:
https://github.com/opentypejs/opentype.js/blob/master/bin/test-render

Maybe we can add an additonal parameter or method to output an SVG for glyphs or text including all the spacing.

@herrstrietzel
Copy link

herrstrietzel commented Apr 12, 2023

Layout-wise (so in an HTML/CSS context) setting a fixed width for an <img> glyph rendering can't work very well.
For Instance, the pretty thin "pipe" glyph is inevitably rendered with a way too large height in comparison to the other glyphs.
Instead, try to set a height and an auto width respecting the aspect ratio.
So it's more about calculating a suitable viewBox (either including left/right sidebearings, ascenders, descenders or just using a tight bounding box).

(Clunky) proof of concept

See this codepen example
(Pardon me, it's not a JS masterpiece =)
Have a look at line 75 defining helper function text2Path()

function text2Path(font, params) {
  let options = params.options;
  let unitsPerEm = font.unitsPerEm;
  let ratio = params.fontSize / unitsPerEm;
  let ascender = font.ascender;
  let descender = Math.abs(font.descender);
  let ratAsc = ascender / unitsPerEm;
  let ratDesc = descender / unitsPerEm;
  let yOffset = params.fontSize * ratAsc;
  let lineHeight = (ascender + descender) * ratio;
  let baseline = +((100 / (ascender + descender)) * descender).toFixed(3) + 2;
  let singleGlyphs = params.singleGlyphs ? params.singleGlyphs : false;

  // get glyph data
  let teststring = params.string.split("");
  let chFirst = params.string.substring(0, 1);
  let chLast = params.string.substring(
    params.string.length - 1,
    params.string.length
  );
  let glyphs = font.stringToGlyphs(params.string);
  let firstGlyph = glyphs[0];
  let lastGlyph = glyphs[glyphs.length - 1];
  let leftSB = firstGlyph.leftSideBearing * ratio;
  let rightSB = (lastGlyph.advanceWidth - lastGlyph.xMax) * ratio;
  let textPath = "";
  let path = "";
  let paths = "";
  let stringWidth = 0;

  //individual paths for each glyph
  if (singleGlyphs) {
    paths = font.getPaths(
      params.string,
      -leftSB,
      yOffset,
      params.fontSize,
      options
    );
    paths.forEach(function (path, i) {
      let pathEl = path.toSVG(params.decimals);
      textPath += pathEl.replaceAll(
        "d=",
        'class="glyph glyph-' + teststring[i] + '" d='
      );
    });
  }
  //word (all glyphs) merged to one path
  else {
    path = font.getPath(
      params.string,
      -leftSB,
      yOffset,
      params.fontSize,
      options
    );
    textPath += path
      .toSVG(params.decimals)
      .replaceAll("d=", 'class="glyph" d=');
  }

The first lines are essentially just retrieving metrics values: either globally from the font (such as ascender, descender UnitsperEm etc.) as well as specific to the glyph (e.g. left side bearing, advance width ... or not necessarily needed a "right side bearing" calculated from boundingBox and advanceWidth).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

3 participants