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

SVG Table Support #260

Open
jlarmstrongiv opened this issue Aug 4, 2021 · 26 comments
Open

SVG Table Support #260

jlarmstrongiv opened this issue Aug 4, 2021 · 26 comments

Comments

@jlarmstrongiv
Copy link

I am trying to render color fonts to an image (either svg or png). While I have been following the docs, I can’t seem to get it to work—rather than color, it’s black and white.

uppercase_G

Here are a few examples to help debug:
color-fonts.zip

@Pomax
Copy link
Contributor

Pomax commented Aug 4, 2021

You probably want to include the code you used, too

@jlarmstrongiv
Copy link
Author

jlarmstrongiv commented Aug 4, 2021

@Pomax the code is similar to the readme and tests.

That would involve loading the font and reading the glyph:

const fontBuffer = fs.readFileSync("./path/to/font.otf")
const font = fontkit.create(fontBuffer);

// failed attempts
let glyph;

glyph = font.glyphsForString("B")[0];

// glyph_id https://developer.apple.com/fonts/TrueType-Reference-Manual/RM06/Chap6post.html
glyph = font.getGlyph(37);

glyph = font.layout("B").glyphs[0];

Anyway, none of these contained glyph.getImageForSize(size) or glyph.layers. The goal is to be able to render the color font to a png or svg image.

@Pomax
Copy link
Contributor

Pomax commented Aug 4, 2021

As per the docs, Fontkit supports the SBIX and COLR tables, but all the fonts you're trying don't use those, they instead use the newer SVG table for color information, which Fontkit never got support for.

@jlarmstrongiv
Copy link
Author

Ahh, I see, thank you! So I suppose this should be a feature request.

I did look open the raw files, and the SVGs are definitely contained inside:

image

These svgs can be extracted:

image

It’s possible to see which characters are contained too:

image

So it’s definitely possible to create a very rough parser in the meantime. The best solution would be adding support with fontkit.

@jlarmstrongiv jlarmstrongiv changed the title Color font example SVG Table Support Aug 5, 2021
@Pomax
Copy link
Contributor

Pomax commented Aug 5, 2021

Yeah, the SVG table has a rather simple format: https://docs.microsoft.com/en-us/typography/opentype/spec/svg

Just the smallest header someone could come up with, then the number of SVG records, where each record specifies the range of codepoints it applies to (start-end are inclusive), then the byte offset to the SVG outlines, and the length, so you can read exactly and only the bytes you need for a (collection of) glyph(s).

I don't know if fontkit has a way to just get "a table's raw data", but that would be worth doing a quick hunt for in the codebase, to see if you can just work with that directly.

edit: in fact, looking at

fontkit/src/TTFFont.js

Lines 38 to 46 in 417af0c

for (let tag in this.directory.tables) {
let table = this.directory.tables[tag];
if (tables[tag] && table.length > 0) {
Object.defineProperty(this, tag, {
get: this._getTable.bind(this, table)
});
}
}
}
it appears you should be able to get the table by using the data in font.directory.tables["SVG"].

@Pomax
Copy link
Contributor

Pomax commented Aug 5, 2021

Hell let me just write this code for you, who knows, might be useful to other people in the future too. First, let's define the most basic data parser and an SVG document record class to make table parsing easier:

class DataParser {
  constructor(data, pos = 0) {
    this.data = data;
    this.seek(pos);
    this.start = this.offset;
  }
  reset() { this.seek(this.start); }
  seek(pos) { this.offset = pos; }
  read(bytes) {
    return Array.from(this.data.slice(this.offset, this.offset + bytes));
  }
  uint(n) {
    let sum = this.read(n)
                  .map((e, i) => e << (8 * (n - 1 - i)))
                  .reduce((t, e) => t + e, 0);
    this.offset += n;
    return sum;
  }
  uint16() { return this.uint(2); }
  uint32() { return this.uint(4); }
  readStructs(RecordType, n, ...args) {
    const records = [];
    while (n--) records.push(new RecordType(this, ...args));
    return records;
  }
}

class SVGDocumentRecord {
  constructor(parser, svgDocumentListOffset) {
    this.parser = parser;
    this.baseOffset = svgDocumentListOffset;
    this.startGlyphID = data.uint16(); // The first glyph ID for the range covered by this record.
    this.endGlyphID = data.uint16();   // The last glyph ID for the range covered by this record.
    this.svgDocOffset = data.uint32(); // Offset from the beginning of the SVGDocumentList to an SVG document.
    this.svgDocLength = data.uint32(); // Length of the SVG document data.
  }
  getSVG() {
    this.parser.seek(this.baseOffset + this.svgDocOffset);
    return this.parser.read(this.svgDocLength).map(b => String.fromCharCode(b)).join(``);
  }
}

And let's also write a convenience function to get the SVG associated with a glyph id:

function getSVGforGlyphId(id) {
  const record = SVGDocumentRecords.find(
    (record) => record.startGlyphID <= id && id <= record.endGlyphID
  );
  return record?.getSVG();
}

With that, we can write a table parser for the SVG table:

// Load the font:

const fontkit = require("fontkit");
const font = fontkit.openSync("./font.otf");

// get the SVG table data and wrap a byte parser around it:

const entry = font.directory.tables["SVG "];
const tableBuffer = font.stream.buffer.slice(entry.offset, entry.offset + entry.length);
const data = new DataParser(tableBuffer);

// Parse the SVG table's header:

const version = data.uint16();
const svgDocumentListOffset = data.uint32();
const reserved = data.uint16();

console.log(`SVG table version ${version}, data offset at ${svgDocumentListOffset}`);

// We can now move our read pointer to the correct offset, and read the SVG records:

data.seek(svgDocumentListOffset);
const numEntries = data.uint16();
const SVGDocumentRecords = data.readStructs(
  SVGDocumentRecord,
  numEntries,
  svgDocumentListOffset
);

console.log(`there are ${numEntries} svg records`);

// And that's it, we can now typeset things.
// For instance, what does the SVG sequence for the phrase "oh look, an SVG table parser!" look like?

const phrase = `oh look, an SVG table parser!`;
const glyphs = font.glyphsForString(phrase);
const svgDocuments = glyphs.map(({ id }) => getSVGforGlyphId(id));

console.log(`The phrase "${phrase}" consists of ${
  glyphs.filter((v,i) => glyphs.indexOf(v)===i).length
} glyphs, of which ${
  svgDocuments.filter(v => !v).length
} do not have SVG definitions`);

And done, we've successfully written an SVG table parser.

@Pomax
Copy link
Contributor

Pomax commented Aug 5, 2021

However, remember that font coordinates by convention have the y coordinates flipped compared to SVG coordinates, so the SVG string you get will have a viewbox 0 0 1000 1000 (for otf, typically) or 0 0 2048 2048 (for ttf, typically) but coordinates with negative y coordinates, so you'll never actually see anything:

image

To fix that, find the top level <g> and add transform="scale(1,-1)" to flip the coordinates to something you can actually see:

image

@jlarmstrongiv
Copy link
Author

Wow, thank you so much @Pomax ! Do you have a donation link or a favorite charitable organization?

For the orientation, I’ve found the glyph path in fontkit to be very helpful:

const run = font.layout(character);
const flipped = run.glyphs[0].path.scale(-1, 1).rotate(Math.PI);
const boundingBox = flipped.bbox;
const d = flipped.toSVG();
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="${boundingBox.minX} ${
          boundingBox.minY
        } ${boundingBox.maxX - boundingBox.minX} ${
          boundingBox.maxY - boundingBox.minY
        }">
      <path fill="#000000" d="${d}" />
    </svg>`

I like your suggestion of finding and using the top level <g> and changing the viewbox 👍

Reference: https://web.archive.org/web/20210123120325/http://batik.2283329.n4.nabble.com/Converting-Font-to-SVG-all-glyphs-are-rotated-td3596984.html

@Pomax
Copy link
Contributor

Pomax commented Aug 5, 2021

I have a paypal and patreon, usually they're because of https://pomax.github.io/bezierinfo but if you think this was worth a coffee: I do like coffee =P

@jlarmstrongiv
Copy link
Author

@Pomax definitely, enjoy a few coffees 😄 thanks again!

@jlarmstrongiv
Copy link
Author

jlarmstrongiv commented Aug 6, 2021

@Pomax tangential to this issue of color font support, would you happen to know more about support for COLR tables?

Example font:
image

BungeeColor-Regular_colr_Windows.ttf.zip

const fs = require('fs');
const fontkit = require('fontkit');

// rgb to hex https://stackoverflow.com/a/5624139
function rgbToHex({ blue, green, red }) {
  return (
    '#' + ((1 << 24) + (red << 16) + (green << 8) + blue).toString(16).slice(1)
  );
}
function rgbaToFillOpacity({ alpha }) {
  return alpha / 255;
}

// rgba to svg color https://stackoverflow.com/a/6042577
function rgbaToSvgColor(rgba) {
  return {
    fill: rgbToHex(rgba),
    fillOpacity: rgbaToFillOpacity(rgba),
  };
}

const fontBuffer = fs.readFileSync('./BungeeColor-Regular_colr_Windows.ttf');
const font = fontkit.create(fontBuffer);
const run = font.layout('a');
const paths = run.glyphs[0].layers.map((layer) => {
  try {
    const d = font
      .getGlyph(layer.glyph.id)
      .path.scale(-1, 1)
      .rotate(Math.PI)
      .toSVG();
    const { fill, fillOpacity } = rgbaToSvgColor(layer.color);
    return `<path fill="${fill}" fill-opacity="${fillOpacity}" d="${d}" />`;
  } catch (error) {
    console.log(error);
  }
}).filter(Boolean);

const svg = `
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000">
    <g fill="none">
      ${paths.join('\n')}
    </g>
  </svg>
`;

However, I receive this error:

TypeError: this._font.getGlyph(...)._getContours is not a function
    at TTFGlyph._getContours

The strange thing is that is works some of the time. So, if I use that try/catch block, it will still output some paths. The result:

image

On the other hand, SBIX support via getImageForSize works great 👍

@Pomax
Copy link
Contributor

Pomax commented Aug 6, 2021

Let me have a look, but as a note on that rgb to hex function: web colors are #rrggbbaa or #rrggbb, so that would be:

function rgb2hex({r, g, b, a}) {
  return `#${( (r<<16)+(g<<8)+b).toString(16).padStart(6,`0`)}`;
}

That 1<<24 is entirely unnecessary, given that padStart exists =)

However, much easier is to use the rgb() and rgba colors, if you already have rgb values:

function rgbaToSvgColor({red, green, blue, alpha=255}) {
  return {
    fill: `rgb(${red}, ${green}, ${blue})`,
    fillOpacity: (alpha/255).toFixed(2)
  };
}

And even easier is to use rgba() instead of rgb(), because there's no need to set the opacity separately:

function rgbaToSvgColor({red, green, blue, alpha=255}) {
  return `rgba(${red}, ${green}, ${blue}, ${(alpha/255).toFixed(2)})`;
}

@jlarmstrongiv
Copy link
Author

Looking forward to it @Pomax ! I kept trying to debug why those paths crashed on the glyph layers, but I don’t feel any closer to a solution.

For the colors, I was under the old impression that svgs did not support transparency or alpha level on SVG fill colours ( https://stackoverflow.com/a/6042577 ), which is why you see that funky workaround. But, I should have read more, it seems modern browsers support it now and I’ll to check and see if other tools support it too. Thank you!

@jlarmstrongiv
Copy link
Author

@Pomax
Copy link
Contributor

Pomax commented Aug 7, 2021

Looks like even if that change reverted, the error's still there though. The problem is that a resolves to glyph id 43, which has a numberOfContours of -1, so it's a compound glyph (meaning, a glyph made of other glyphs, rather than of one single (set of) path(s)).

However, if we look at the glyph information according to fontkit, we get this:

{
  numberOfContours: -1,
  xMin: 54,
  yMin: 0,
  xMax: 676,
  yMax: 720,
  components: [
    Component {
      glyphID: 43,
      dx: 0,
      dy: 0,
      pos: 12,
      scaleY: 1,
      scaleX: 1,
      scale10: 0,
      scale01: 0
    }
  ]
}

So this glyph with id 43 is a compound glyph, consisting of a single other glyph (that's... unusual, but a reasonable edge case) which is... itself. So something's going very wrong here.

@Pomax
Copy link
Contributor

Pomax commented Aug 7, 2021

However, if we check the TTX dump for this font, we see:

    <TTGlyph name="A" xMin="54" yMin="0" xMax="676" yMax="720">
      <contour>
        <pt x="330" y="510" on="1"/>
        <pt x="283" y="358" on="1"/>
        <pt x="440" y="358" on="1"/>
        <pt x="393" y="510" on="1"/>
        <pt x="389" y="519" on="0"/>
        <pt x="380" y="527" on="0"/>
        <pt x="374" y="527" on="1"/>
        <pt x="349" y="527" on="1"/>
        <pt x="343" y="527" on="0"/>
        <pt x="334" y="519" on="0"/>
      </contour>
      <contour>
        <pt x="273" y="36" on="1"/>
        <pt x="273" y="17" on="0"/>
        <pt x="256" y="0" on="0"/>
        <pt x="237" y="0" on="1"/>
        <pt x="90" y="0" on="1"/>
        <pt x="71" y="0" on="0"/>
        <pt x="54" y="17" on="0"/>
        <pt x="54" y="36" on="1"/>
        <pt x="54" y="300" on="1"/>
        <pt x="54" y="330" on="0"/>
        <pt x="73" y="408" on="0"/>
        <pt x="93" y="460" on="1"/>
        <pt x="180" y="687" on="1"/>
        <pt x="186" y="704" on="0"/>
        <pt x="211" y="720" on="0"/>
        <pt x="231" y="720" on="1"/>
        <pt x="500" y="720" on="1"/>
        <pt x="519" y="720" on="0"/>
        <pt x="544" y="704" on="0"/>
        <pt x="550" y="687" on="1"/>
        <pt x="637" y="460" on="1"/>
        <pt x="657" y="408" on="0"/>
        <pt x="676" y="330" on="0"/>
        <pt x="676" y="300" on="1"/>
        <pt x="676" y="36" on="1"/>
        <pt x="676" y="17" on="0"/>
        <pt x="659" y="0" on="0"/>
        <pt x="640" y="0" on="1"/>
        <pt x="489" y="0" on="1"/>
        <pt x="469" y="0" on="0"/>
        <pt x="450" y="17" on="0"/>
        <pt x="450" y="36" on="1"/>
        <pt x="450" y="176" on="1"/>
        <pt x="273" y="176" on="1"/>
      </contour>
      <instructions/>
    </TTGlyph>

So this glyph has two contours. Not -1 (which is used to indicate "no contours: compound glyph").

@Pomax
Copy link
Contributor

Pomax commented Aug 7, 2021

if we look at the COLR table, we see:

    <ColorGlyph name="A">
      <layer colorID="0" name="A.alt001"/>
      <layer colorID="1" name="A.alt002"/>
    </ColorGlyph>

so looking at those two glyphs in the glyf table again:

    <TTGlyph name="A.alt001" xMin="54" yMin="0" xMax="676" yMax="720">
      <component glyphName="A" x="0" y="0" flags="0x204"/>
      <instructions/>
    </TTGlyph>

    <TTGlyph name="A.alt002" xMin="160" yMin="90" xMax="570" yMax="630">
      <contour>
        <pt x="165" y="272" on="1"/>
        <pt x="567" y="272" on="1"/>
        <pt x="567" y="262" on="1"/>
        <pt x="165" y="262" on="1"/>
      </contour>
      <contour>
        <pt x="570" y="90" on="1"/>
        <pt x="560" y="90" on="1"/>
        <pt x="560" y="303" on="1"/>
        <pt x="560" y="330" on="0"/>
        <pt x="551" y="376" on="0"/>
        <pt x="544" y="396" on="1"/>
        <pt x="475" y="559" on="1"/>
        <pt x="464" y="585" on="0"/>
        <pt x="440" y="620" on="0"/>
        <pt x="412" y="620" on="1"/>
        <pt x="314" y="620" on="1"/>
        <pt x="284" y="620" on="0"/>
        <pt x="260" y="585" on="0"/>
        <pt x="250" y="560" on="1"/>
        <pt x="186" y="396" on="1"/>
        <pt x="179" y="376" on="0"/>
        <pt x="170" y="330" on="0"/>
        <pt x="170" y="303" on="1"/>
        <pt x="170" y="90" on="1"/>
        <pt x="160" y="90" on="1"/>
        <pt x="160" y="303" on="1"/>
        <pt x="160" y="331" on="0"/>
        <pt x="169" y="378" on="0"/>
        <pt x="177" y="400" on="1"/>
        <pt x="241" y="564" on="1"/>
        <pt x="252" y="592" on="0"/>
        <pt x="280" y="630" on="0"/>
        <pt x="314" y="630" on="1"/>
        <pt x="412" y="630" on="1"/>
        <pt x="445" y="630" on="0"/>
        <pt x="472" y="592" on="0"/>
        <pt x="484" y="563" on="1"/>
        <pt x="553" y="400" on="1"/>
        <pt x="561" y="378" on="0"/>
        <pt x="570" y="331" on="0"/>
        <pt x="570" y="303" on="1"/>
      </contour>
      <instructions/>
    </TTGlyph>

so A.alt001 is the compound glyph, with the outlines defined for A.

@Pomax
Copy link
Contributor

Pomax commented Aug 7, 2021

So, quick check:

const fs = require("fs");
const fontkit = require("fontkit");
const fontBuffer = fs.readFileSync("./BungeeColor-Regular_colr_Windows.ttf");
const font = fontkit.create(fontBuffer);

const run = font.layout("a");
const glyph = run.glyphs[0];
const layers = glyph.layers;
console.log(layers);
process.exit();

yields

[
  COLRLayer {
    glyph: TTFGlyph {
      id: 292,
      codePoints: [],
      _font: [TTFFont],
      isMark: false,
      isLigature: false
    },
    color: { blue: 0, green: 9, red: 201, alpha: 255 }
  },
  COLRLayer {
    glyph: TTFGlyph {
      id: 293,
      codePoints: [],
      _font: [TTFFont],
      isMark: false,
      isLigature: false
    },
    color: { blue: 128, green: 149, red: 255, alpha: 255 }
  }
]

This is correct.

@Pomax
Copy link
Contributor

Pomax commented Aug 7, 2021

Extending this a little:

const fs = require("fs");
const fontkit = require("fontkit");
const fontBuffer = fs.readFileSync("./BungeeColor-Regular_colr_Windows.ttf");
const font = fontkit.create(fontBuffer);
const run = font.layout("a");
const glyph = run.glyphs[0];
const layers = glyph.layers;

layers.forEach(({ glyph, color }) => console.log(glyph))
process.exit();

Yields:

<ref *1> TTFGlyph {
  id: 292,
  codePoints: [],
  _font: TTFFont {
    defaultLanguage: null,
    stream: DecodeStream {
      buffer: <Buffer 00 01 00 00 00 0f 00 80 00 03 00 70 43 4f 4c 52 f6 66 0d c4 00 00 f9 90 00 00 0f ce 43 50 41 4c c9 ff 80 b0 00 01 09 60 00 00 00 16 44 53 49 47 55 57 ... 75298 more bytes>,
      pos: 252,
      length: 75348
    },
    variationCoords: null,
    _directoryPos: 0,
    _tables: {
      GSUB: [Object],
      GPOS: [Object],
      cmap: [Object],
      hhea: [Object],
      maxp: [Object],
      hmtx: [Object],
      'OS/2': [Object],
      CPAL: [Object],
      COLR: [Object]
    },
    _glyphs: {
      '10': [COLRGlyph],
      '43': [COLRGlyph],
      '292': [Circular *1],
      '293': [TTFGlyph]
    },
    directory: {
      tag: '\x00\x01\x00\x00',
      numTables: 15,
      searchRange: 128,
      entrySelector: 3,
      rangeShift: 112,
      tables: [Object]
    }
  },
  isMark: false,
  isLigature: false
}
<ref *1> TTFGlyph {
  id: 293,
  codePoints: [],
  _font: TTFFont {
    defaultLanguage: null,
    stream: DecodeStream {
      buffer: <Buffer 00 01 00 00 00 0f 00 80 00 03 00 70 43 4f 4c 52 f6 66 0d c4 00 00 f9 90 00 00 0f ce 43 50 41 4c c9 ff 80 b0 00 01 09 60 00 00 00 16 44 53 49 47 55 57 ... 75298 more bytes>,
      pos: 252,
      length: 75348
    },
    variationCoords: null,
    _directoryPos: 0,
    _tables: {
      GSUB: [Object],
      GPOS: [Object],
      cmap: [Object],
      hhea: [Object],
      maxp: [Object],
      hmtx: [Object],
      'OS/2': [Object],
      CPAL: [Object],
      COLR: [Object]
    },
    _glyphs: {
      '10': [COLRGlyph],
      '43': [COLRGlyph],
      '292': [TTFGlyph],
      '293': [Circular *1]
    },
    directory: {
      tag: '\x00\x01\x00\x00',
      numTables: 15,
      searchRange: 128,
      entrySelector: 3,
      rangeShift: 112,
      tables: [Object]
    }
  },
  isMark: false,
  isLigature: false
}

This, too, is correct.

@Pomax
Copy link
Contributor

Pomax commented Aug 7, 2021

Tracing this further:

const fs = require("fs");
const fontkit = require("fontkit");
const fontBuffer = fs.readFileSync("./BungeeColor-Regular_colr_Windows.ttf");
const font = fontkit.create(fontBuffer);
const run = font.layout("a");
const glyph = run.glyphs[0];
const layers = glyph.layers;

layers.forEach(({ glyph, color }) => {
  const decoded = glyph._decode();
  if (decoded.numberOfContours === -1) {
    decoded.components.map(({ glyphID }) => {
      const g = font.getGlyph(glyphID);
      console.log(glyphID, g.constructor.name, g._getContours, g.path);
    });
  }
  else {
    // const contours = glyph._getContours();
    // console.log(contours);
  }
})
process.exit();

Yields

43 COLRGlyph undefined Path {
  commands: [],
  _bbox: null,
  _cbox: BBox {
    minX: Infinity,
    minY: Infinity,
    maxX: -Infinity,
    maxY: -Infinity
  }
}

And that definitely looks like why things are going wrong: sure, we're looking up glyphid:43 because we found it in a layer, but it should not be a COLRGlyph, it should be a normal TTFGlyph.

@Pomax
Copy link
Contributor

Pomax commented Aug 7, 2021

So the change to _getBaseGlyph is supposed to get around this, ensuring that it fetches the glyph as a TTFGlyph instead of a COLRGlyph. However, it fails, because Fontkit is caching glyphs based on their id, but without separate caches for "base glyphs" vs. COLR (etc.) glyphs, which is a bit of a problem when you need the base glyph.

So if we can somehow clear glyph caching, we can make this work. Sort of. It'll be hacky.

@Pomax
Copy link
Contributor

Pomax commented Aug 7, 2021

And there we have it: this is not great code, but that's more on Fontkit than on you or me at this point, really... =/

const fs = require("fs");
const fontkit = require("fontkit");
const fontBuffer = fs.readFileSync("./BungeeColor-Regular_colr_Windows.ttf");
const font = fontkit.create(fontBuffer);

function rgbaToSvgColor({ red, green, blue, alpha = 255 }) {
  return {
    fill: `rgb(${red}, ${green}, ${blue})`,
    opacity: (alpha / 255).toFixed(2),
  };
}

function svgPath(glyph) {
  return glyph.path.toSVG();
}

const layout = font.layout("a");

const paths = layout.glyphs[0].layers.map(({ glyph, color }) => {
  const { fill, opacity } = rgbaToSvgColor(color);
  const decoded = glyph._decode();
  const d =
    decoded.numberOfContours === -1
      ? decoded.components
          .map(({ glyphID }) => {
            font._glyphs[glyphID] = false; // EXPLICIT CACHE CLEAR FOR THIS GLYPH
            return svgPath(font._getBaseGlyph(glyphID))
          })
          .join(` `)
      : svgPath(glyph);
  return `<path fill="${fill}" fill-opacity="${opacity}" d="${d}" />`;
});

const svg = `
  <svg xmlns="http://www.w3.org/2000/svg" width="1000" height="1000" viewBox="0 0 1000 1000">
    <g transform="translate(0,1000) scale(1,-1)">
      ${paths.join(`\n`)}
    </g>
  </svg>
`;

console.log(svg);
fs.writeFileSync(`test.svg`, svg, `utf-8`);

This code yields a file that looks like:

image

And you may have noticed some things. Or not, either way, let's point them out:

  1. this is effectively manually resolving compound glyphs. Which means this will almost certainly break for compounds of compounds. At that point, something like OpenType.js or something might be a better choice.
  2. this assumes the cache stays where it is. Given that Fontkit isn't being maintained, that's not unreasonable, but it's worth remembering.
  3. We're doing the flip-and-reposition using <g> attributes, but this assumes the em quad is actually 1000 units: you'll want to verify that by consulting the head table.
  4. I gave the SVG a width and height value, but these are 100% wrong, and you should pull those numbers from the actual layout result =D

So with all those caveats: have some working code that will at the very least unblock you in whatever you're trying to do that Fontkit is actively trying to fight you on =D

@jlarmstrongiv
Copy link
Author

jlarmstrongiv commented Aug 7, 2021

Wow, thank you for not only solving the difficult bug, but also showing your debugging steps 🙇‍♂️ another round of coffees on me

Appreciate the takeaways! I’ll definitely be able to play around with the viewBox, width, height, and em quad and fix the sizing. Pinning the version of fontkit (or writing tests) is definitely a good idea to make sure it continues to work.

I actually started with both opentypejs and fontkit, and eventually found that fontkit could do a lot of what opentypejs could (but work with more formats). I don’t think opentypejs has support either ( opentypejs/opentype.js#193 ).

You mention compounds of compounds—I’ll definitely try to make sure those work. Would that mean adding extra checks to the svgPath function?

// excuse the pseudo code

function svgPath(glyph) {
  const decoded = glyph._decode();
  decoded.numberOfContours === -1
      ? decoded.components
          .map(({ glyphID }) => {
            font._glyphs[glyphID] = false; // EXPLICIT CACHE CLEAR
            return svgPath(font._getBaseGlyph(glyphID))
          })
          .join(` `)
      : glyph.path.toSVG();
}

const layout = font.layout("a");

const paths = layout.glyphs[0].layers.map(({ glyph, color }) => {
  const { fill, opacity } = rgbaToSvgColor(color);
  const d = svgPath(glyph)
  return `<path fill="${fill}" fill-opacity="${opacity}" d="${d}" />`;
});

I think that’s the only thing I had a question about. Amazing stuff.

@Pomax
Copy link
Contributor

Pomax commented Aug 7, 2021

Yeah, you'd basically be checking the result of font._getBaseGlyph(glyphID) to see if it, too, is a compound glyph or not. If so, time for (a creatively implemented form of) recursion!

@jlarmstrongiv
Copy link
Author

jlarmstrongiv commented Aug 7, 2021

That trick for clearing the cache to get glyph.path.toSVG(); also worked for getting the bounding box (some reducing involved)😄 everything works now—thanks again!

Note to future self: restore the cache afterwards

@Pomax
Copy link
Contributor

Pomax commented Aug 7, 2021

Since we're only clearing the cache on a glyph-by-glyph basis (rather than just deleting everything) and the code you're running refills the cache for that glyph, I'm pretty sure there's no need to restore anything.

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

No branches or pull requests

2 participants