satie
Version:
A sheet music renderer for the web
266 lines (227 loc) • 8.62 kB
text/typescript
/**
* This file is part of Satie music engraver <https://github.com/jnetterf/satie>.
* Copyright (C) Joshua Netterfield <joshua.ca> 2015 - present.
*
* Satie is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* Satie is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Satie. If not, see <http://www.gnu.org/licenses/>.
*/
import {Font, parse as parseFont} from "opentype.js";
import {memoize, forEach} from "lodash";
const IS_BROWSER = "browser" in process;
const NO_PATH_DATA = <Font> {};
/*---- PRIVATE ------------------------------------------------------------------------*/
module State {
export let fonts: {[font: string]: Font} = {};
export let cbs: ((err?: Error) => void)[] = [];
export let remaining = 0;
export let err: Error;
export let canvasContext = IS_BROWSER ?
<CanvasRenderingContext2D> document.createElement("canvas").getContext("2d") :
null;
export let root = IS_BROWSER ?
location.protocol + "//" + location.host + "/vendor/" :
"./vendor/";
}
function getFullName(name: string, style?: string) {
name = name.toLowerCase();
style = style && style.toLowerCase();
return `${name}${style ? "_" + style : ""}`;
}
function loadFont(name: string, url: string, style: string, full?: boolean) {
++State.remaining;
let fullName = getFullName(name, style);
url = getNativeURL(url);
if (!full && IS_BROWSER) {
let styleSheet = document.createElement("style");
styleSheet.appendChild(document.createTextNode(`@font-face{
font-family: ${name};
src: url(${url}) format('truetype');
${style && style.toLowerCase() === "bold" ? "font-weight: bold;" : ""}
}`));
document.head.appendChild(styleSheet);
State.fonts[fullName] = State.fonts[fullName] || NO_PATH_DATA;
goOn();
} else {
(IS_BROWSER ? loadFromUrl : loadFromFile)(url, (err, buffer) => {
if (err) {
return goOn(err);
}
let font = parseFont(buffer);
State.fonts[fullName] = font;
if (IS_BROWSER) {
let styleSheet = <CSSStyleSheet> document.styleSheets[0];
let fontFaceStyle = `@font-face{
font-family: ${name};
src: url(data:font/truetype;charset=utf-8;base64,${toBase64(buffer)
}) format('truetype');
${style && style.toLowerCase() === "bold" ? "font-weight: bold;" : ""}
}`;
styleSheet.insertRule(fontFaceStyle, 0);
}
goOn();
});
}
function goOn(err?: Error) {
--State.remaining;
if (!State.remaining) {
forEach(State.cbs, cb => cb(State.err));
State.cbs = [];
}
}
}
/*---- SUPPORT ------------------------------------------------------------------------*/
function toArrayBuffer(buffer: Uint8Array) {
let arrayBuffer = new ArrayBuffer(buffer.length);
let data = new Uint8Array(arrayBuffer);
for (let i = 0; i < buffer.length; i += 1) {
data[i] = buffer[i];
}
return arrayBuffer;
}
function loadFromFile(path: string, callback: (err: Error, buffer?: ArrayBuffer) => void) {
const fs = require("fs");
fs.readFile(path, function(err: Error, buffer: Uint8Array) {
if (err) {
return callback(err);
}
callback(null, toArrayBuffer(buffer));
});
}
function loadFromUrl(url: string, callback: (err: Error, buffer?: ArrayBuffer) => void) {
let request = new XMLHttpRequest();
request.open("get", url, true);
request.responseType = "arraybuffer";
request.onload = function() {
if (request.status !== 200) {
return callback(new Error(`Font could not be loaded: ${request.statusText}`));
}
return callback(null, request.response);
};
request.send();
}
function getNativeURL(url: string) {
return url.replace("root://", State.root);
}
const CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
function toBase64(buffer: ArrayBuffer) {
let bytes = new Uint8Array(buffer);
let len = bytes.length, base64 = "";
for (let i = 0; i < len; i += 3) {
/* tslint:disable */
base64 += CHARS[bytes[i] >> 2];
base64 += CHARS[((bytes[i] & 3) << 4) | (bytes[i + 1] >> 4)];
base64 += CHARS[((bytes[i + 1] & 15) << 2) | (bytes[i + 2] >> 6)];
base64 += CHARS[bytes[i + 2] & 63];
/* tslint:enable */
}
if ((len % 3) === 2) {
base64 = base64.substring(0, base64.length - 1) + "=";
} else if (len % 3 === 1) {
base64 = base64.substring(0, base64.length - 2) + "==";
}
return base64;
}
/*---- PUBLIC -------------------------------------------------------------------------*/
export function requireFont(name: string, url: string, style?: string, full?: boolean) {
let fullName = getFullName(name, style);
if (full && State.fonts[fullName] === NO_PATH_DATA) {
delete State.fonts[fullName];
}
if (!(fullName in State.fonts)) {
State.fonts[fullName] = null; // Indicate it's pending
loadFont(name, url, style, full);
}
}
export function setRoot(root: string) {
State.root = root;
}
export function markPreloaded(name: string, style?: string) {
State.fonts[getFullName(name, style)] = NO_PATH_DATA;
}
export function whenReady(cb: (err?: Error) => void) {
if (!State.remaining) {
cb();
return;
}
State.cbs.push(cb);
}
export function getTextBB(name: string, text: string, fontSize: number, style?: string) {
let fullName = getFullName(name, style);
let font = State.fonts[fullName];
if (State.canvasContext && font === NO_PATH_DATA) {
State.canvasContext.font = `${style || ""} ${fontSize}px ${name}`;
// We want to be consistent between web browsers. Many browsers only support measuring
// width, so even if we are in Chrome and have better information, we ignore that.
// Of course that this information is wrong, but it's good enough to place text.
return {
bottom: fontSize,
left: -fontSize / 18,
right: State.canvasContext.measureText(text).width,
top: -4 * fontSize / 18
};
}
if (font === NO_PATH_DATA) {
// TODO: get width by canvas if this is the browser
console.warn(`${fullName} was loaded without path data`);
return {
bottom: 1,
left: 0,
right: 1,
top: 0
};
}
if (!font) {
console.warn(`${fullName} is not loaded`);
return {
bottom: 1,
left: 0,
right: 1,
top: 0
};
}
let minX = 10000;
let minY = 10000;
let maxX = 0;
let maxY = 0;
font.forEachGlyph(text, 0, 0, fontSize, {kerning: true}, (glyph, x, y, fontSize) => {
let scale = 1 / font.unitsPerEm * fontSize;
minX = Math.min(x, minX);
maxX = Math.max(x, maxX);
minY = Math.min(y + (glyph as any).yMin * scale, minY);
maxY = Math.max(y + (glyph as any).yMax * scale, maxY);
});
return {
bottom: maxY,
left: minX,
right: maxX,
top: minY
};
}
export let toPathData = memoize(function(name: string, text: string,
x: number, y: number, fontSize: number, style?: string): string {
let fullName = getFullName(name, style);
let font = State.fonts[fullName];
if (!font) {
console.warn(`${fullName} is not loaded`);
return "";
}
if (font === NO_PATH_DATA) {
console.warn(`${fullName} was loaded without path data`);
return "";
}
return font.getPath(text, x, y, fontSize, {kerning: true}).toPathData(3);
}, resolvePDKey);
function resolvePDKey(name: string, text: string,
x: number, y: number, fontSize: number, style?: string) {
return name + "_" + text + "_" + x + "_" + y + "_" + fontSize + "_" + (style || "");
}