Skip to content

Commit 7bf7610

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

9 files changed

Lines changed: 269 additions & 6 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: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -547,6 +547,112 @@ 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+
export 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+
* Since dropflow internally depends on WASM using top-level await, if you
571+
* want to change the location, you need to do it before importing dropflow.
572+
* To do that, import {environment} from 'dropflow/environment.js';
573+
*
574+
* Many package managers only guarantee the order of imports relative to other
575+
* imports, so you should usually call this in a separate module imported
576+
* before dropflow. See the README for an example.
577+
*/
578+
wasmLocator(): Promise<Uint8Array>;
579+
/**
580+
* This will get called when a font in flow.fonts transitions to loaded or
581+
* when an already loaded font is added to flow.fonts. It's intended to be
582+
* used to add the font to the underlying paint target.
583+
*
584+
* Use `face.getBuffer` if the backend supports font buffers. You can use the
585+
* url property to access the file if it doesn't (node-canvas v2). The font
586+
* will be selected via `face.uniqueFamily` and nothing else.
587+
*
588+
* You can return an unregister function which will be called when the font
589+
* is no longer needed by dropflow (eg user called `flow.fonts.delete`).
590+
*/
591+
registerFont(face: LoadedFontFace): (() => void) | void;
592+
/**
593+
* Must return a promise of a buffer for the given URL. This used for fonts
594+
* and will be used for images.
595+
*/
596+
resolveUrl(url: URL): Promise<ArrayBufferLike>
597+
/**
598+
* Same as `resolveUrl`, but synchronous if it's a file:// URL. This should
599+
* throw if URL is not a file:// URL, which would mean the user called
600+
* loadSync on a document with asynchronous-only URLs.
601+
*/
602+
resolveUrlSync(url: URL): ArrayBufferLike;
603+
}
604+
```
605+
606+
### Using `@napi-rs/canvas`
607+
608+
```ts
609+
import {GlobalFonts} from '@napi-rs/canvas';
610+
import * as flow from 'dropflow';
611+
612+
// Configure @napi-rs/canvas
613+
flow.environment.registerFont = face => {
614+
const key = GlobalFonts.register(face.getBuffer(), face.uniqueFamily);
615+
if (key) return () => GlobalFonts.remove(key);
616+
};
617+
```
618+
619+
### Using `skia-canvas`
620+
621+
```ts
622+
import {FontLibrary} from 'skia-canvas';
623+
import * as flow from 'dropflow';
624+
625+
// Configure skia-canvas
626+
flow.environment.registerFont = face => {
627+
FontLibrary.use(face.uniqueFamily, fileURLToPath(face.url));
628+
};
629+
```
630+
631+
### Overriding the WASM location
632+
633+
```ts
634+
// dropflow.config.js
635+
//
636+
// This should usually go in its own file that is imported before dropflow,
637+
// because dropflow's own modules use top-level await to retrieve dropflow.wasm.
638+
// (Bundlers guarantee import order but only relative to other imports)
639+
import {environment} from 'dropflow/environment.js';
640+
// This will be the path to wasm if you're using Bun. Vite (others?) need ?url
641+
import wasmUrl from 'dropflow/dropflow.wasm';
642+
// or you can get it some other way
643+
// const wasmUrl = 'the/path/to/dropflow.wasm';
644+
645+
environment.wasmLocator = function () {
646+
return fetch(wasmUrl).then(res => {
647+
if (res.status === 200) {
648+
return res.arrayBuffer()
649+
} else {
650+
throw new Error(res.statusText);
651+
}
652+
});
653+
};
654+
```
655+
550656
## Other
551657

552658
### `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'));

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: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,43 @@
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+
*
9+
* Since dropflow internally depends on WASM using top-level await, if you
10+
* want to change the location, you need to do it before importing dropflow.
11+
* To do that, import {environment} from 'dropflow/environment.js';
12+
*
13+
* Many package managers only guarantee the order of imports relative to other
14+
* imports, so you should usually call this in a separate module imported
15+
* before dropflow. See the README for an example.
16+
*/
417
wasmLocator(): Promise<Uint8Array>;
18+
/**
19+
* This will get called when a font in flow.fonts transitions to loaded or
20+
* when an already loaded font is added to flow.fonts. It's intended to be
21+
* used to add the font to the underlying paint target.
22+
*
23+
* Use `face.getBuffer` if the backend supports font buffers. You can use the
24+
* url property to access the file if it doesn't (node-canvas v2). The font
25+
* will be selected via `face.uniqueFamily` and nothing else.
26+
*
27+
* You can return an unregister function which will be called when the font
28+
* is no longer needed by dropflow (eg user called `flow.fonts.delete`).
29+
*/
530
registerFont(face: LoadedFontFace): (() => void) | void;
31+
/**
32+
* Must return a promise of a buffer for the given URL. This used for fonts
33+
* and will be used for images.
34+
*/
635
resolveUrl(url: URL): Promise<ArrayBufferLike>
36+
/**
37+
* Same as `resolveUrl`, but synchronous if it's a file:// URL. This should
38+
* throw if URL is not a file:// URL, which would mean the user called
39+
* loadSync on a document with asynchronous-only URLs.
40+
*/
741
resolveUrlSync(url: URL): ArrayBufferLike;
842
}
943

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)