Skip to content
Merged
Show file tree
Hide file tree
Changes from 26 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/clean-corners-remain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@takumi-rs/helpers": minor
---

Add `extractResourceUrls` in pure JS to avoid extra roundtrip to native bindings
6 changes: 6 additions & 0 deletions .changeset/nasty-guests-rush.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@takumi-rs/core": patch
"@takumi-rs/wasm": patch
---

Replaced native `extractResourceUrls` with JS version to avoid roundtrip
5 changes: 5 additions & 0 deletions .changeset/nasty-mails-buy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"takumi": minor
---

Rework on internal rendering pipeline to be performant
4 changes: 2 additions & 2 deletions docs/app/playground/worker.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { fetchResources } from "takumi-js/helpers";
import { fetchResources, extractResourceUrls } from "takumi-js/helpers";
import { extractEmojis } from "takumi-js/helpers/emoji";
import { fromJsx } from "takumi-js/helpers/jsx";
import wasm, { extractResourceUrls, init, Renderer } from "takumi-js/wasm";
import wasm, { init, Renderer } from "takumi-js/wasm";
import * as React from "react";
import { transform } from "sucrase";
import * as z from "zod/mini";
Expand Down
14 changes: 6 additions & 8 deletions docs/content/docs/load-images.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,19 @@ icon: Image

By default, Takumi will fetch external images in `src` attributes and CSS properties like `background-image` and `mask-image`.

If you want to add additional caching layer, you can fetch the image yourself and pass the data to `render()` with `fetchedResources`.
If you want to add additional caching layer, you can pass `resourcesOptions.cache` to `render` function.

```tsx twoslash
import { fromJsx } from "takumi-js/helpers/jsx";
import { extractResourceUrls, render } from "takumi-js";
import { fetchResources } from "takumi-js/helpers";
import { render } from "takumi-js";

const element = <img src="https://example.com/image.png" />;

// [!code ++:2]
const urls = await extractResourceUrls(element);
const fetchedResources = await fetchResources(urls);
const cache = new Map<string, ArrayBuffer>();

const image = await render(element, {
fetchedResources, // [!code ++]
resourcesOptions: {
cache,
},
});
```

Expand Down
4 changes: 2 additions & 2 deletions docs/content/docs/typography-and-fonts.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,8 @@ Under the hood it calls `extractEmojis` helper function, which separates the emo
```tsx twoslash
import { extractEmojis } from "takumi-js/helpers/emoji";
import { fromJsx } from "takumi-js/helpers/jsx";
import { fetchResources } from "takumi-js/helpers";
import { Renderer, extractResourceUrls } from "takumi-js/node";
import { extractResourceUrls, fetchResources } from "takumi-js/helpers";
import { Renderer } from "takumi-js/node";

let { node } = await fromJsx(<div tw="flex justify-center items-center text-3xl">Hello 👋😁</div>);
node = extractEmojis(node, "twemoji");
Expand Down
55 changes: 55 additions & 0 deletions takumi-helpers/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,59 @@
import type { CSSProperties } from "react";
import type { Node } from "./types";

const defaultTimeout = 5000;
const cssUrlPattern = /url\(\s*(['"]?)(.*?)\1\s*\)/g;

function isFetchableResourceUrl(value: string): boolean {
return value.startsWith("https://") || value.startsWith("http://");
}

function collectCssUrls(value: unknown, urls: Set<string>) {
if (typeof value === "string") {
for (const match of value.matchAll(cssUrlPattern)) {
const url = match[2]?.trim();
if (url && isFetchableResourceUrl(url)) {
urls.add(url);
}
}
} else if (Array.isArray(value)) {
for (const item of value) {
collectCssUrls(item, urls);
}
}
}

export function extractResourceUrls(node: Node): string[] {
const urls = new Set<string>();

const visit = (current: Node) => {
const collectStyleUrls = (style: CSSProperties | undefined) => {
if (!style) {
return;
}

collectCssUrls(style.backgroundImage, urls);
collectCssUrls(style.maskImage, urls);
};

collectStyleUrls(current.style);
collectStyleUrls(current.preset);

if (current.type === "image" && isFetchableResourceUrl(current.src)) {
urls.add(current.src);
return;
}

if (current.type === "container") {
for (const child of current.children ?? []) {
visit(child);
}
}
};

visit(node);
return [...urls];
}

export type FetchResourcesOptions = {
/**
Expand Down
54 changes: 54 additions & 0 deletions takumi-helpers/test/utils/extract-resource-urls.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, expect, test } from "bun:test";
import { container, image } from "../../src/helpers";
import { extractResourceUrls } from "../../src/utils";

describe("extractResourceUrls", () => {
test("extracts remote image and style urls without duplicates", () => {
const remoteImageUrl = "https://example.com/image.png";
const backgroundUrl = "https://example.com/background.png";
const maskUrl = "https://example.com/mask.png";
const node = container({
children: [
image({
src: remoteImageUrl,
width: 100,
height: 100,
}),
image({
src: "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' />",
width: 100,
height: 100,
}),
container({
style: {
backgroundImage: `linear-gradient(#fff, #000), url("${backgroundUrl}")`,
maskImage: `url(${maskUrl}) url(${backgroundUrl})`,
},
}),
],
});

expect(extractResourceUrls(node)).toEqual([remoteImageUrl, backgroundUrl, maskUrl]);
});

test("ignores malformed css url values", () => {
const node = container({
style: {
backgroundImage: "url(https://example.com/good.png) url(",
},
});

expect(extractResourceUrls(node)).toEqual(["https://example.com/good.png"]);
});

test("ignores non-fetchable css url values", () => {
const validUrl = "https://example.com/background.png";
const node = container({
style: {
backgroundImage: `url(background), url("${validUrl}")`,
},
});

expect(extractResourceUrls(node)).toEqual([validUrl]);
});
});
2 changes: 0 additions & 2 deletions takumi-js/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ export * from "./render";

export type { RenderOptions } from "./render";

export { extractResourceUrls } from "./render";

declare module "react" {
interface DOMAttributes<T> {
tw?: string;
Expand Down
20 changes: 9 additions & 11 deletions takumi-js/src/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,20 @@ import { fromJsx, type FromJsxOptions } from "@takumi-rs/helpers/jsx";
import { loadRendererResources, type ManagedRendererOptions } from "./renderer";
import { getImports } from "./import";
import type { ReactNode } from "react";
import { fetchResources, type ReactElementLike } from "@takumi-rs/helpers";
import {
extractResourceUrls,
fetchResources,
type FetchResourcesOptions,
type ReactElementLike,
} from "@takumi-rs/helpers";

type InnerRenderOptions = napi.RenderOptions | wasm.RenderOptions;

type RenderOptionsWithRenderer = InnerRenderOptions & {
renderer: napi.Renderer | wasm.Renderer;
signal?: AbortSignal;
jsx?: FromJsxOptions;
resourcesOptions?: FetchResourcesOptions;
/**
* @description The emoji provider to use when rendering emojis. If set to `"from-font"`, the renderer will attempt to source emoji glyphs from the loaded fonts.
* @default "twemoji"
Expand Down Expand Up @@ -45,9 +51,8 @@ export async function render(element: ReactNode | ReactElementLike, options?: Re

const node = emojiType !== "from-font" ? extractEmojis(originalNode, emojiType) : originalNode;
const fetchedResources =
options?.fetchedResources !== undefined
? options.fetchedResources
: await fetchResources(imports.extractResourceUrls(node));
options?.fetchedResources ??
(await fetchResources(extractResourceUrls(node), options?.resourcesOptions));

const renderOptions = {
...options,
Expand All @@ -57,10 +62,3 @@ export async function render(element: ReactNode | ReactElementLike, options?: Re

return renderer.render(node, renderOptions, options?.signal);
}

export async function extractResourceUrls(element: ReactNode | ReactElementLike) {
const imports = await getImports();
const { node } = await fromJsx(element);

return imports.extractResourceUrls(node);
}
2 changes: 1 addition & 1 deletion takumi-napi-core/src/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { Font, FontDetails, ImageSource } from "../index.d.ts";
export type * from "../index.d.ts";
import { Renderer as NativeRenderer } from "../index.js";

export { extractResourceUrls } from "../index.js";
export { extractResourceUrls } from "@takumi-rs/helpers";

export type ImageSourceLoader = Omit<ImageSource, "data"> & {
data: ImageSource["data"] | (() => Promise<ImageSource["data"]> | ImageSource["data"]);
Expand Down
12 changes: 0 additions & 12 deletions takumi-napi-core/src/helper.rs

This file was deleted.

2 changes: 0 additions & 2 deletions takumi-napi-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
)]

mod encode_frames_task;
mod helper;
mod load_font_task;
mod measure_task;
mod put_persistent_image_task;
Expand All @@ -28,7 +27,6 @@ use takumi::{
resources::font::FontResource,
};

pub use helper::*;
pub use renderer::Renderer;

#[derive(Deserialize, Default)]
Expand Down
31 changes: 1 addition & 30 deletions takumi-napi-core/tests/render.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { readFile } from "node:fs/promises";
import { container, image, text } from "@takumi-rs/helpers";
import { fromJsx } from "@takumi-rs/helpers/jsx";
import { Glob } from "bun";
import { extractResourceUrls, Renderer, type RenderOptions } from "../src/export";
import { Renderer, type RenderOptions } from "../src/export";

const glob = new Glob("../assets/fonts/**/*.{woff2,ttf}");
const files = await Array.fromAsync(glob.scan());
Expand Down Expand Up @@ -117,35 +117,6 @@ describe("setup", () => {
});
});

describe("extractResourceUrls", () => {
test("extractResourceUrls", () => {
const tasks = extractResourceUrls(node);
expect(tasks).toEqual([remoteUrl]);
});

test("extracts nested backgroundImage URLs", () => {
const nestedBackgroundUrl = "https://placehold.co/80x80/22c55e/white";
const nestedNode = container({
children: [
container({
style: {
backgroundImage: `url(${nestedBackgroundUrl})`,
width: 80,
height: 80,
},
}),
],
style: {
width: 100,
height: 100,
},
});

const tasks = extractResourceUrls(nestedNode);
expect(tasks).toEqual([nestedBackgroundUrl]);
});
});

describe("render", () => {
const options: RenderOptions = {
width: 1200,
Expand Down
1 change: 1 addition & 0 deletions takumi-wasm/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"dist",
"bundlers"
],
"type": "module",
"main": "./dist/export.cjs",
"module": "./dist/export.mjs",
"types": "./dist/export.d.mts",
Expand Down
2 changes: 2 additions & 0 deletions takumi-wasm/src/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
export * from "../pkg/takumi_wasm";
export { default } from "../pkg/takumi_wasm";

export { extractResourceUrls } from "@takumi-rs/helpers";

export type ImageSourceLoader = Omit<ImageSource, "data"> & {
data: ImageSource["data"] | (() => Promise<ImageSource["data"]> | ImageSource["data"]);
};
Expand Down
11 changes: 0 additions & 11 deletions takumi-wasm/src/helper.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
//! Helper functions and utilities for the WebAssembly bindings.

use crate::model::NodeType;
use serde_wasm_bindgen::from_value;
use std::fmt::Display;
use takumi::layout::node::Node;
use wasm_bindgen::prelude::*;

/// Maps any error to a JavaScript Error object.
pub fn map_error<E: Display>(err: E) -> js_sys::Error {
Expand All @@ -13,10 +9,3 @@ pub fn map_error<E: Display>(err: E) -> js_sys::Error {

/// Type alias for JavaScript result.
pub type JsResult<T> = Result<T, js_sys::Error>;

/// Collects the fetch task urls from the node.
#[wasm_bindgen(js_name = extractResourceUrls)]
pub fn extract_resource_urls(node: NodeType) -> JsResult<Vec<String>> {
let node: Node = from_value(node.into()).map_err(map_error)?;
Ok(node.resource_urls().map(str::to_owned).collect())
}
4 changes: 4 additions & 0 deletions takumi/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -117,3 +117,7 @@ harness = false
[[bench]]
name = "gradient"
harness = false

[[bench]]
name = "fixtures"
harness = false
3 changes: 2 additions & 1 deletion takumi/benches/effects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ fn run_effect_render(global: &GlobalContext, effect_tw: &str) {
.global(global)
.build();

let _image = render(options).unwrap();
let image = render(options).unwrap();
black_box(image);
}

fn bench_effects(c: &mut Criterion) {
Expand Down
Loading