Skip to content

Commit

Permalink
add the easy-to-use api and refactor font registration
Browse files Browse the repository at this point in the history
  • Loading branch information
chearon committed Sep 25, 2023
1 parent 20b5c3a commit 4d4d458
Show file tree
Hide file tree
Showing 8 changed files with 152 additions and 57 deletions.
67 changes: 41 additions & 26 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,58 +29,73 @@ Shaping is done internally, as web browsers do, with [harfbuzzjs](https://github

The fastest performance can be achieved by using the hyperscript API, which creates a DOM directly and skips the typical HTML and CSS parsing steps. Take care to re-use style objects to get the most benefits. Reflows at different widths are faster than recreating the layout tree.

# Hyperscript API
# Fast hyperscript API

Overflow works off of a DOM with inherited and calculated styles, the same way
that browsers do. You create the DOM with the familiar `h()` function, and
specify styles as plain objects.

```ts
import {h, layout, paintToCanvas, registerFont, eachRegisteredFont} from 'overflow';
import {createCanvas, registerFont as canvasRegisterFont} from 'canvas';
import {h, renderToCanvas, registerFont} from 'overflow';
import {createCanvas} from 'canvas';
import fs from 'node:fs';

const font = new Uint8Array(fs.readFileSync(new URL('Roboto.ttf', import.meta.url)));
// Register fonts before layout. This is a required step.
// It is only async when you don't pass an ArrayBuffer
await registerFont(new URL('fonts/Roboto-Regular.ttf', import.meta.url));
await registerFont(new URL('fonts/Roboto-Bold.ttf', import.meta.url));

registerFont(font, 'Roboto.ttf' /* must be unique */);
// Always create styles at the top-level of your module if you can
const divStyle = {
backgroundColor: {r: 28, g: 10, b: 0, a: 1},
color: {r: 179, g: 200, b: 144, a: 1}
};

eachRegisteredFont(match => canvasRegisterFont(match.filename, match.toNodeCanvas()));
// Since we're creating styles directly, colors have to be defined numerically
const spanStyle = {
color: {r: 115, g: 169, b: 173, a: 1},
fontWeight: 700
};

// always save style references and re-use them if you can
const divStyle = {backgroundColor: {r: 128, g: 128, b: 128, a: 1}};
const spanStyle = {fontWeight: 700, color: {r: 255, g: 0, b: 0, a: 1}};
// Create a DOM
const rootElement = h('div', {style: divStyle}, [
'Hello, ',
h('span', {style: spanStyle}, ['World!'])
]);

const blockContainer = generate(rootElement);
layout(blockContainer, 640 /* width */, 480 /* height */);
paintToCanvas(blockContainer, ctx);
// Layout and paint into the entire canvas (see also renderToCanvasContext)
const canvas = createCanvas(200, 50);
renderToCanvas(rootElement, canvas, /* optional density: */ 2);

// Save your image
canvas.createPNGStream().pipe(fs.createWriteStream(new URL('hello.png', import.meta.url)));

canvas.createPNGStream().pipe(fs.writeFileSync(new URL('hello_world.png', import.meta.url));
```

# HTML API

This API is only recommended if performance is not a concern, or for learning
purposes. Parsing adds extra time (though it is fast) and increases bundle size
significantly.

```ts
import {parse, layout, paintToCanvas, registerFont, eachRegisteredFont} from 'overflow/with-parse';
import {createCanvas, registerFont as canvasRegisterFont} from 'canvas';
import {parse, renderToCanvas, registerFont} from 'overflow/with-parse.js';
import {createCanvas} from 'canvas';
import fs from 'node:fs';

const font = new Uint8Array(fs.readFileSync(new URL('Roboto.ttf', import.meta.url)));

registerFont(font, 'Roboto.ttf' /* must be unique */);

eachRegisteredFont(match => canvasRegisterFont(match.filename, match.toNodeCanvas()));
await registerFont(new URL('fonts/Roboto-Regular.ttf', import.meta.url));
await registerFont(new URL('fonts/Roboto-Bold.ttf', import.meta.url));

const rootElement = parse(`
<div style="background-color: gray;">
Hello, <span style="font-weight: bold; color: red;">World!</span>
<div style="background-color: #1c0a00; color: #b3c890;">
Hello, <span style="color: #73a9ad; font-weight: bold;">World!</span>
</div>
`);

const blockContainer = generate(rootElement);
layout(blockContainer, 640 /* width */, 480 /* height */);
paintToCanvas(blockContainer, ctx);
const canvas = createCanvas(200, 50);
renderToCanvas(rootElement, canvas, 2);

canvas.createPNGStream().pipe(fs.writeFileSync(new URL('hello_world.png', import.meta.url));
canvas.createPNGStream().pipe(fs.createWriteStream(new URL('hello.png', import.meta.url)));
```

# Supported CSS rules
Expand Down
9 changes: 4 additions & 5 deletions assets/register.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import {registerFont, unregisterFont} from '../src/api.js';
import {fileURLToPath} from 'url';
import fs from 'fs';
import fs from 'node:fs';

export function registerFontAsset(filename: string) {
const path = new URL(filename, import.meta.url);
const array = new Uint8Array(fs.readFileSync(path));
registerFont(array, fileURLToPath(path));
const array = fs.readFileSync(path).buffer;
registerFont(array, path);
}

export function unregisterFontAsset(filename: string) {
const path = new URL(filename, import.meta.url);
unregisterFont(fileURLToPath(path));
unregisterFont(path);
}
33 changes: 33 additions & 0 deletions examples/readme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import {h, renderToCanvas, registerFont} from '../src/api.js';
import {createCanvas} from 'canvas';
import fs from 'node:fs';

// Register fonts before layout. This is a required step.
// It is only async when you don't pass an ArrayBuffer
await registerFont(new URL('../assets/Roboto/Roboto-Regular.ttf', import.meta.url));

// Always create styles at the top-level of your module if you can
const divStyle = {
backgroundColor: {r: 28, g: 10, b: 0, a: 1},
color: {r: 179, g: 200, b: 144, a: 1}
};

// Since we're creating styles directly, colors have to be defined numerically
const spanStyle = {
color: {r: 115, g: 169, b: 173, a: 1},
fontWeight: 700
};

// Create a DOM
const rootElement = h('div', {style: divStyle}, [
'Hello, ',
h('span', {style: spanStyle}, ['World!'])
]);

// Layout and paint into the entire canvas (see also renderToCanvasContext)
const canvas = createCanvas(200, 50);
renderToCanvas(rootElement, canvas, /* optional density: */ 2);

// Save your image
canvas.createPNGStream().pipe(fs.createWriteStream(new URL('hello.png', import.meta.url)));

8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,10 @@
"browser": "./dist/src/api-wasm-locator-browser.js",
"default": "./dist/src/api-wasm-locator-node.js"
},
"#register-paint-font": {
"buildtime": "./src/register-paint-font-node.js",
"browser": "./dist/src/register-paint-font-browser.js",
"default": "./dist/src/register-paint-font-node.js"
"#backend": {
"buildtime": "./src/backend-node.js",
"browser": "./dist/src/backend-browser.js",
"default": "./dist/src/backend-node.js"
}
},
"scripts": {
Expand Down
19 changes: 18 additions & 1 deletion src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import CanvasPaintBackend from './paint-canvas.js';
import paintBlockContainer from './paint.js';
import {BlockContainerArea} from './flow.js';
import {id} from './util.js';
import type {CanvasRenderingContext2D} from 'canvas';
import type {Canvas, CanvasRenderingContext2D} from 'canvas';

export {cascadeStyles};

Expand Down Expand Up @@ -80,6 +80,23 @@ export function paintToCanvas(root: BlockContainer, ctx: CanvasRenderingContext2
paintBlockContainer(root, b);
}

export function renderToCanvasContext(
rootElement: HTMLElement,
ctx: CanvasRenderingContext2D,
width: number,
height: number
) {
const root = generate(dom(rootElement));
layout(root, width, height);
paintToCanvas(root, ctx);
}

export function renderToCanvas(rootElement: HTMLElement, canvas: Canvas, density = 1) {
const ctx = canvas.getContext('2d');
ctx.scale(density, density);
renderToCanvasContext(rootElement, ctx, canvas.width, canvas.height);
}

type Node = HTMLElement | TextNode;
type Child = Node | string;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,14 @@ import type {FaceMatch} from './font.js';

declare const document: any;
declare const FontFace: any;
declare const fetch: any;

export default function registerPaintFont(match: FaceMatch, buffer: Uint8Array, filename: string) {
export function registerPaintFont(match: FaceMatch, buffer: Uint8Array, filename: string) {
const descriptor = match.toCssDescriptor();
const face = new FontFace(descriptor.family, buffer, descriptor);
document.fonts.add(face);
}

export async function loadBuffer(path: URL) {
return await fetch(path).then((res: any) => res.arrayBuffer());
}
9 changes: 8 additions & 1 deletion src/register-paint-font-node.ts → src/backend-node.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import type {FaceMatch} from './font.js';
import fs from 'node:fs';
import {fileURLToPath} from 'url';

const alreadyRegistered = new Set<string>();

Expand All @@ -7,10 +9,15 @@ try {
} catch (e) {
}

export default function registerPaintFont(match: FaceMatch, buffer: Uint8Array, filename: string) {
export function registerPaintFont(match: FaceMatch, buffer: Uint8Array, url: URL) {
const filename = fileURLToPath(url);
if (canvas?.registerFont && !alreadyRegistered.has(filename)) {
const descriptor = match.toCssDescriptor();
canvas.registerFont(filename, descriptor);
alreadyRegistered.add(filename);
}
}

export async function loadBuffer(path: URL) {
return fs.readFileSync(path).buffer;
}
57 changes: 38 additions & 19 deletions src/font.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as hb from './harfbuzz.js';
import langCoverage from '../gen/lang-script-coverage.js';
import wasm from './wasm.js';
import {HbSet} from './harfbuzz.js';
import registerPaintFont from '#register-paint-font';
import {registerPaintFont, loadBuffer} from '#backend';

import type {HbBlob, HbFace, HbFont} from './harfbuzz.js';
import type {Style, FontStretch} from './cascade.js';
Expand Down Expand Up @@ -222,47 +222,66 @@ const hbFaces = new Map<string, HbFace>();
const hbFonts = new Map<string, HbFont>();
const faces = new Map<string, FaceMatch>();

export function registerFont(
buffer: Uint8Array,
filename: string,
options = {paint: true}
export async function registerFont(url: URL, options?: {paint: boolean}): Promise<void>;
export async function registerFont(buffer: ArrayBuffer, url: URL, options?: {paint: boolean}): Promise<void>;
export async function registerFont(
arg1: URL | ArrayBuffer,
arg2?: {paint: boolean} | URL,
arg3?: {paint: boolean}
) {
if (!hbBlobs.has(filename)) {
let buffer: Uint8Array;
let url: URL;
let options: {paint: boolean};

if (arg1 instanceof ArrayBuffer) {
buffer = new Uint8Array(arg1);
url = arg2 as any;
options = arg3 || {paint: true};
} else {
url = arg1 as any;
buffer = new Uint8Array(await loadBuffer(url));
options = arg2 as any || {paint: true};
}

const stringUrl = String(url);

if (!hbBlobs.has(stringUrl)) {
const blob = hb.createBlob(buffer);

hbBlobs.set(filename, blob);
hbBlobs.set(stringUrl, blob);

for (let i = 0, l = blob.countFaces(); i < l; ++i) {
const face = hb.createFace(blob, i);
const font = hb.createFont(face);
const match = new FaceMatch(face, font, filename, i);
hbFaces.set(filename + i, face);
hbFonts.set(filename + i, font);
faces.set(filename + i, match);
const match = new FaceMatch(face, font, stringUrl, i);
hbFaces.set(stringUrl + i, face);
hbFonts.set(stringUrl + i, font);
faces.set(stringUrl + i, match);

// Browsers don't support registering collections because there would be
// no way to clearly associate one description with one buffer. I suppose
// this should be enforced elsewhere too...
if (options.paint && i === 0 && l === 1) {
registerPaintFont(match, buffer, filename);
registerPaintFont(match, buffer, url);
}
}
}
}

export function unregisterFont(filename: string) {
const blob = hbBlobs.get(filename);
export function unregisterFont(url: URL) {
const stringUrl = String(url);
const blob = hbBlobs.get(stringUrl);
if (blob) {
for (let i = 0, l = blob.countFaces(); i < l; i++) {
const face = hbFaces.get(filename + i)!;
const font = hbFonts.get(filename + i)!;
const face = hbFaces.get(stringUrl + i)!;
const font = hbFonts.get(stringUrl + i)!;
blob.destroy();
face.destroy();
font.destroy();
hbFaces.delete(filename + i);
faces.delete(filename + i);
hbFaces.delete(stringUrl + i);
faces.delete(stringUrl + i);
}
hbBlobs.delete(filename);
hbBlobs.delete(stringUrl);
}
cascades = new WeakMap();
}
Expand Down

0 comments on commit 4d4d458

Please sign in to comment.