Open jlarmstrongiv opened 3 years ago
You probably want to include the code you used, too
@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.
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.
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:
These svgs can be extracted:
It’s possible to see which characters are contained too:
So it’s definitely possible to create a very rough parser in the meantime. The best solution would be adding support with fontkit.
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 https://github.com/foliojs/fontkit/blob/417af0c79c5664271a07a783574ec7fac7ebad0c/src/TTFFont.js#L38-L46 it appears you should be able to get the table by using the data in font.directory.tables["SVG"]
.
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.
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:
To fix that, find the top level <g>
and add transform="scale(1,-1)"
to flip the coordinates to something you can actually see:
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
👍
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
@Pomax definitely, enjoy a few coffees 😄 thanks again!
@Pomax tangential to this issue of color font support, would you happen to know more about support for COLR tables?
Example font:
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:
On the other hand, SBIX support via getImageForSize
works great 👍
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)})`;
}
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!
Following up, I wonder if it’s related to https://github.com/foliojs/fontkit/pull/116/files#diff-73702de003b5a6f2d69ac4b8664b227c89c89d93c61ec0d6b9f9e81b816cbe9fR274
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.
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").
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.
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.
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.
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.
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.
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:
And you may have noticed some things. Or not, either way, let's point them out:
<g>
attributes, but this assumes the em quad is actually 1000 units: you'll want to verify that by consulting the head
table.width
and height
value, but these are 100% wrong, and you should pull those numbers from the actual layout result =DSo 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
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 ( https://github.com/opentypejs/opentype.js/issues/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.
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!
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
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.
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.
Here are a few examples to help debug: color-fonts.zip