pdf.js/src/display/font_loader.js
Ujjwal Sharma 4bed7370f4 [WIP] Serialize font data into an ArrayBuffer
This PR serializes font data into an ArrayBuffer
that is then transfered from the worker to the
main thread. It's more efficient than the current
solution which clones the "export data" object
which includes the font data as a Uint8Array.

It prepares us to switch to a SharedArrayBuffer
in the future, which would allow us to share
the font data with multiple agents, which would be
crucial for the upcoming "renderer" worker.
2025-09-19 12:02:40 +05:30

545 lines
16 KiB
JavaScript

/* Copyright 2012 Mozilla Foundation
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
assert,
FeatureTest,
isNodeJS,
shadow,
string32,
toBase64Util,
unreachable,
warn,
} from "../shared/util.js";
class FontLoader {
#systemFonts = new Set();
constructor({
ownerDocument = globalThis.document,
styleElement = null, // For testing only.
}) {
this._document = ownerDocument;
this.nativeFontFaces = new Set();
this.styleElement =
typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")
? styleElement
: null;
if (typeof PDFJSDev === "undefined" || !PDFJSDev.test("MOZCENTRAL")) {
this.loadingRequests = [];
this.loadTestFontId = 0;
}
}
addNativeFontFace(nativeFontFace) {
this.nativeFontFaces.add(nativeFontFace);
this._document.fonts.add(nativeFontFace);
}
removeNativeFontFace(nativeFontFace) {
this.nativeFontFaces.delete(nativeFontFace);
this._document.fonts.delete(nativeFontFace);
}
insertRule(rule) {
if (!this.styleElement) {
this.styleElement = this._document.createElement("style");
this._document.documentElement
.getElementsByTagName("head")[0]
.append(this.styleElement);
}
const styleSheet = this.styleElement.sheet;
styleSheet.insertRule(rule, styleSheet.cssRules.length);
}
clear() {
for (const nativeFontFace of this.nativeFontFaces) {
this._document.fonts.delete(nativeFontFace);
}
this.nativeFontFaces.clear();
this.#systemFonts.clear();
if (this.styleElement) {
// Note: ChildNode.remove doesn't throw if the parentNode is undefined.
this.styleElement.remove();
this.styleElement = null;
}
}
async loadSystemFont({
systemFontInfo: info,
disableFontFace,
_inspectFont,
}) {
if (!info || this.#systemFonts.has(info.loadedName)) {
return;
}
assert(
!disableFontFace,
"loadSystemFont shouldn't be called when `disableFontFace` is set."
);
if (this.isFontLoadingAPISupported) {
const { loadedName, src, style } = info;
const fontFace = new FontFace(loadedName, src, style);
this.addNativeFontFace(fontFace);
try {
await fontFace.load();
this.#systemFonts.add(loadedName);
_inspectFont?.(info);
} catch {
warn(
`Cannot load system font: ${info.baseFontName}, installing it could help to improve PDF rendering.`
);
this.removeNativeFontFace(fontFace);
}
return;
}
unreachable(
"Not implemented: loadSystemFont without the Font Loading API."
);
}
async bind(font) {
// Add the font to the DOM only once; skip if the font is already loaded.
if (font.attached || (font.missingFile && !font.systemFontInfo)) {
return;
}
font.attached = true;
if (font.systemFontInfo) {
await this.loadSystemFont(font);
return;
}
if (this.isFontLoadingAPISupported) {
const nativeFontFace = font.createNativeFontFace();
if (nativeFontFace) {
this.addNativeFontFace(nativeFontFace);
try {
await nativeFontFace.loaded;
} catch (ex) {
warn(`Failed to load font '${nativeFontFace.family}': '${ex}'.`);
// When font loading failed, fall back to the built-in font renderer.
font.disableFontFace = true;
throw ex;
}
}
return; // The font was, asynchronously, loaded.
}
// !this.isFontLoadingAPISupported
const rule = font.createFontFaceRule();
if (rule) {
this.insertRule(rule);
if (this.isSyncFontLoadingSupported) {
return; // The font was, synchronously, loaded.
}
if (typeof PDFJSDev !== "undefined" && PDFJSDev.test("MOZCENTRAL")) {
throw new Error("Not implemented: async font loading");
}
await new Promise(resolve => {
const request = this._queueLoadingCallback(resolve);
this._prepareFontLoadEvent(font, request);
});
// The font was, asynchronously, loaded.
}
}
get isFontLoadingAPISupported() {
const hasFonts = !!this._document?.fonts;
if (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) {
return shadow(
this,
"isFontLoadingAPISupported",
hasFonts && !this.styleElement
);
}
return shadow(this, "isFontLoadingAPISupported", hasFonts);
}
get isSyncFontLoadingSupported() {
if (typeof PDFJSDev !== "undefined" && PDFJSDev.test("MOZCENTRAL")) {
return shadow(this, "isSyncFontLoadingSupported", true);
}
// Node.js - we can pretend that sync font loading is supported.
// Firefox, from version 14, supports synchronous font loading.
return shadow(
this,
"isSyncFontLoadingSupported",
(typeof PDFJSDev === "undefined" || !PDFJSDev.test("CHROME")) &&
(isNodeJS || FeatureTest.platform.isFirefox)
);
}
_queueLoadingCallback(callback) {
if (typeof PDFJSDev !== "undefined" && PDFJSDev.test("MOZCENTRAL")) {
throw new Error("Not implemented: _queueLoadingCallback");
}
function completeRequest() {
assert(!request.done, "completeRequest() cannot be called twice.");
request.done = true;
// Sending all completed requests in order of how they were queued.
while (loadingRequests.length > 0 && loadingRequests[0].done) {
const otherRequest = loadingRequests.shift();
setTimeout(otherRequest.callback, 0);
}
}
const { loadingRequests } = this;
const request = {
done: false,
complete: completeRequest,
callback,
};
loadingRequests.push(request);
return request;
}
get _loadTestFont() {
if (typeof PDFJSDev !== "undefined" && PDFJSDev.test("MOZCENTRAL")) {
throw new Error("Not implemented: _loadTestFont");
}
// This is a CFF font with 1 glyph for '.' that fills its entire width
// and height.
const testFont = atob(
"T1RUTwALAIAAAwAwQ0ZGIDHtZg4AAAOYAAAAgUZGVE1lkzZwAAAEHAAAABxHREVGABQA" +
"FQAABDgAAAAeT1MvMlYNYwkAAAEgAAAAYGNtYXABDQLUAAACNAAAAUJoZWFk/xVFDQAA" +
"ALwAAAA2aGhlYQdkA+oAAAD0AAAAJGhtdHgD6AAAAAAEWAAAAAZtYXhwAAJQAAAAARgA" +
"AAAGbmFtZVjmdH4AAAGAAAAAsXBvc3T/hgAzAAADeAAAACAAAQAAAAEAALZRFsRfDzz1" +
"AAsD6AAAAADOBOTLAAAAAM4KHDwAAAAAA+gDIQAAAAgAAgAAAAAAAAABAAADIQAAAFoD" +
"6AAAAAAD6AABAAAAAAAAAAAAAAAAAAAAAQAAUAAAAgAAAAQD6AH0AAUAAAKKArwAAACM" +
"AooCvAAAAeAAMQECAAACAAYJAAAAAAAAAAAAAQAAAAAAAAAAAAAAAFBmRWQAwAAuAC4D" +
"IP84AFoDIQAAAAAAAQAAAAAAAAAAACAAIAABAAAADgCuAAEAAAAAAAAAAQAAAAEAAAAA" +
"AAEAAQAAAAEAAAAAAAIAAQAAAAEAAAAAAAMAAQAAAAEAAAAAAAQAAQAAAAEAAAAAAAUA" +
"AQAAAAEAAAAAAAYAAQAAAAMAAQQJAAAAAgABAAMAAQQJAAEAAgABAAMAAQQJAAIAAgAB" +
"AAMAAQQJAAMAAgABAAMAAQQJAAQAAgABAAMAAQQJAAUAAgABAAMAAQQJAAYAAgABWABY" +
"AAAAAAAAAwAAAAMAAAAcAAEAAAAAADwAAwABAAAAHAAEACAAAAAEAAQAAQAAAC7//wAA" +
"AC7////TAAEAAAAAAAABBgAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" +
"AAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" +
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" +
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" +
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" +
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMAAAAAAAD/gwAyAAAAAQAAAAAAAAAAAAAAAAAA" +
"AAABAAQEAAEBAQJYAAEBASH4DwD4GwHEAvgcA/gXBIwMAYuL+nz5tQXkD5j3CBLnEQAC" +
"AQEBIVhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYAAABAQAADwACAQEEE/t3" +
"Dov6fAH6fAT+fPp8+nwHDosMCvm1Cvm1DAz6fBQAAAAAAAABAAAAAMmJbzEAAAAAzgTj" +
"FQAAAADOBOQpAAEAAAAAAAAADAAUAAQAAAABAAAAAgABAAAAAAAAAAAD6AAAAAAAAA=="
);
return shadow(this, "_loadTestFont", testFont);
}
_prepareFontLoadEvent(font, request) {
if (typeof PDFJSDev !== "undefined" && PDFJSDev.test("MOZCENTRAL")) {
throw new Error("Not implemented: _prepareFontLoadEvent");
}
/** Hack begin */
// There's currently no event when a font has finished downloading so the
// following code is a dirty hack to 'guess' when a font is ready.
// It's assumed fonts are loaded in order, so add a known test font after
// the desired fonts and then test for the loading of that test font.
function int32(data, offset) {
return (
(data.charCodeAt(offset) << 24) |
(data.charCodeAt(offset + 1) << 16) |
(data.charCodeAt(offset + 2) << 8) |
(data.charCodeAt(offset + 3) & 0xff)
);
}
function spliceString(s, offset, remove, insert) {
const chunk1 = s.substring(0, offset);
const chunk2 = s.substring(offset + remove);
return chunk1 + insert + chunk2;
}
let i, ii;
// The temporary canvas is used to determine if fonts are loaded.
const canvas = this._document.createElement("canvas");
canvas.width = 1;
canvas.height = 1;
const ctx = canvas.getContext("2d");
let called = 0;
function isFontReady(name, callback) {
// With setTimeout clamping this gives the font ~100ms to load.
if (++called > 30) {
warn("Load test font never loaded.");
callback();
return;
}
ctx.font = "30px " + name;
ctx.fillText(".", 0, 20);
const imageData = ctx.getImageData(0, 0, 1, 1);
if (imageData.data[3] > 0) {
callback();
return;
}
setTimeout(isFontReady.bind(null, name, callback));
}
const loadTestFontId = `lt${Date.now()}${this.loadTestFontId++}`;
// Chromium seems to cache fonts based on a hash of the actual font data,
// so the font must be modified for each load test else it will appear to
// be loaded already.
// TODO: This could maybe be made faster by avoiding the btoa of the full
// font by splitting it in chunks before hand and padding the font id.
let data = this._loadTestFont;
const COMMENT_OFFSET = 976; // has to be on 4 byte boundary (for checksum)
data = spliceString(
data,
COMMENT_OFFSET,
loadTestFontId.length,
loadTestFontId
);
// CFF checksum is important for IE, adjusting it
const CFF_CHECKSUM_OFFSET = 16;
const XXXX_VALUE = 0x58585858; // the "comment" filled with 'X'
let checksum = int32(data, CFF_CHECKSUM_OFFSET);
for (i = 0, ii = loadTestFontId.length - 3; i < ii; i += 4) {
checksum = (checksum - XXXX_VALUE + int32(loadTestFontId, i)) | 0;
}
if (i < loadTestFontId.length) {
// align to 4 bytes boundary
checksum = (checksum - XXXX_VALUE + int32(loadTestFontId + "XXX", i)) | 0;
}
data = spliceString(data, CFF_CHECKSUM_OFFSET, 4, string32(checksum));
const url = `url(data:font/opentype;base64,${btoa(data)});`;
const rule = `@font-face {font-family:"${loadTestFontId}";src:${url}}`;
this.insertRule(rule);
const div = this._document.createElement("div");
div.style.visibility = "hidden";
div.style.width = div.style.height = "10px";
div.style.position = "absolute";
div.style.top = div.style.left = "0px";
for (const name of [font.loadedName, loadTestFontId]) {
const span = this._document.createElement("span");
span.textContent = "Hi";
span.style.fontFamily = name;
div.append(span);
}
this._document.body.append(div);
isFontReady(loadTestFontId, () => {
div.remove();
request.complete();
});
/** Hack end */
}
}
class FontFaceObject {
#fontData;
constructor(translatedData, inspectFont = null, extra, charProcOperatorList) {
this.compiledGlyphs = Object.create(null);
this.#fontData = translatedData;
if (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) {
if (typeof this.disableFontFace !== "boolean") {
unreachable("disableFontFace must be available.");
}
if (typeof this.fontExtraProperties !== "boolean") {
unreachable("fontExtraProperties must be available.");
}
}
this._inspectFont = inspectFont;
if (extra) {
Object.assign(this, extra);
}
if (charProcOperatorList) {
this.charProcOperatorList = charProcOperatorList;
}
}
createNativeFontFace() {
if (!this.data || this.disableFontFace) {
return null;
}
let nativeFontFace;
if (!this.cssFontInfo) {
nativeFontFace = new FontFace(this.loadedName, this.data, {});
} else {
const css = {
weight: this.cssFontInfo.fontWeight,
};
if (this.cssFontInfo.italicAngle) {
css.style = `oblique ${this.cssFontInfo.italicAngle}deg`;
}
nativeFontFace = new FontFace(
this.cssFontInfo.fontFamily,
this.data,
css
);
}
this._inspectFont?.(this);
return nativeFontFace;
}
createFontFaceRule() {
if (!this.data || this.disableFontFace) {
return null;
}
// Add the @font-face rule to the document.
const url = `url(data:${this.mimetype};base64,${toBase64Util(this.data)});`;
let rule;
if (!this.cssFontInfo) {
rule = `@font-face {font-family:"${this.loadedName}";src:${url}}`;
} else {
let css = `font-weight: ${this.cssFontInfo.fontWeight};`;
if (this.cssFontInfo.italicAngle) {
css += `font-style: oblique ${this.cssFontInfo.italicAngle}deg;`;
}
rule = `@font-face {font-family:"${this.cssFontInfo.fontFamily}";${css}src:${url}}`;
}
this._inspectFont?.(this, url);
return rule;
}
getPathGenerator(objs, character) {
if (this.compiledGlyphs[character] !== undefined) {
return this.compiledGlyphs[character];
}
const objId = this.loadedName + "_path_" + character;
let cmds;
try {
cmds = objs.get(objId);
} catch (ex) {
warn(`getPathGenerator - ignoring character: "${ex}".`);
}
const path = new Path2D(cmds || "");
if (!this.fontExtraProperties) {
// Remove the raw path-string, since we don't need it anymore.
objs.delete(objId);
}
return (this.compiledGlyphs[character] = path);
}
get black() {
return this.#fontData.black;
}
get bold() {
return this.#fontData.bold;
}
get disableFontFace() {
return this.#fontData.disableFontFace ?? false;
}
get fontExtraProperties() {
return this.#fontData.fontExtraProperties ?? false;
}
get isInvalidPDFjsFont() {
return this.#fontData.isInvalidPDFjsFont;
}
get isType3Font() {
return this.#fontData.isType3Font;
}
get italic() {
return this.#fontData.italic;
}
get missingFile() {
return this.#fontData.missingFile;
}
get remeasure() {
return this.#fontData.remeasure;
}
get vertical() {
return this.#fontData.vertical;
}
get ascent() {
return this.#fontData.ascent;
}
get defaultWidth() {
return this.#fontData.defaultWidth;
}
get descent() {
return this.#fontData.descent;
}
get bbox() {
return this.#fontData.bbox;
}
get fontMatrix() {
return this.#fontData.fontMatrix;
}
get fallbackName() {
return this.#fontData.fallbackName;
}
get loadedName() {
return this.#fontData.loadedName;
}
get mimetype() {
return this.#fontData.mimetype;
}
get name() {
return this.#fontData.name;
}
get data() {
return this.#fontData.data;
}
clearData() {
this.#fontData.clearData();
}
get cssFontInfo() {
return this.#fontData.cssFontInfo;
}
get systemFontInfo() {
return this.#fontData.systemFontInfo;
}
get defaultVMetrics() {
return this.#fontData.defaultVMetrics;
}
}
export { FontFaceObject, FontLoader };