SkillAgentSearch skills...

Sone

Declarative Canvas layout engine for JavaScript with advanced rich text support.

Install / Use

/learn @seanghay/Sone
About this skill

Quality Score

0/100

Supported Platforms

Universal

README

<img src="test/image/sone.svg" width=28>

Sone — A declarative Canvas layout engine for JavaScript with advanced rich text support.

Tests install size

Why Sone.js?

  • Declarative API
  • Flex Layout & CSS Grid
  • Multi-Page PDF — automatic page breaking, repeating headers & footers, margins
  • Rich Text — spans, justification, tab stops, tab leaders, text orientation (0°/90°/180°/270°)
  • Syntax Highlighting — via sone/shiki (Shiki integration)
  • Lists, Tables, Photos, SVG Paths, QR Codes
  • Squircle, ClipGroup
  • Custom font loading — any language or script
  • Output as SVG, PDF, PNG, JPG, WebP
  • Fully Typed
  • Metadata API (Text Recognition Dataset Generation)
  • All features from skia-canvas

<img width=720 height=720 src="https://github.com/user-attachments/assets/9a5bce63-33ca-4086-873a-a552b147f99a" alt="">

Overview

npm install sone
import { Column, Span, sone, Text } from "sone";

function Document() {
  return Column(
    Text("Hello, ", Span("World").color("blue").weight("bold"))
      .size(44)
      .color("black"),
  )
    .padding(24)
    .bg("white");
}

// save as buffer
const buffer = await sone(Document()).jpg();

// save to file
import fs from "node:fs/promises";
await fs.writeFile("image.jpg", buffer);

More examples can be found in the test/visual directory.


Syntax Highlighting

Install Shiki as a peer dependency, then import from sone/shiki:

npm install shiki
import { Column, sone } from "sone";
import { createSoneHighlighter } from "sone/shiki";

// Pre-load themes and languages once
const highlight = await createSoneHighlighter({
  themes: ["github-dark"],
  langs: ["typescript", "javascript", "bash"],
});

// Code() returns a ColumnNode — compose it like any other node
const doc = Column(
  highlight.Code(`const greet = (name: string) => \`Hello, \${name}!\``, {
    lang: "typescript",
    theme: "github-dark",
    fontSize: 13,
    fontFamily: ["monospace"],
    lineHeight: 1.6,
  }),
).padding(24).bg("white");

await sone(doc).pdf();

CodeOptions:

| Option | Type | Default | Description | |---|---|---|---| | lang | BundledLanguage | — | Shiki language identifier. | | theme | BundledTheme | first loaded theme | Shiki theme. | | fontSize | number | 12 | Font size in pixels. | | fontFamily | string[] | ["monospace"] | Font families in priority order. | | lineHeight | number | inherited | Line height multiplier. | | paddingX | number | 12 | Horizontal padding inside the block. | | paddingY | number | 8 | Vertical padding inside the block. |


Multi-Page PDF

Pass pageHeight to enable automatic page breaking. Headers and footers repeat on every page; use a function to access per-page info.

import { Column, Row, Text, Span, sone } from "sone";

const header = Row(Text("My Report").size(10)).padding(8, 16);

const footer = ({ pageNumber, totalPages }) =>
  Row(Text(Span(`${pageNumber}`).weight("bold"), ` / ${totalPages}`).size(10))
    .padding(8, 16)
    .justifyContent("flex-end");

const content = Column(
  Text("Section 1").size(24).weight("bold"),
  Text("Lorem ipsum...").size(12).lineHeight(1.6),
  // PageBreak() forces a new page at any point
).gap(12);

const pdf = await sone(content, {
  pageHeight: 1056,          // Letter height @ 96 dpi
  header,
  footer,
  margin: { top: 16, bottom: 16 },
  lastPageHeight: "content", // trim last page to actual content
}).pdf();

Tab Stops

Align columns without a Table node using \t and .tabStops().

Text("Name\tAmount\tDate")
  .tabStops(200, 320)
  .font("GeistMono")
  .size(12)

Add .tabLeader(char) to fill the tab gap with a repeated character — dot leader (.) is the classic MS Word table-of-contents style, but any character works.

// Table of contents — dot leader
Text("Introduction\t1")
  .tabStops(360)
  .tabLeader(".")
  .size(13)

// Financial report — dash leader
Text("Revenue\t$1,200,000")
  .tabStops(300)
  .tabLeader("-")
  .size(13)

Text Orientation

Rotate text 0°/90°/180°/270°. At 90° and 270° the layout footprint swaps width and height so surrounding elements flow naturally.

Text("Rotated").size(16).orientation(90)

Lists

Use built-in markers or pass a Span for full typographic control. Supports nested lists.

import { List, ListItem, Span, Text } from "sone";

// Built-in disc marker
List(
  ListItem(Text("Automatic page breaking").size(12)),
  ListItem(Text("Repeating headers & footers").size(12)),
).listStyle("disc").markerGap(10).gap(8)

// Custom Span marker
List(
  ListItem(Text("Tab stops").size(12)),
  ListItem(Text("Text orientation").size(12)),
).listStyle(Span("→").color("black").weight("bold")).markerGap(10).gap(8)

// Numbered list (startIndex sets the starting number)
List(
  ListItem(Text("npm install sone").size(12)),
  ListItem(Text("Compose your node tree").size(12)),
  ListItem(Text("sone(root).pdf()").size(12)),
).listStyle(Span("{}.").color("black").weight("bold")).startIndex(1).gap(8)

// Dynamic arrow function marker — index is 0-based, full Span styling available
const labels = ["①", "②", "③"]
List(
  ListItem(Text("Install dependencies").size(12)),
  ListItem(Text("Configure the environment").size(12)),
  ListItem(Text("Run the build").size(12)),
).listStyle((index) => Span(labels[index]).color("royalblue").weight("bold")).gap(8)

Font Registration

import { Font } from 'sone';

await Font.load("NotoSansKhmer", "test/font/NotoSansKhmer.ttf");

// Load a specific weight variant
await Font.load("GeistMono", ["/path/to/GeistMono-Bold.ttf"], { weight: "bold" });

Font.has("NotoSansKhmer") // → boolean

Next.js

To make it work with Next.js, update your config file:

import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  serverExternalPackages: ["skia-canvas"],
  webpack: (config, options) => {
    if (options.isServer) {
      config.externals = [
        ...config.externals,
        { "skia-canvas": "commonjs skia-canvas" },
      ];
    }
    return config;
  },
};

export default nextConfig;

Philosophy

Inspired by Flutter and SwiftUI, Sone lets you focus on designing instead of calculating positions manually. Describe your layout as a tree of composable nodes — Column, Row, Text, Photo — and Sone figures out where everything goes.

Built for real-world document generation: invoices, letters, open graph images, reports, resumes, and anything that needs to look good at scale.

Just JavaScript, no preprocessors. Sone does not use JSX or HTML. JSX requires a build step and transpiler config. HTML requires a full CSS parser — and any missing feature becomes a confusing gap for users. Sone's API is plain function calls that work anywhere JavaScript runs, with no setup beyond npm install.

Flexbox for layout. Powered by yoga-layout — the same engine behind React Native. If you know CSS flexbox, you already know Sone's layout model.

Rich text as a first-class citizen. Mixed-style spans, justification, tab stops, decorations, drop shadows, and per-glyph gradients — all within a single Text() node.

Pages are just layout. pageHeight slices the same node tree into pages. Headers, footers, and page breaks are ordinary nodes. No special mode, no different API.

Performance. No browser, no Puppeteer, no CDP. Rendering goes directly through skia-canvas — a native Skia binding for Node.js. Images render in single-digit milliseconds, multi-page PDFs in tens of milliseconds.


API Reference

sone(node, config?)

The main render function. Returns an object with export methods.

sone(node: SoneNode, config?: SoneRenderConfig)
  .pdf()           // → Promise<Buffer>
  .png()           // → Promise<Buffer>
  .jpg(quality?)   // → Promise<Buffer>  quality: 0.0–1.0
  .svg()           // → Promise<Buffer>
  .webp()          // → Promise<Buffer>
  .raw()           // → Promise<Buffer>
  .canvas()        // → Promise<Canvas>
  .pages()         // → Promise<Canvas[]>  one per page

SoneRenderConfig

| Option | Type | Description | |---|---|---| | width | number | Exact canvas width. When set, margins inset content within it. | | height | number | Canvas height (auto-sized if omitted). | | background | string | Canvas background color. | | pageHeight | number | Enables multi-page output. Each page is this many pixels tall. | | header | SoneNode \| (info) => SoneNode | Repeating header on every page. | | footer | SoneNode \| (info) => SoneNode | Repeating footer on every page. | | margin | number \| { top, right, bottom, left } | Page margins in pixels. | | lastPageHeight | "uniform" \| "content" | "content" trims the last page to its actual height. Default "uniform". | | cache | Map | Image cache for repeated renders. |

SonePageInfo — passed to dynamic header/footer functions:

{ pageNumber: number, totalPages: number }

Column(...children) / Row(...children)

Flex layout containers. Column stacks children vertically, Row horizontally.

Layout methods — available on all node types:

| Method | Description | |---|---| | width(v) / height(v) | Fixed dimensions. | | minWidth(v) / maxWidth(v) | Size constra

Related Skills

View on GitHub
GitHub Stars85
CategoryCustomer
Updated23m ago
Forks3

Languages

TypeScript

Security Score

100/100

Audited on Apr 3, 2026

No findings