From cb5740cb31c3043a2903b43dc0c0d472c38162e9 Mon Sep 17 00:00:00 2001 From: Tero Parvinen Date: Mon, 4 May 2026 17:56:34 +0300 Subject: [PATCH 1/2] Support smcp substitutions to enable small caps type fonts --- src/libs/ttffont.js | 165 ++++++++++++++++++++++++++++++++++++++++++++ src/modules/utf8.js | 8 ++- 2 files changed, 171 insertions(+), 2 deletions(-) diff --git a/src/libs/ttffont.js b/src/libs/ttffont.js index f45145182..d5c524129 100644 --- a/src/libs/ttffont.js +++ b/src/libs/ttffont.js @@ -55,6 +55,7 @@ jsPDF.API.TTFFont = (function() { this.os2 = new OS2Table(this); this.loca = new LocaTable(this); this.glyf = new GlyfTable(this); + this.gsub = new GsubTable(this); this.ascender = (this.os2.exists && this.os2.ascender) || this.hhea.ascender; this.decender = @@ -135,6 +136,16 @@ jsPDF.API.TTFFont = (function() { 0 ); }; + TTFFont.prototype.applyFeaturesToGlyph = function(glyphId) { + if ( + this.gsub && + this.gsub.smcpSubstitutions && + this.gsub.smcpSubstitutions[glyphId] !== undefined + ) { + return this.gsub.smcpSubstitutions[glyphId]; + } + return glyphId; + }; TTFFont.prototype.widthOfGlyph = function(glyph) { var scale; scale = 1000.0 / this.head.unitsPerEm; @@ -1678,6 +1689,160 @@ var LocaTable = (function(_super) { return LocaTable; })(Table); +var GsubTable = (function(_super) { + __extends(GsubTable, _super); + + function GsubTable() { + return GsubTable.__super__.constructor.apply(this, arguments); + } + GsubTable.prototype.tag = "GSUB"; + GsubTable.prototype.parse = function(data) { + var i, + j, + tableOffset, + featureListOffset, + lookupListOffset, + featureCount, + tag, + featureOffset, + savedPos, + lookupIndexCount, + smcpLookupIndices, + lookupCount, + lookupOffsets, + lookupIndex, + lookupOffset, + lookupType, + subTableCount, + subTableOffsets, + self; + + this.smcpSubstitutions = {}; + tableOffset = this.offset; + data.pos = tableOffset; + data.pos += 4; // skip MajorVersion + MinorVersion + data.readUInt16(); // scriptListOffset (unused) + featureListOffset = data.readUInt16(); + lookupListOffset = data.readUInt16(); + + data.pos = tableOffset + featureListOffset; + featureCount = data.readUInt16(); + smcpLookupIndices = []; + + for (i = 0; i < featureCount; i++) { + tag = data.readString(4); + featureOffset = data.readUInt16(); + if (tag === "smcp") { + savedPos = data.pos; + data.pos = tableOffset + featureListOffset + featureOffset; + data.readUInt16(); // featureParamsOffset + lookupIndexCount = data.readUInt16(); + for (j = 0; j < lookupIndexCount; j++) { + smcpLookupIndices.push(data.readUInt16()); + } + data.pos = savedPos; + } + } + + if (smcpLookupIndices.length === 0) return; + + data.pos = tableOffset + lookupListOffset; + lookupCount = data.readUInt16(); + lookupOffsets = []; + for (i = 0; i < lookupCount; i++) { + lookupOffsets.push(data.readUInt16()); + } + + self = this; + for (i = 0; i < smcpLookupIndices.length; i++) { + lookupIndex = smcpLookupIndices[i]; + if (lookupIndex >= lookupCount) continue; + lookupOffset = + tableOffset + lookupListOffset + lookupOffsets[lookupIndex]; + data.pos = lookupOffset; + lookupType = data.readUInt16(); + data.readUInt16(); // lookupFlag + subTableCount = data.readUInt16(); + subTableOffsets = []; + for (j = 0; j < subTableCount; j++) { + subTableOffsets.push(lookupOffset + data.readUInt16()); + } + if (lookupType === 1) { + for (j = 0; j < subTableOffsets.length; j++) { + self.parseSingleSubst(data, subTableOffsets[j]); + } + } + } + }; + GsubTable.prototype.parseSingleSubst = function(data, offset) { + var substFormat, + coverageRelOffset, + coverageOffset, + deltaGlyphID, + glyphCount, + substituteGlyphs, + glyphs, + i; + data.pos = offset; + substFormat = data.readUInt16(); + coverageRelOffset = data.readUInt16(); + coverageOffset = offset + coverageRelOffset; + + if (substFormat === 1) { + deltaGlyphID = data.readInt16(); + glyphs = this.parseCoverage(data, coverageOffset); + for (i = 0; i < glyphs.length; i++) { + this.smcpSubstitutions[glyphs[i]] = (glyphs[i] + deltaGlyphID) & 0xffff; + } + } else if (substFormat === 2) { + glyphCount = data.readUInt16(); + substituteGlyphs = []; + for (i = 0; i < glyphCount; i++) { + substituteGlyphs.push(data.readUInt16()); + } + glyphs = this.parseCoverage(data, coverageOffset); + for (i = 0; i < glyphs.length; i++) { + if (i < substituteGlyphs.length) { + this.smcpSubstitutions[glyphs[i]] = substituteGlyphs[i]; + } + } + } + }; + GsubTable.prototype.parseCoverage = function(data, offset) { + var coverageFormat, + glyphCount, + rangeCount, + startGlyphID, + endGlyphID, + glyphs, + i, + g; + data.pos = offset; + coverageFormat = data.readUInt16(); + glyphs = []; + + if (coverageFormat === 1) { + glyphCount = data.readUInt16(); + for (i = 0; i < glyphCount; i++) { + glyphs.push(data.readUInt16()); + } + } else if (coverageFormat === 2) { + rangeCount = data.readUInt16(); + for (i = 0; i < rangeCount; i++) { + startGlyphID = data.readUInt16(); + endGlyphID = data.readUInt16(); + data.readUInt16(); // startCoverageIndex + for (g = startGlyphID; g <= endGlyphID; g++) { + glyphs.push(g); + } + } + } + + return glyphs; + }; + return GsubTable; +})(Table); + /************************************************************************************/ /* function : invert */ /* comment : Change the object's (key: value) to create an object with (value: key).*/ diff --git a/src/modules/utf8.js b/src/modules/utf8.js index 91ce12852..a212a154d 100644 --- a/src/modules/utf8.js +++ b/src/modules/utf8.js @@ -19,9 +19,13 @@ import { toPDFName } from "../libs/pdfname.js"; var padz = ["", "0", "00", "000", "0000"]; var ar = [""]; for (var i = 0, l = text.length, t; i < l; ++i) { - t = font.metadata.characterToGlyph(text.charCodeAt(i)); + var charCode = text.charCodeAt(i); + t = font.metadata.characterToGlyph(charCode); + if (font.metadata.applyFeaturesToGlyph) { + t = font.metadata.applyFeaturesToGlyph(t); + } font.metadata.glyIdsUsed.push(t); - font.metadata.toUnicode[t] = text.charCodeAt(i); + font.metadata.toUnicode[t] = charCode; if (widths.indexOf(t) == -1) { widths.push(t); widths.push([parseInt(font.metadata.widthOfGlyph(t), 10)]); From 7f54e05856bb30a429c90538bbbe9a2458a7b739 Mon Sep 17 00:00:00 2001 From: Tero Parvinen Date: Tue, 5 May 2026 17:39:59 +0300 Subject: [PATCH 2/2] Fix false positive application of small caps on fonts that support but do not require them --- src/libs/ttffont.js | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/libs/ttffont.js b/src/libs/ttffont.js index d5c524129..8ab3a9465 100644 --- a/src/libs/ttffont.js +++ b/src/libs/ttffont.js @@ -56,6 +56,10 @@ jsPDF.API.TTFFont = (function() { this.loca = new LocaTable(this); this.glyf = new GlyfTable(this); this.gsub = new GsubTable(this); + this.gsub.smcpEnabled = + this.gsub.exists && + Object.keys(this.gsub.smcpSubstitutions).length > 0 && + isSmallCapsFont(this.name); this.ascender = (this.os2.exists && this.os2.ascender) || this.hhea.ascender; this.decender = @@ -139,7 +143,7 @@ jsPDF.API.TTFFont = (function() { TTFFont.prototype.applyFeaturesToGlyph = function(glyphId) { if ( this.gsub && - this.gsub.smcpSubstitutions && + this.gsub.smcpEnabled && this.gsub.smcpSubstitutions[glyphId] !== undefined ) { return this.gsub.smcpSubstitutions[glyphId]; @@ -176,6 +180,25 @@ jsPDF.API.TTFFont = (function() { gap = includeGap ? this.lineGap : 0; return ((this.ascender + gap - this.decender) / 1000) * size; }; + + var SMALL_CAPS_RE = /small[\s_-]?caps|smallcaps|\bSC\b/i; + + // Turns out the most common way to detect a font that is intended to render in small caps + // is to check for the presence of smcp gsub substitutions and a simple string lookup in + // the font name records + var isSmallCapsFont = function(nameTable) { + var nameIDs = [1, 4, 17]; + for (var i = 0; i < nameIDs.length; i++) { + var entries = nameTable.strings[nameIDs[i]]; + if (!entries) continue; + for (var j = 0; j < entries.length; j++) { + var text = entries[j].raw.replace(/[\x00-\x19\x80-\xff]/g, ""); + if (SMALL_CAPS_RE.test(text)) return true; + } + } + return false; + }; + return TTFFont; })();