Skip to content

Commit f33e526

Browse files
committed
add support for @napi-rs/canvas, skia-canvas
1 parent 49de941 commit f33e526

10 files changed

Lines changed: 252 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ project adheres to [Semantic Versioning](http://semver.org/).
2222
* Added `flow.FontFace`, `flow.fonts`, `flow.createFaceFromTables` (see **Changed** above)
2323
* Added `unicodeRange` to `FontFaceDescriptors`
2424
* Added `flow.load` for loading all fonts needed by a document
25+
* Added support for `@napi-rs/canvas` and `skia-canvas` via environments (see examples)
26+
* Exposed environment hooks so that dropflow's behavior can be customized (see updated README).
2527

2628
### Fixed
2729
* RTL text-align issue in the SVG painter and base direction issue in the canvas painter (#27)

README.md

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -547,6 +547,102 @@ class BoxArea {
547547
}
548548
```
549549

550+
## Environments
551+
552+
Dropflow is designed to support a flexible configuration of environments. In the browser, it loads fonts (soon, images too) via `fetch` and registers font buffers to `document.fonts`. In Nodejs, dropflow can load fonts synchronously via `fs.readFileSync`.
553+
554+
When you use the canvas backend with dropflow and `node-canvas` is present, it will call node-canvas's `registerFont`. Node-canvas doesn't support font buffers, so you have to use file:// URLs.
555+
556+
If you want to use `@napi-rs/canvas` or `skia-canvas`, you'll need just a few lines of code to wire `flow.environment.registerFont` to the appropriate font registration APIs.
557+
558+
### Hooks
559+
560+
There are 4 hooks you can override, documented below. They have default implementations based on whether dropflow was built for the browser or node ("browser" export condition or "default") but may not be sufficient for your use case.
561+
562+
```ts
563+
const environment: Environment;
564+
565+
interface Environment {
566+
/**
567+
* Must return a promise of a Uint8Array of dropflow.wasm. Typically this
568+
* just does a fetch() or fs.readFile.
569+
*/
570+
wasmLocator(): Promise<Uint8Array>;
571+
/**
572+
* This will get called when a font in flow.fonts transitions to loaded or
573+
* when an already loaded font is added to flow.fonts. It's intended to be
574+
* used to add the font to the underlying paint target.
575+
*
576+
* Use `face.getBuffer` if the backend supports font buffers. You can use the
577+
* url property to access the file if it doesn't (node-canvas v2). The font
578+
* will be selected via `face.uniqueFamily` and nothing else.
579+
*
580+
* You can return an unregister function which will be called when the font
581+
* is no longer needed by dropflow (eg user called `flow.fonts.delete`).
582+
*/
583+
registerFont(face: LoadedFontFace): (() => void) | void;
584+
/**
585+
* Must return a promise of a buffer for the given URL. This used for fonts
586+
* and will be used for images.
587+
*/
588+
resolveUrl(url: URL): Promise<ArrayBufferLike>
589+
/**
590+
* Same as `resolveUrl`, but synchronous if it's a file:// URL. This should
591+
* throw if URL is not a file:// URL, which would mean the user called
592+
* loadSync on a document with asynchronous-only URLs.
593+
*/
594+
resolveUrlSync(url: URL): ArrayBufferLike;
595+
}
596+
```
597+
598+
### Using `@napi-rs/canvas`
599+
600+
```ts
601+
import {GlobalFonts} from '@napi-rs/canvas';
602+
import * as flow from 'dropflow';
603+
604+
// Configure @napi-rs/canvas
605+
flow.environment.registerFont = face => {
606+
const key = GlobalFonts.register(face.getBuffer(), face.uniqueFamily);
607+
if (key) return () => GlobalFonts.remove(key);
608+
};
609+
```
610+
611+
### Using `skia-canvas`
612+
613+
```ts
614+
import {FontLibrary} from 'skia-canvas';
615+
import * as flow from 'dropflow';
616+
617+
// Configure skia-canvas
618+
flow.environment.registerFont = face => {
619+
FontLibrary.use(face.uniqueFamily, fileURLToPath(face.url));
620+
};
621+
```
622+
623+
### Overriding the WASM location
624+
625+
```ts
626+
// This should usually go in a config.js file that is imported before dropflow,
627+
// because dropflow's own modules use top-level await to retrieve dropflow.wasm.
628+
// (Bundlers guarantee import order but only relative to other imports)
629+
import * as flow from 'dropflow';
630+
// This will be the path to wasm if you're using Bun. Vite (others?) need ?url
631+
import wasmUrl from 'dropflow/dropflow.wasm';
632+
// or you can get it some other way
633+
// const wasmUrl = 'the/path/to/dropflow.wasm';
634+
635+
flow.environment.wasmLocator = function () {
636+
return fetch(wasmUrl).then(res => {
637+
if (res.status === 200) {
638+
return res.arrayBuffer()
639+
} else {
640+
throw new Error(res.statusText);
641+
}
642+
});
643+
};
644+
```
645+
550646
## Other
551647

552648
### `staticLayoutContribution`

examples/napi-rs-canvas.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// Note if you're running this in the dropflow repo, you'll have to npm install
2+
// @napi-rs/canvas and un-exclude this file from tsconfig.json. @napi-rs/canvas
3+
// leaks ambient types: https://github.com/Brooooooklyn/canvas/issues/659
4+
import * as flow from 'dropflow';
5+
import fs from 'fs';
6+
import {createCanvas, GlobalFonts} from '@napi-rs/canvas';
7+
8+
// Configure @napi-rs/canvas
9+
flow.environment.registerFont = face => {
10+
const key = GlobalFonts.register(face.getBuffer(), face.uniqueFamily);
11+
if (key) return () => GlobalFonts.remove(key);
12+
};
13+
14+
// Register fonts
15+
const p = (p: string) => new URL(`../assets/${p}`, import.meta.url);
16+
flow.fonts.add(flow.createFaceFromTablesSync(p('Cousine/Cousine-Regular.ttf')));
17+
flow.fonts.add(flow.createFaceFromTablesSync(p('Arimo/Arimo-Regular.ttf')));
18+
19+
// Create styles
20+
const zoom = 3;
21+
22+
const rootStyle = flow.style({
23+
paddingTop: 10,
24+
paddingRight: 10,
25+
paddingBottom: 10,
26+
paddingLeft: 10,
27+
backgroundColor: {r: 0x33, g: 0x55, b: 0x66, a: 1},
28+
lineHeight: {value: 2, unit: null},
29+
zoom,
30+
color: {r: 0xee, g: 0xee, b: 0xee, a: 1}
31+
});
32+
33+
const spanStyle = flow.style({
34+
fontFamily: ['Cousine'],
35+
color: {r: 0x33, g: 0x33, b: 0x33, a: 1},
36+
backgroundColor: {r: 0xaa, g: 0xaa, b: 0xaa, a: 1},
37+
borderBottomWidth: 2,
38+
borderBottomStyle: 'solid'
39+
});
40+
41+
// Create the document!
42+
const rootElement = flow.dom(
43+
flow.h('html', {style: rootStyle, attrs: {'x-dropflow-log': 'true'}}, [
44+
flow.h('div', ['Hello ', flow.h('span', {style: spanStyle}, '@napi-rs/canvas'), '!'])
45+
])
46+
);
47+
48+
// Normal layout, logging
49+
flow.loadSync(rootElement);
50+
const blockContainer = flow.generate(rootElement);
51+
blockContainer.log();
52+
flow.layout(blockContainer, 200 * zoom, 100 * zoom);
53+
const canvas = createCanvas(200 * zoom, 100 * zoom);
54+
const ctx = canvas.getContext('2d');
55+
flow.paintToCanvas(blockContainer, ctx);
56+
57+
fs.writeFileSync(new URL('napi-rs-canvas.png', import.meta.url), await canvas.encode('png'));

examples/skia-canvas.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// Note if you're running this in the dropflow repo, you'll have to npm install
2+
// skia-canvas and un-exclude this file from tsconfig.json. skia-canvas refers
3+
// to ambient types: https://github.com/samizdatco/skia-canvas/pull/220
4+
import * as flow from 'dropflow';
5+
import fs from 'fs';
6+
import {fileURLToPath} from 'url';
7+
import {Canvas, FontLibrary} from 'skia-canvas';
8+
9+
// Configure skia-canvas
10+
flow.environment.registerFont = face => {
11+
FontLibrary.use(face.uniqueFamily, fileURLToPath(face.url));
12+
};
13+
14+
// Register fonts
15+
const p = (p: string) => new URL(`../assets/${p}`, import.meta.url);
16+
flow.fonts.add(flow.createFaceFromTablesSync(p('Cousine/Cousine-Regular.ttf')));
17+
flow.fonts.add(flow.createFaceFromTablesSync(p('Arimo/Arimo-Regular.ttf')));
18+
19+
// Create styles
20+
const zoom = 3;
21+
22+
const rootStyle = flow.style({
23+
paddingTop: 10,
24+
paddingRight: 10,
25+
paddingBottom: 10,
26+
paddingLeft: 10,
27+
backgroundColor: {r: 0x33, g: 0x55, b: 0x66, a: 1},
28+
lineHeight: {value: 2, unit: null},
29+
zoom,
30+
color: {r: 0xee, g: 0xee, b: 0xee, a: 1}
31+
});
32+
33+
const spanStyle = flow.style({
34+
fontFamily: ['Cousine'],
35+
color: {r: 0x33, g: 0x33, b: 0x33, a: 1},
36+
backgroundColor: {r: 0xaa, g: 0xaa, b: 0xaa, a: 1},
37+
borderBottomWidth: 2,
38+
borderBottomStyle: 'solid'
39+
});
40+
41+
// Create the document!
42+
const rootElement = flow.dom(
43+
flow.h('html', {style: rootStyle, attrs: {'x-dropflow-log': 'true'}}, [
44+
flow.h('div', ['Hello ', flow.h('span', {style: spanStyle}, 'skia-canvas'), '!'])
45+
])
46+
);
47+
48+
// Normal layout, logging
49+
flow.loadSync(rootElement);
50+
const blockContainer = flow.generate(rootElement);
51+
blockContainer.log();
52+
flow.layout(blockContainer, 200 * zoom, 100 * zoom);
53+
const canvas = new Canvas(200 * zoom, 100 * zoom);
54+
const ctx = canvas.getContext('2d');
55+
flow.paintToCanvas(blockContainer, ctx);
56+
57+
fs.writeFileSync(new URL('skia-canvas.png', import.meta.url), canvas.toBufferSync('png'));

site/config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {environment} from 'dropflow/environment.js';
1+
import * as flow from 'dropflow';
22
import wasmUrl from 'dropflow/dropflow.wasm';
33

44
environment.wasmLocator = function () {

src/api.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import paint from './paint.js';
1010
import {BoxArea, prelayout, postlayout} from './layout-box.js';
1111
import {id} from './util.js';
1212

13+
export {environment} from './environment.js';
14+
1315
export type {BlockContainer, DeclaredStyle};
1416

1517
export type {HTMLElement};

src/environment.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,35 @@
11
import type {LoadedFontFace} from './text-font.js';
22

3+
// !!! NOTE !!! if you change anything below, change the readme too
34
export interface Environment {
5+
/**
6+
* Must return a promise of a Uint8Array of dropflow.wasm. Typically this
7+
* just does a fetch() or fs.readFile.
8+
*/
49
wasmLocator(): Promise<Uint8Array>;
10+
/**
11+
* This will get called when a font in flow.fonts transitions to loaded or
12+
* when an already loaded font is added to flow.fonts. It's intended to be
13+
* used to add the font to the underlying paint target.
14+
*
15+
* Use `face.getBuffer` if the backend supports font buffers. You can use the
16+
* url property to access the file if it doesn't (node-canvas v2). The font
17+
* will be selected via `face.uniqueFamily` and nothing else.
18+
*
19+
* You can return an unregister function which will be called when the font
20+
* is no longer needed by dropflow (eg user called `flow.fonts.delete`).
21+
*/
522
registerFont(face: LoadedFontFace): (() => void) | void;
23+
/**
24+
* Must return a promise of a buffer for the given URL. This used for fonts
25+
* and will be used for images.
26+
*/
627
resolveUrl(url: URL): Promise<ArrayBufferLike>
28+
/**
29+
* Same as `resolveUrl`, but synchronous if it's a file:// URL. This should
30+
* throw if URL is not a file:// URL, which would mean the user called
31+
* loadSync on a document with asynchronous-only URLs.
32+
*/
733
resolveUrlSync(url: URL): ArrayBufferLike;
834
}
935

test/ci.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import './text.spec.js';
1212
import './font.spec.js';
1313
import './itemize.spec.js';
1414
import './paint.spec.js';
15-
import {environment} from '../src/environment.js';
15+
import {environment} from 'dropflow';
1616

1717
// Tests don't make calls to node-canvas; that's what the mock paint class is for
1818
environment.registerFont = () => {};

test/font.spec.js

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import registerNotoFonts from 'dropflow/register-noto-fonts.js';
77
import {registerFontAsset, unregisterFontAsset} from '../assets/register.js';
88
import {getLangCascade, fonts, FontFace, createFaceFromTablesSync, loadFonts} from '../src/text-font.js';
99
import {getOriginStyle, createStyle, createDeclaredStyle} from '../src/style.js';
10-
import {environment} from '../src/environment.js';
1110

1211
/** @param {import("../src/style.js").DeclaredStyleProperties} style */
1312
function style(style) {
@@ -351,7 +350,7 @@ describe('Fonts', function () {
351350

352351
it('calls environment.registerFont when a loaded font is added', function () {
353352
let path;
354-
mock.method(environment, 'registerFont', face => path = face.url.href);
353+
mock.method(flow.environment, 'registerFont', face => path = face.url.href);
355354
const f = new FontFace('f', url('Roboto/Roboto-Italic.ttf'));
356355
expect(path).to.be.undefined;
357356
f.loadSync();
@@ -362,7 +361,7 @@ describe('Fonts', function () {
362361

363362
it('calls environment.registerFont when an added font is loaded', function () {
364363
let path;
365-
mock.method(environment, 'registerFont', face => path = face.url.href);
364+
mock.method(flow.environment, 'registerFont', face => path = face.url.href);
366365
const f = new FontFace('f', url('Roboto/Roboto-Italic.ttf'));
367366
expect(path).to.be.undefined;
368367
fonts.add(f);
@@ -374,7 +373,7 @@ describe('Fonts', function () {
374373
it('calls the unregistration function when a font is removed', function () {
375374
let unregisterCalled = false;
376375
const unregister = () => unregisterCalled = true;
377-
mock.method(environment, 'registerFont', () => unregister);
376+
mock.method(flow.environment, 'registerFont', () => unregister);
378377
const f = new FontFace('f', url('Roboto/Roboto-Italic.ttf'));
379378
fonts.add(f);
380379
expect(unregisterCalled).to.be.false;

tsconfig.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
{
22
"include": ["*.ts", "src/**/*", "*.d.ts", "examples/*.ts", "assets/register.ts"],
3-
"exclude": ["node_modules"],
3+
"exclude": [
4+
"node_modules",
5+
// https://github.com/Brooooooklyn/canvas/issues/659
6+
"examples/napi-rs-canvas.ts",
7+
// https://github.com/samizdatco/skia-canvas/pull/220
8+
"examples/skia-canvas.ts"
9+
],
410
"compilerOptions": {
511
"rootDir": ".",
612
"outDir": "dist",

0 commit comments

Comments
 (0)