pdf.js/src/display/font_loader.js
Jonas Jenwald b6d090cc14 Fallback to the built-in font renderer when font loading fails
After PR 9340 all glyphs are now re-mapped to a Private Use Area (PUA) which means that if a font fails to load, for whatever reason[1], all glyphs in the font will now render as Unicode glyph outlines.
This obviously doesn't look good, to say the least, and might be seen as a "regression" since previously many glyphs were left in their original positions which provided a slightly better fallback[2].

Hence this patch, which implements a *general* fallback to the PDF.js built-in font renderer for fonts that fail to load (i.e. are rejected by the sanitizer). One caveat here is that this only works for the Font Loading API, since it's easy to handle errors in that case[3].

The solution implemented in this patch does *not* in any way delay the loading of valid fonts, which was the problem with my previous attempt at a solution, and will only require a bit of extra work/waiting for those fonts that actually fail to load.

*Please note:* This patch doesn't fix any of the underlying PDF.js font conversion bugs that's responsible for creating corrupt font files, however it does *improve* rendering in a number of cases; refer to this possibly incomplete list:

[Bug 1524888](https://bugzilla.mozilla.org/show_bug.cgi?id=1524888)
Issue 10175
Issue 10232

---
[1] Usually because the PDF.js font conversion code wasn't able to parse the font file correctly.

[2] Glyphs fell back to some default font, which while not accurate was more useful than the current state.

[3] Furthermore I'm not sure how to implement this generally, assuming that's even possible, and don't really have time/interest to look into it either.
2019-02-11 10:27:08 +01:00

446 lines
15 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, bytesToString, isEvalSupported, shadow, string32, unreachable,
UNSUPPORTED_FEATURES, warn
} from '../shared/util';
class BaseFontLoader {
constructor({ docId, onUnsupportedFeature, }) {
if (this.constructor === BaseFontLoader) {
unreachable('Cannot initialize BaseFontLoader.');
}
this.docId = docId;
this._onUnsupportedFeature = onUnsupportedFeature;
this.nativeFontFaces = [];
this.styleElement = null;
}
addNativeFontFace(nativeFontFace) {
this.nativeFontFaces.push(nativeFontFace);
document.fonts.add(nativeFontFace);
}
insertRule(rule) {
let styleElement = this.styleElement;
if (!styleElement) {
styleElement = this.styleElement = document.createElement('style');
styleElement.id = `PDFJS_FONT_STYLE_TAG_${this.docId}`;
document.documentElement.getElementsByTagName('head')[0].appendChild(
styleElement);
}
const styleSheet = styleElement.sheet;
styleSheet.insertRule(rule, styleSheet.cssRules.length);
}
clear() {
this.nativeFontFaces.forEach(function(nativeFontFace) {
document.fonts.delete(nativeFontFace);
});
this.nativeFontFaces.length = 0;
if (this.styleElement) {
// Note: ChildNode.remove doesn't throw if the parentNode is undefined.
this.styleElement.remove();
this.styleElement = null;
}
}
async bind(font) {
// Add the font to the DOM only once; skip if the font is already loaded.
if (font.attached || font.missingFile) {
return;
}
font.attached = true;
if (this.isFontLoadingAPISupported) {
const nativeFontFace = font.createNativeFontFace();
if (nativeFontFace) {
this.addNativeFontFace(nativeFontFace);
try {
await nativeFontFace.loaded;
} catch (ex) {
this._onUnsupportedFeature({ featureId: UNSUPPORTED_FEATURES.font, });
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.
}
return new Promise((resolve) => {
const request = this._queueLoadingCallback(resolve);
this._prepareFontLoadEvent([rule], [font], request);
});
}
}
_queueLoadingCallback(callback) {
unreachable('Abstract method `_queueLoadingCallback`.');
}
get isFontLoadingAPISupported() {
unreachable('Abstract method `isFontLoadingAPISupported`.');
}
get isSyncFontLoadingSupported() {
unreachable('Abstract method `isSyncFontLoadingSupported`.');
}
get _loadTestFont() {
unreachable('Abstract method `_loadTestFont`.');
}
_prepareFontLoadEvent(rules, fontsToLoad, request) {
unreachable('Abstract method `_prepareFontLoadEvent`.');
}
}
let FontLoader;
if (typeof PDFJSDev !== 'undefined' && PDFJSDev.test('MOZCENTRAL')) {
FontLoader = class MozcentralFontLoader extends BaseFontLoader {
get isFontLoadingAPISupported() {
return shadow(this, 'isFontLoadingAPISupported',
typeof document !== 'undefined' && !!document.fonts);
}
get isSyncFontLoadingSupported() {
return shadow(this, 'isSyncFontLoadingSupported', true);
}
};
} else { // PDFJSDev.test('CHROME || GENERIC')
FontLoader = class GenericFontLoader extends BaseFontLoader {
constructor(docId) {
super(docId);
this.loadingContext = {
requests: [],
nextRequestId: 0,
};
this.loadTestFontId = 0;
}
get isFontLoadingAPISupported() {
let supported = (typeof document !== 'undefined' && !!document.fonts);
if ((typeof PDFJSDev === 'undefined' || !PDFJSDev.test('CHROME')) &&
(supported && typeof navigator !== 'undefined')) {
// The Firefox Font Loading API does not work with `mozPrintCallback`
// prior to version 63; see https://bugzilla.mozilla.org/show_bug.cgi?id=1473742
const m = /Mozilla\/5.0.*?rv:(\d+).*? Gecko/.exec(navigator.userAgent);
if (m && m[1] < 63) {
supported = false;
}
}
return shadow(this, 'isFontLoadingAPISupported', supported);
}
get isSyncFontLoadingSupported() {
let supported = false;
if (typeof PDFJSDev === 'undefined' || !PDFJSDev.test('CHROME')) {
if (typeof navigator === 'undefined') {
// Node.js - we can pretend that sync font loading is supported.
supported = true;
} else {
// User agent string sniffing is bad, but there is no reliable way to
// tell if the font is fully loaded and ready to be used with canvas.
const m = /Mozilla\/5.0.*?rv:(\d+).*? Gecko/.exec(navigator.userAgent);
if (m && m[1] >= 14) {
supported = true;
}
// TODO - other browsers...
}
}
return shadow(this, 'isSyncFontLoadingSupported', supported);
}
_queueLoadingCallback(callback) {
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 (context.requests.length > 0 && context.requests[0].done) {
const otherRequest = context.requests.shift();
setTimeout(otherRequest.callback, 0);
}
}
const context = this.loadingContext;
const request = {
id: `pdfjs-font-loading-${context.nextRequestId++}`,
done: false,
complete: completeRequest,
callback,
};
context.requests.push(request);
return request;
}
get _loadTestFont() {
const getLoadTestFont = function() {
// This is a CFF font with 1 glyph for '.' that fills its entire width and
// height.
return 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', getLoadTestFont());
}
_prepareFontLoadEvent(rules, fonts, request) {
/** 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) {
let chunk1 = s.substring(0, offset);
let chunk2 = s.substring(offset + remove);
return chunk1 + insert + chunk2;
}
let i, ii;
// The temporary canvas is used to determine if fonts are loaded.
let canvas = document.createElement('canvas');
canvas.width = 1;
canvas.height = 1;
let ctx = canvas.getContext('2d');
let called = 0;
function isFontReady(name, callback) {
called++;
// 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);
let 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;
let 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
let CFF_CHECKSUM_OFFSET = 16;
let 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);
let names = [];
for (i = 0, ii = fonts.length; i < ii; i++) {
names.push(fonts[i].loadedName);
}
names.push(loadTestFontId);
let div = document.createElement('div');
div.setAttribute('style', 'visibility: hidden;' +
'width: 10px; height: 10px;' +
'position: absolute; top: 0px; left: 0px;');
for (i = 0, ii = names.length; i < ii; ++i) {
let span = document.createElement('span');
span.textContent = 'Hi';
span.style.fontFamily = names[i];
div.appendChild(span);
}
document.body.appendChild(div);
isFontReady(loadTestFontId, function() {
document.body.removeChild(div);
request.complete();
});
/** Hack end */
}
};
} // End of PDFJSDev.test('CHROME || GENERIC')
const IsEvalSupportedCached = {
get value() {
return shadow(this, 'value', isEvalSupported());
},
};
class FontFaceObject {
constructor(translatedData, { isEvalSupported = true,
disableFontFace = false,
ignoreErrors = false,
onUnsupportedFeature = null,
fontRegistry = null, }) {
this.compiledGlyphs = Object.create(null);
// importing translated data
for (let i in translatedData) {
this[i] = translatedData[i];
}
this.isEvalSupported = isEvalSupported !== false;
this.disableFontFace = disableFontFace === true;
this.ignoreErrors = ignoreErrors === true;
this._onUnsupportedFeature = onUnsupportedFeature;
this.fontRegistry = fontRegistry;
}
createNativeFontFace() {
if (!this.data || this.disableFontFace) {
return null;
}
const nativeFontFace = new FontFace(this.loadedName, this.data, {});
if (this.fontRegistry) {
this.fontRegistry.registerFont(this);
}
return nativeFontFace;
}
createFontFaceRule() {
if (!this.data || this.disableFontFace) {
return null;
}
const data = bytesToString(new Uint8Array(this.data));
// Add the @font-face rule to the document.
const url = `url(data:${this.mimetype};base64,${btoa(data)});`;
const rule = `@font-face {font-family:"${this.loadedName}";src:${url}}`;
if (this.fontRegistry) {
this.fontRegistry.registerFont(this, url);
}
return rule;
}
getPathGenerator(objs, character) {
if (this.compiledGlyphs[character] !== undefined) {
return this.compiledGlyphs[character];
}
let cmds, current;
try {
cmds = objs.get(this.loadedName + '_path_' + character);
} catch (ex) {
if (!this.ignoreErrors) {
throw ex;
}
if (this._onUnsupportedFeature) {
this._onUnsupportedFeature({ featureId: UNSUPPORTED_FEATURES.font, });
}
warn(`getPathGenerator - ignoring character: "${ex}".`);
return this.compiledGlyphs[character] = function(c, size) {
// No-op function, to allow rendering to continue.
};
}
// If we can, compile cmds into JS for MAXIMUM SPEED...
if (this.isEvalSupported && IsEvalSupportedCached.value) {
let args, js = '';
for (let i = 0, ii = cmds.length; i < ii; i++) {
current = cmds[i];
if (current.args !== undefined) {
args = current.args.join(',');
} else {
args = '';
}
js += 'c.' + current.cmd + '(' + args + ');\n';
}
// eslint-disable-next-line no-new-func
return this.compiledGlyphs[character] = new Function('c', 'size', js);
}
// ... but fall back on using Function.prototype.apply() if we're
// blocked from using eval() for whatever reason (like CSP policies).
return this.compiledGlyphs[character] = function(c, size) {
for (let i = 0, ii = cmds.length; i < ii; i++) {
current = cmds[i];
if (current.cmd === 'scale') {
current.args = [size, -size];
}
c[current.cmd].apply(c, current.args);
}
};
}
}
export {
FontFaceObject,
FontLoader,
};