
Trmnl Paper Takumi
- 2 installs
- 4 repo stars
- Updated April 9, 2026
- miantiao-me/trmnl-paper
Render image-first 800x480 TRMNL screens from Takumi-style JSX and Tailwind into a WebP/PNG image, then wrap it in minimal TRMNL markup.
About
This skill renders poster-like 800x480 TRMNL e-ink screens by turning Takumi-style TSX/JSX plus Tailwind layouts into a single image, then wrapping it in minimal TRMNL Blade markup. A developer uses it for pixel-art, screenshot-like, or hero-illustration TRMNL screens rather than component-assembled ones.
- Renders JSX/Tailwind scenes to 800x480 WebP/PNG/JPEG for TRMNL
- Hands off the rendered image to trmnl-paper-screen for pushing
Trmnl Paper Takumi by the numbers
- 2 all-time installs (skills.sh)
- Ranked #1,862 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/miantiao-me/trmnl-paper --skill trmnl-paper-takumiAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2 |
|---|---|
| repo stars | ★ 4 |
| Last updated | April 9, 2026 |
| Repository | miantiao-me/trmnl-paper ↗ |
What it does
Render image-first 800x480 TRMNL screens from Takumi-style JSX and Tailwind into a WebP/PNG image, then wrap it in minimal TRMNL markup.
Files
trmnl-paper-takumi
概览
把 TSX / JSX + Tailwind 风格布局 渲染成 800x480 图片,再包一层最小 TRMNL markup。
默认路线:
1. 写或改一个 Takumi scene(.tsx) 2. 本地渲染成 webp / png / jpeg 3. 把图片放到 LaraPaper 可访问的 URL 4. 用 wrap_image_markup.py 生成最小 Blade markup 5. 必要时交给 trmnl-paper-screen 推送
何时使用
优先用这个 skill:
- 海报式单屏
- 像素风 / pixelated 主视觉
- 截图式、插画式、封面式布局
- 更适合“先渲染成图片,再包进 TRMNL” 的内容
改用 trmnl-paper-blade:
- KPI、列表、表格、rich text、item、table、progress 为主
- 需要直接复用
<x-trmnl::...>组件 - 需要后续长期编辑局部模块,而不是替换整张图
当前 skill 边界
Takumi 上游官方能力比当前 skill 更多,例如原始 HTML、measure()、renderAnimation()、raw frames、ImageResponse、emoji 路线等。
当前 trmnl-paper-takumi 只封装最常用的子集:
.tsxscene → 本地静态图- persistent images(
--image key=path) - 可选 Google Fonts 动态加载
- 外部图片 URL → 最小 TRMNL wrapper
需要更完整的上游边界时,先读 references/takumi-basics.md 与 references/api_reference.md。
工作流
1. 判断是否适合 image-first 路线 2. 选择或新建 scene:优先从 assets/templates/ 复制 3. 运行 scripts/render_scene.tsx 生成图片 4. 肉眼检查构图、灰度、留白、可读性 5. 把图片上传到可访问 URL 6. 运行 scripts/wrap_image_markup.py 生成最小 wrapper 7. 用 trmnl-paper-blade/scripts/validate_markup.py 校验结构 8. 需要推送时,交给 trmnl-paper-screen
生成规则
- 画布默认
800x480 - 根节点始终显式写宽高
- Takumi v1 默认
display: inline;需要容器布局时显式写display: "flex"/tw="flex" - 布局优先
tw;精确像素控制优先style - 像素图放大时显式写
imageRendering: "pixelated" - 尽量用整数尺寸、整数间距
- 默认输出
webp - 只有在 scene 导出
googleFonts或显式传--google-font时,才远程加载 Google Fonts - wrapper 只负责放一张图片,不要在这里重建复杂 TRMNL 结构
可用脚本
scripts/render_scene.tsx:渲染 scene 到本地图片scripts/wrap_image_markup.py:生成最小 TRMNL wrapper
建议阅读顺序
1. references/takumi-basics.md:Takumi 能力边界、字体、图片、Google Fonts 2. references/render-workflow.md:从 scene 到 wrapper 的整条链路 3. references/trmnl-wrapper.md:最小 TRMNL 包装规则 4. references/examples.md:常用命令示例 5. references/api_reference.md:Node API 速查
export const MONO_FONT_STACK = "'IBM Plex Mono', 'Geist Mono', monospace"
export const SANS_FONT_STACK = "'Noto Sans SC', 'Noto Sans Symbols 2', 'Geist', sans-serif"
export const SHOWCASE_GOOGLE_FONTS = ["Noto Sans SC:wght@400;700", "Noto Sans Symbols 2"]
/**
* clock.tsx — Retro monospace clock display for TRMNL e-paper (800×480).
* Fully self-contained: renders current system time at build time.
*/
import React from "react"
import type { RenderOptions } from "@takumi-rs/core"
import { MONO_FONT_STACK } from "./_shared"
export const renderOptions: Partial<RenderOptions> = {
width: 800,
height: 480,
format: "webp",
}
interface Props {
/** Label shown in the header badge. */
label?: string
}
function pad2(n: number): string {
return String(n).padStart(2, "0")
}
function snapshot() {
const d = new Date()
return {
hh: pad2(d.getHours()),
mm: pad2(d.getMinutes()),
ss: pad2(d.getSeconds()),
date: d.toLocaleDateString("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
}),
}
}
export default function ClockScene({ label = "TRMNL" }: Props) {
const { hh, mm, ss, date } = snapshot()
return (
<div
style={{
width: 800,
height: 480,
background: "#ffffff",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
fontFamily: MONO_FONT_STACK,
}}
>
{/* outer frame */}
<div
style={{
width: 720,
border: "4px solid #000000",
padding: "28px 48px 32px",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 18,
}}
>
{/* header badge */}
<div
style={{
fontSize: 12,
letterSpacing: 8,
color: "#000000",
textTransform: "uppercase",
}}
>
{"★ "}
{label}
{" ★"}
</div>
{/* large time display */}
<div style={{ display: "flex", alignItems: "flex-end", gap: 2 }}>
<span
style={{
fontSize: 130,
fontWeight: 700,
lineHeight: 1,
color: "#000000",
}}
>
{hh}
</span>
<span
style={{
fontSize: 110,
fontWeight: 700,
lineHeight: 1.08,
color: "#000000",
}}
>
:
</span>
<span
style={{
fontSize: 130,
fontWeight: 700,
lineHeight: 1,
color: "#000000",
}}
>
{mm}
</span>
<span
style={{
fontSize: 56,
fontWeight: 400,
lineHeight: 1.55,
color: "#888888",
marginLeft: 12,
}}
>
{":"}
{ss}
</span>
</div>
{/* date line */}
<div
style={{
fontSize: 17,
letterSpacing: 3,
color: "#333333",
textTransform: "uppercase",
}}
>
{date}
</div>
{/* decorative pixel row */}
<div style={{ display: "flex", gap: 6 }}>
{Array.from({ length: 28 }, (_, i) => (
<div
key={i}
style={{
width: 6,
height: 6,
background: i % 4 === 0 ? "#000000" : "#cccccc",
}}
/>
))}
</div>
</div>
</div>
)
}
import React from "react"
import type { RenderOptions } from "@takumi-rs/core"
import { SANS_FONT_STACK, SHOWCASE_GOOGLE_FONTS } from "./_shared"
export const renderOptions: Partial<RenderOptions> = {
width: 800,
height: 480,
format: "webp",
}
export const googleFonts = SHOWCASE_GOOGLE_FONTS
interface Props {
title?: string
subtitle?: string
symbols?: string
}
export default function GoogleFontShowcase({
title = "中文字体与符号演示",
subtitle = "天气:晴转多云,气温 24°C,湿度 68%,状态:正常运行",
symbols = "☀ ☁ ☂ ★ ✓ → ↓ ↑ ℃ ¥ § ♫",
}: Props) {
return (
<div
tw="w-[800px] h-[480px] bg-white p-[32px] flex flex-col gap-[22px]"
style={{ fontFamily: SANS_FONT_STACK }}
>
<div tw="border-[4px] border-black rounded-[24px] p-[24px] flex flex-col gap-[14px]">
<div tw="text-[14px] tracking-[6px] uppercase text-[#666666]">Google Fonts</div>
<div tw="text-[38px] leading-[1.2] font-[700] text-black">{title}</div>
<div tw="text-[19px] leading-[1.7] text-[#222222]">{subtitle}</div>
</div>
<div tw="grid grid-cols-[1fr_220px] gap-[20px] flex-1">
<div tw="border-[4px] border-black rounded-[24px] p-[24px] flex flex-col gap-[16px]">
<div tw="text-[14px] uppercase tracking-[4px] text-[#666666]">Mixed glyphs</div>
<div tw="text-[56px] leading-[1.1] font-[700] text-black">数据同步成功 ✓</div>
<div tw="text-[26px] leading-[1.6] text-[#333333]">
今日提醒:09:30 开会 → 12:00 午餐 → 18:30 跑步 5 km
</div>
</div>
<div tw="border-[4px] border-black rounded-[24px] p-[20px] flex flex-col justify-between">
<div tw="text-[14px] uppercase tracking-[4px] text-[#666666]">Symbols</div>
<div tw="text-[28px] leading-[1.8] text-black break-words">{symbols}</div>
<div tw="text-[12px] text-[#666666]">Noto Sans SC + Noto Sans Symbols 2</div>
</div>
</div>
</div>
)
}
import type { RenderOptions } from "@takumi-rs/core"
export const renderOptions: Partial<RenderOptions> = {
width: 800,
height: 480,
format: "webp",
}
export const googleFonts = ["Liu Jian Mao Cao"]
interface Props {
text?: string
signature?: string
}
const PLAQUE_FONT_STACK = "'Liu Jian Mao Cao', 'Noto Sans SC', serif"
export default function LiuJianPlaque({
text = "为人类服务",
signature = "Codex",
}: Props) {
const glyphs = Array.from(text.trim())
const slotWidth = Math.max(90, Math.floor(560 / Math.max(glyphs.length, 1)))
const fontSize = Math.min(150, Math.floor(slotWidth * 1.34))
return (
<div
style={{
width: 800,
height: 480,
background: "#ffffff",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontFamily: PLAQUE_FONT_STACK,
}}
>
<div
style={{
width: 752,
height: 380,
border: "14px solid #000000",
padding: 14,
boxSizing: "border-box",
background: "#ffffff",
}}
>
<div
style={{
width: "100%",
height: "100%",
border: "3px solid #000000",
boxSizing: "border-box",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "24px 32px",
position: "relative",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 10,
transform: "translateY(-8px)",
}}
>
{glyphs.map((glyph, index) => (
<span
key={`${glyph}-${index}`}
style={{
display: "block",
width: slotWidth,
fontSize,
lineHeight: 1,
textAlign: "center",
color: "#000000",
}}
>
{glyph}
</span>
))}
</div>
<div
style={{
position: "absolute",
right: 28,
bottom: 18,
color: "#333333",
fontSize: 18,
lineHeight: 1,
letterSpacing: 1,
fontFamily: "'Geist', sans-serif",
}}
>
{signature}
</div>
</div>
</div>
</div>
)
}
import React from "react"
import type { RenderOptions } from "@takumi-rs/core"
import { MONO_FONT_STACK } from "./_shared"
export const renderOptions: Partial<RenderOptions> = {
width: 800,
height: 480,
format: "webp",
}
interface Props {
title?: string
subtitle?: string
imageKey?: string
footer?: string
}
function Placeholder() {
return (
<div
tw="w-[240px] h-[240px] border-[4px] border-dashed border-black rounded-[24px] flex flex-col items-center justify-center gap-[12px] bg-white"
style={{ fontFamily: MONO_FONT_STACK }}
>
<div tw="text-[16px] tracking-[4px] text-black">NO IMAGE</div>
<div tw="text-[11px] text-[#666666] text-center leading-[1.4] px-[18px]">
pass --image hero=./sprite.png
</div>
</div>
)
}
export default function PixelImageScene({
title = "PIXEL HERO",
subtitle = "attach a local sprite and scale it up cleanly",
imageKey,
footer = "800x480 · WEBP · pixelated",
}: Props) {
const imagePanel = imageKey ? (
<img
src={imageKey}
width={240}
height={240}
style={{
width: 240,
height: 240,
objectFit: "contain",
imageRendering: "pixelated",
}}
/>
) : (
<Placeholder />
)
return (
<div tw="w-[800px] h-[480px] bg-white flex gap-[24px] p-[28px]">
<div tw="flex-1 border-[4px] border-black rounded-[28px] bg-[#f2f2f2] flex items-center justify-center overflow-hidden">
{imagePanel}
</div>
<div
tw="w-[260px] border-[4px] border-black rounded-[28px] flex flex-col justify-between p-[24px]"
style={{ fontFamily: MONO_FONT_STACK }}
>
<div tw="flex flex-col gap-[14px]">
<div tw="text-[12px] tracking-[6px] uppercase text-[#666666]">Takumi scene</div>
<div tw="text-[38px] leading-[1] font-[700] text-black">{title}</div>
<div tw="text-[14px] leading-[1.6] text-[#333333]">{subtitle}</div>
</div>
<div tw="flex flex-col gap-[10px] text-[12px] text-black">
<div tw="flex items-center justify-between border-t-[2px] border-black pt-[12px]">
<span>mode</span>
<span>image-first</span>
</div>
<div tw="flex items-center justify-between">
<span>src</span>
<span>{imageKey ?? "placeholder"}</span>
</div>
<div tw="flex items-center justify-between">
<span>render</span>
<span>pixelated</span>
</div>
<div tw="pt-[8px] text-[#666666]">{footer}</div>
</div>
</div>
</div>
)
}
/**
* pixel-matrix.tsx — Static pixel-art tile pattern for TRMNL e-paper (800×480).
* Completely self-contained: no external data or images required.
*/
import React from "react"
import type { RenderOptions } from "@takumi-rs/core"
import { MONO_FONT_STACK } from "./_shared"
export const renderOptions: Partial<RenderOptions> = {
width: 800,
height: 480,
format: "webp",
}
const COLS = 24
const ROWS = 14
const CELL = 26
const GAP = 4
/**
* Returns a fill level 0–3 for each cell based on its position.
* Creates a repeating diamond / cross motif across the grid.
*/
function cellLevel(r: number, c: number): number {
const rMod = r % 4
const cMod = c % 4
const diag = (rMod + cMod) % 4
if (diag === 0) return 3 // black
if (diag === 2) return 1 // light gray
return 0 // white
}
const FILLS = ["#f5f5f5", "#aaaaaa", "#555555", "#000000"]
export default function PixelMatrixScene() {
return (
<div
style={{
width: 800,
height: 480,
background: "#ffffff",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 24,
fontFamily: MONO_FONT_STACK,
}}
>
{/* pixel grid */}
<div
style={{
display: "flex",
flexDirection: "column",
gap: GAP,
}}
>
{Array.from({ length: ROWS }, (_, r) => (
<div key={r} style={{ display: "flex", gap: GAP }}>
{Array.from({ length: COLS }, (_, c) => (
<div
key={c}
style={{
width: CELL,
height: CELL,
background: FILLS[cellLevel(r, c)],
}}
/>
))}
</div>
))}
</div>
{/* footer label */}
<div
style={{
fontSize: 12,
letterSpacing: 7,
color: "#888888",
textTransform: "uppercase",
}}
>
pixel matrix v1.0
</div>
</div>
)
}
/**
* retro-dashboard.tsx — Terminal-style system monitor for TRMNL e-paper (800×480).
* Ships with default fake metrics; override via --props JSON.
*/
import React from "react"
import type { RenderOptions } from "@takumi-rs/core"
import { MONO_FONT_STACK } from "./_shared"
export const renderOptions: Partial<RenderOptions> = {
width: 800,
height: 480,
format: "webp",
}
export interface Metric {
/** Left-aligned label (padded to align bars). */
label: string
/** Percentage fill, 0–100. */
value: number
/** Human-readable value shown on the right. */
display: string
}
interface Props {
title?: string
metrics?: Metric[]
uptime?: string
version?: string
}
const DEFAULT_METRICS: Metric[] = [
{ label: "CPU ", value: 42, display: " 42%" },
{ label: "MEMORY ", value: 67, display: " 67%" },
{ label: "DISK ", value: 28, display: " 28%" },
{ label: "NETWORK ", value: 81, display: "81 KB/s" },
{ label: "TEMP ", value: 55, display: " 55°C" },
]
function ProgressBar({ value }: { value: number }) {
return (
<div
style={{
flex: 1,
height: 12,
background: "#e8e8e8",
border: "1px solid #000000",
display: "flex",
}}
>
<div
style={{
width: `${Math.min(100, Math.max(0, value))}%`,
height: "100%",
// High values rendered darker for visual emphasis
background: value >= 80 ? "#000000" : "#444444",
}}
/>
</div>
)
}
function MetricRow({ metric }: { metric: Metric }) {
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: 14,
height: 26,
}}
>
<span
style={{
fontSize: 13,
color: "#000000",
width: 90,
flexShrink: 0,
}}
>
{metric.label}
</span>
<ProgressBar value={metric.value} />
<span
style={{
fontSize: 13,
color: "#000000",
width: 70,
textAlign: "right",
flexShrink: 0,
}}
>
{metric.display}
</span>
</div>
)
}
export default function RetroDashboardScene({
title = "SYS MONITOR",
metrics = DEFAULT_METRICS,
uptime = "42d 07h 13m",
version = "v0.1.0",
}: Props) {
const timestamp = new Date().toISOString().slice(0, 19).replace("T", " ")
return (
<div
style={{
width: 800,
height: 480,
background: "#ffffff",
display: "flex",
flexDirection: "column",
fontFamily: MONO_FONT_STACK,
padding: 32,
gap: 0,
}}
>
{/* title bar */}
<div
style={{
background: "#000000",
color: "#ffffff",
padding: "8px 18px",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<span style={{ fontSize: 15, letterSpacing: 4 }}>{"◆ "}{title}</span>
<span style={{ fontSize: 11, color: "#bbbbbb" }}>{timestamp}</span>
</div>
{/* metrics panel */}
<div
style={{
flex: 1,
border: "2px solid #000000",
borderTop: "none",
padding: "20px 24px",
display: "flex",
flexDirection: "column",
gap: 14,
}}
>
{metrics.map((metric) => (
<MetricRow key={metric.label} metric={metric} />
))}
{/* separator */}
<div style={{ height: 1, background: "#cccccc", marginTop: 2 }} />
{/* status footer */}
<div
style={{
display: "flex",
justifyContent: "space-between",
fontSize: 11,
color: "#777777",
}}
>
<span>STATUS: OK</span>
<span>{"UPTIME: "}{uptime}</span>
<span>{version}</span>
</div>
</div>
{/* bottom accent */}
<div style={{ background: "#000000", height: 4 }} />
</div>
)
}
{
"name": "trmnl-paper-takumi",
"version": "0.1.0",
"description": "Local render environment for TRMNL e-paper scenes via @takumi-rs",
"private": true,
"type": "module",
"scripts": {
"render": "tsx scripts/render_scene.tsx"
},
"dependencies": {
"@takumi-rs/core": "^0.73.1",
"@takumi-rs/helpers": "^0.73.1",
"react": "^18.3.1"
},
"devDependencies": {
"@types/node": "^22.15.3",
"@types/react": "^18.3.1",
"tsx": "^4.19.1",
"typescript": "^5.7.0"
}
}
Node API 速查
来源:Takumi 官方文档与 kane50613/takumi 仓库本 skill 的本地运行时采用:
@takumi-rs/core:Node 原生渲染器@takumi-rs/helpers/jsx:把 React JSX 转成 Takumi node tree
官方llms-full.txt主要用takumi-js作为统一文档入口;当前 skill 为了直接控制 Node 运行时,使用底层 npm 包。
最小调用链
import { Renderer } from "@takumi-rs/core"
import { fromJsx } from "@takumi-rs/helpers/jsx"
const renderer = new Renderer()
const { node, stylesheets } = await fromJsx(<Scene />)
const output = await renderer.render(node, {
width: 800,
height: 480,
format: "webp",
stylesheets,
})当前 skill 直接依赖的接口
Renderer
renderer.putPersistentImage(src, data):注册本地图片renderer.render(node, options):输出图片 bufferrenderer.loadFonts(fonts):显式追加自定义字体 / Google Fonts
上游官方还支持但当前 skill 未直接封装:
renderer.measure(node, options)renderer.renderAnimation(...)renderer.encodeFrames(...)
fromJsx
tw属性会被转换成样式表- 返回的
stylesheets需要继续传给renderer.render(...) - React Server Components / Fragments 会先解析到最终值
<img>与<svg>会转成 image node- string / number primitive 会自动变成 text node
- 其他元素会转成 container node
当前 skill 实际使用的 render options
{
width: 800,
height: 480,
format: "webp",
stylesheets,
}补充说明:
format已验证支持webp/png/jpeg- scene 模块可导出
renderOptions,再由 CLI 参数覆盖 - 上游官方还支持
resourcesOptions.cache、fetchedResources、devicePixelRatio、timeMs、keyframes
图片与资源
上游官方说明:
- 外部
src会自动抓取 background-image/mask-image的url()也会抓取persistentImages可在这些位置重复使用
当前 skill 显式封装的是 --image key=path,没有把 resourcesOptions.cache 暴露出来。
字体
Takumi Node 原生运行时默认嵌入 Geist 与 Geist Mono。
Google Fonts、字体栈、子集策略等细节,统一读 takumi-basics.md。
动画、测量、emoji
上游官方支持但当前 skill 未直接封装:
measure():只测布局,不出图renderAnimation():直接输出webp/gif/apngrender(..., { format: "raw", timeMs, keyframes }):用于 ffmpeg 等视频流水线ImageResponse的emoji选项extractEmojis()+fetchedResources的低层 emoji 路线
注意
- 不要假设完整浏览器 DOM / CSS 都可用
- 根节点始终显式写宽高
- Takumi v1 默认
display: inline,需要布局容器时要显式声明display: flex/grid/block等 - 像素图放大时用
style={{ imageRendering: "pixelated" }} - 需要更底层能力时,再回看上游文档或仓库类型定义
常用命令示例
示例 1:完全自包含的像素图案
cd skills/trmnl-paper-takumi
npm install
npm run render -- --scene assets/templates/pixel-matrix.tsx示例 2:本地 sprite 放大为 pixelated 主视觉
cd skills/trmnl-paper-takumi
npm run render -- \
--scene assets/templates/pixel-image.tsx \
--props '{"imageKey":"hero","title":"PIXEL WEATHER","subtitle":"forecast 09:00"}' \
--image hero=./sprite.png示例 3:中文与符号字体
cd skills/trmnl-paper-takumi
npm run render -- --scene assets/templates/google-font-showcase.tsx示例 4:命令行临时追加 Google Fonts
cd skills/trmnl-paper-takumi
npm run render -- \
--scene assets/templates/retro-dashboard.tsx \
--props '{"title":"生产环境 ✓","uptime":"7天 3小时"}' \
--google-font "Noto Sans SC:wght@400;700" \
--google-font "Noto Sans Symbols 2"示例 5:生成 wrapper markup
python3 scripts/wrap_image_markup.py \
--url https://example.com/pixel-weather.webp \
--title "Pixel Weather" \
--fit contain \
--out dist/pixel-weather.markup示例 6:校验后进入推送脚本
python3 ../trmnl-paper-blade/scripts/validate_markup.py dist/pixel-weather.markup
python3 ../trmnl-paper-screen/scripts/push_screen.py \
--base-url https://larapaper.example.com \
--mac-address AA:BB:CC:DD:EE:FF \
--api-key test-key \
--markup-file dist/pixel-weather.markup渲染工作流
1. 判断是否该走图片路线
满足任一情况时,优先用本 skill:
- 画面本质是一张主视觉
- 更像海报、封面、插画,而不是组件拼装
- 用户明确提到 Takumi / JSX-to-image / pixelated
2. 选择 scene 起点
- 完全自包含:先从
clock.tsx/pixel-matrix.tsx开始 - 需要本地 sprite:从
pixel-image.tsx开始 - 需要文本 + 进度感画面:从
retro-dashboard.tsx开始 - 需要验证中文 / 符号:从
google-font-showcase.tsx开始
3. 本地渲染
cd skills/trmnl-paper-takumi
npm install
npm run render -- --scene assets/templates/clock.tsx默认输出:dist/<scene-name>.webp
如果 scene 需要中文 / 符号字体,显式启用 Google Fonts:
npm run render -- --scene assets/templates/google-font-showcase.tsx或:
npm run render -- \
--scene my-scene.tsx \
--google-font "Noto Sans SC:wght@400;700" \
--google-font "Noto Sans Symbols 2"Google Fonts 细节与字体策略,读 takumi-basics.md。
4. 肉眼检查
至少检查:
- 是否确实是 800x480
- 文字是否清晰
- 灰度层级是否过多
- 像素图是否需要
imageRendering: "pixelated" - 留白、边距、对齐是否合理
5. 上传图片
wrap_image_markup.py 需要的是 可访问 URL,不是本地文件路径。
如果目标是通过 LaraPaper 推送到远端实例,先把 webp/png 上传到:
- 你自己的静态资源服务
- 对外可访问的对象存储
- LaraPaper / 应用自身可访问的静态路径
6. 生成最小 TRMNL wrapper
python3 scripts/wrap_image_markup.py \
--url https://example.com/render.webp \
--title "Takumi Demo" \
--out dist/takumi-demo.markup7. 校验 markup
python3 ../trmnl-paper-blade/scripts/validate_markup.py dist/takumi-demo.markup8. 交给推送 skill
python3 ../trmnl-paper-screen/scripts/push_screen.py \
--base-url https://larapaper.example.com \
--mac-address AA:BB:CC:DD:EE:FF \
--api-key test-key \
--markup-file dist/takumi-demo.markup默认先 dry-run;只有用户明确要求时才真正发送。
Takumi 基础约束
适用场景
这个 skill 走的是 image-first 路线:先把 scene 渲染成一张图,再包进 TRMNL。
适合:
- 像素风主视觉
- 海报式单屏
- 一张图占主要版面
- 截图式或插画式布局
不适合:
- 以
item/table/richtext/progress为主的结构化屏幕
上游官方支持范围
llms-full.txt 明确说明,上游 Takumi 支持:
- 输入:JSX、原始 HTML、node tree
- 布局:Flexbox、CSS Grid、block、float、
calc()、absolute、z-index - 文本:WOFF / WOFF2、emoji、RTL、multi-span inline blocks
- 样式:复杂选择器、CSS variables、
@keyframes、gradients、box-shadow、filter、backdrop-filter、mix-blend-mode、transform - Tailwind:v4,含 arbitrary values
- SVG:inline 或 external SVG
- 输出:PNG / JPEG / WebP,动图 WebP / GIF / APNG,raw RGBA frames
当前 trmnl-paper-takumi 没有把这些能力全部封装成 CLI。
当前 skill 暴露的子集
当前 render_scene.tsx 主要封装:
fromJsx(...)Renderer.render(...)Renderer.putPersistentImage(...)Renderer.loadFonts(...)
未直接封装但上游官方支持:
measure()renderAnimation()encodeFrames()- 原始 HTML 输入
ImageResponse路线resourcesOptions.cacheemoji选项与extractEmojis()路线
画布与布局
- 默认尺寸:
800x480 - 根节点始终显式声明宽高
- 优先使用整数尺寸、整数间距
- Takumi v1 默认
display: inline;容器布局要显式写flex/grid/block
示例:
<div tw="flex items-center justify-center" />字体
Takumi Node 原生运行时默认嵌入:
GeistGeist Mono
因此纯英文、纯默认字体场景不需要额外加载字体。
Google Fonts 动态加载
当前 skill 支持两条入口:
- CLI:
--google-font - scene:
export const googleFonts = [...]
示例:
npm run render -- \
--scene assets/templates/google-font-showcase.tsx \
--google-font "Noto Sans SC:wght@400;700" \
--google-font "Noto Sans Symbols 2"export const googleFonts = ["Noto Sans SC:wght@400;700", "Noto Sans Symbols 2"]默认行为:
1. 只有声明了 googleFonts / --google-font 时,才会远程加载 Google Fonts 2. 自动遍历 scene 的 text node 3. 自动提取字符并去重 4. 作为 text= 传给 Google Fonts CSS2 API 5. 只下载当前 scene 真正需要的字形子集
需要额外补字时:
- CLI:
--google-font-text "中文✓☀→" - scene:
export const googleFontText = "中文✓☀→"
这是当前 skill 的实现策略;Takumi 官方文档只要求“显式加载字体”,并未提供 Google Fonts 专用 API。
推荐字体栈:
- monospace:
'IBM Plex Mono', 'Geist Mono', monospace - sans:
'Noto Sans SC', 'Noto Sans Symbols 2', 'Geist', sans-serif
上游性能建议还提到:TTF 比 WOFF2 更快。当前 skill 选择 Google Fonts + WOFF2,是便捷与按需远程加载优先,不是性能最优解。
图片
外部 URL
Takumi 可以直接抓取外部图片 URL。
本地图片
优先使用 persistent image:
npm run render -- \
--scene assets/templates/pixel-image.tsx \
--props '{"imageKey":"hero"}' \
--image hero=./sprite.pngscene 内:
<img src="hero" style={{ imageRendering: "pixelated" }} />上游官方还支持:
- 外部
src自动抓取 background-image/mask-image自动抓取persistent image key在src/background-image/mask-image中复用
当前 skill 没有暴露 resourcesOptions.cache。
输出
- 默认:
webp - 备选:
png jpeg仅在用户明确要求时再用
上游官方还支持动画输出(webp/gif/apng)与raw帧;当前 skill CLI 先不封装。
运行时包选择
Takumi 官方文档的统一入口名是 takumi-js@1。
当前 skill 为了直接控制 Node 本地渲染,直接使用:
@takumi-rs/core@takumi-rs/helpers/jsx
这属于实现选型,不代表上游只有这两种包名。
TRMNL 包装规则
最小结构
<x-trmnl::screen>
<x-trmnl::view>
<x-trmnl::layout>
<img class="image w--full h--full image--contain" src="https://..." alt="">
</x-trmnl::layout>
<x-trmnl::title-bar title="..." />
</x-trmnl::view>
</x-trmnl::screen>规则
screen > view > layout不能乱改title-bar必须是layout的兄弟节点- 图片使用原生
<img class="image ..."> - 不要发明不存在的
<x-trmnl::image>组件
fit 选择
contain:默认;完整保留图片cover:允许裁剪,适合图片本来就要铺满fill:允许拉伸,通常最后再考虑
dither 选择
默认先 不要 加 image-dither。
更适合开启 image-dither 的情况:
- 图片仍然是连续灰阶照片
- 你希望让 TRMNL 的 1-bit 风格更明显
不建议默认开启的情况:
- Takumi scene 本身已经做成像素风
- 已经手工控制成黑 / 白 / 灰块面
验证
python3 ../trmnl-paper-blade/scripts/validate_markup.py dist/example.markup#!/usr/bin/env tsx
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
import { dirname, extname, resolve } from "node:path"
import { pathToFileURL } from "node:url"
import { parseArgs } from "node:util"
import React from "react"
import { Renderer } from "@takumi-rs/core"
import type { Font, Node, OutputFormat, RenderOptions } from "@takumi-rs/core"
import { fromJsx } from "@takumi-rs/helpers/jsx"
// Defaults for TRMNL OG device (800×480 e-paper)
const DEFAULT_WIDTH = 800
const DEFAULT_HEIGHT = 480
const DEFAULT_FORMAT: OutputFormat = "webp"
const GOOGLE_FONTS_API_URL = "https://fonts.googleapis.com/css2"
// Google Fonts returns browser-specific CSS, so keep a desktop browser UA here.
const GOOGLE_FONTS_UA =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36"
// Scene module shape: default export + optional render hints
interface SceneModule {
default: React.ComponentType<Record<string, unknown>> | React.ReactElement
renderOptions?: Partial<RenderOptions>
googleFonts?: string[]
googleFontText?: string
}
class InputError extends Error {}
function exitInputError(message: string): never {
throw new InputError(message)
}
function parseCliArgs() {
const { values } = parseArgs({
args: process.argv.slice(2),
options: {
scene: { type: "string" },
out: { type: "string" },
width: { type: "string" },
height: { type: "string" },
format: { type: "string" },
props: { type: "string" },
// key=localPath pairs; may be repeated
image: { type: "string", multiple: true },
"google-font": { type: "string", multiple: true },
"google-font-text": { type: "string" },
},
strict: true,
allowPositionals: false,
})
if (!values.scene) {
exitInputError("--scene <path> 为必填项")
}
return values as typeof values & { scene: string }
}
function parsePositiveInteger(name: string, value: string | undefined, fallback: number): number {
if (value === undefined) {
return fallback
}
const parsed = Number.parseInt(value, 10)
if (!Number.isFinite(parsed) || parsed <= 0) {
exitInputError(`--${name} 必须是正整数`)
}
return parsed
}
function parseProps(raw: string | undefined): Record<string, unknown> {
if (!raw) {
return {}
}
try {
const parsed = JSON.parse(raw)
if (parsed === null || Array.isArray(parsed) || typeof parsed !== "object") {
exitInputError("--props 必须是 JSON 对象字符串")
}
return parsed as Record<string, unknown>
} catch (error) {
const reason = error instanceof Error ? error.message : String(error)
exitInputError(`--props 不是合法 JSON:${reason}`)
}
}
function uniqueStrings(values: string[]): string[] {
const result: string[] = []
const seen = new Set<string>()
for (const value of values) {
const trimmed = value.trim()
if (!trimmed || seen.has(trimmed)) {
continue
}
seen.add(trimmed)
result.push(trimmed)
}
return result
}
function uniqueCharacters(text: string): string {
const normalized = text.replace(/\s+/g, " ")
const seen = new Set<string>()
let result = ""
for (const char of normalized) {
if (seen.has(char)) {
continue
}
seen.add(char)
result += char
}
return result.trim()
}
function collectNodeText(node: Node): string {
if (node.type === "text") {
return node.text
}
if (node.type === "container") {
return (node.children ?? []).map((child) => collectNodeText(child)).join("")
}
return ""
}
function extractGoogleFontFamilyName(spec: string): string {
const separatorIndex = spec.indexOf(":")
if (separatorIndex === -1) {
return spec.trim()
}
return spec.slice(0, separatorIndex).trim()
}
function buildGoogleFontsCssUrl(spec: string, subsetText: string): string {
const familyQuery = spec.trim().replace(/\s+/g, "+")
let url = `${GOOGLE_FONTS_API_URL}?family=${familyQuery}&display=swap`
if (subsetText) {
url += `&text=${encodeURIComponent(subsetText)}`
}
return url
}
function parseFontWeight(value: string | undefined): number | undefined {
if (!value) {
return undefined
}
const trimmed = value.trim()
if (/^\d+$/.test(trimmed)) {
return Number.parseInt(trimmed, 10)
}
if (trimmed === "normal") {
return 400
}
if (trimmed === "bold") {
return 700
}
return undefined
}
interface RemoteFontSource {
name: string
url: string
style?: string
weight?: number
}
async function fetchText(url: string): Promise<string> {
const response = await fetch(url, {
headers: {
"user-agent": GOOGLE_FONTS_UA,
},
})
if (!response.ok) {
throw new Error(`请求失败:${response.status} ${response.statusText} (${url})`)
}
return response.text()
}
async function fetchBinary(url: string): Promise<ArrayBuffer> {
const response = await fetch(url, {
headers: {
"user-agent": GOOGLE_FONTS_UA,
},
})
if (!response.ok) {
throw new Error(`下载失败:${response.status} ${response.statusText} (${url})`)
}
return response.arrayBuffer()
}
function parseGoogleFontsCss(spec: string, css: string): RemoteFontSource[] {
const familyName = extractGoogleFontFamilyName(spec)
const blocks = css.match(/@font-face\s*\{[^}]*\}/gms) ?? []
const fonts: RemoteFontSource[] = []
for (const block of blocks) {
const urlMatch = block.match(/src:\s*[^;]*url\(([^)]+)\)[^;]*;/m)
if (!urlMatch) {
continue
}
const styleMatch = block.match(/font-style:\s*([^;]+);/m)
const weightMatch = block.match(/font-weight:\s*([^;]+);/m)
const url = urlMatch[1].trim().replace(/^['"]|['"]$/g, "")
const style = styleMatch?.[1]?.trim()
const weight = parseFontWeight(weightMatch?.[1])
fonts.push({
name: familyName,
url,
style,
weight,
})
}
return fonts
}
async function loadGoogleFonts(
renderer: Renderer,
specs: string[],
subsetText: string,
): Promise<void> {
const remoteFonts: Font[] = []
for (const spec of specs) {
const cssUrl = buildGoogleFontsCssUrl(spec, subsetText)
const css = await fetchText(cssUrl)
const parsedFonts = parseGoogleFontsCss(spec, css)
if (parsedFonts.length === 0) {
throw new Error(`Google Fonts 未返回可用字体文件:${spec}`)
}
for (const font of parsedFonts) {
const data = await fetchBinary(font.url)
remoteFonts.push({
name: font.name,
data,
style: font.style,
weight: font.weight,
})
}
}
if (remoteFonts.length > 0) {
await renderer.loadFonts(remoteFonts)
console.log(` 已加载 Google Fonts:${specs.join(", ")}`)
}
}
function resolveFormat(
outPath?: string,
formatArg?: string,
sceneFormat?: OutputFormat,
): OutputFormat {
// Extension on --out path takes precedence over --format
if (outPath) {
const ext = extname(outPath).toLowerCase().slice(1)
if (ext === "webp" || ext === "png") return ext
if (ext === "jpg" || ext === "jpeg") return "jpeg"
}
if (formatArg) {
if (formatArg === "webp" || formatArg === "png") return formatArg
if (formatArg === "jpg" || formatArg === "jpeg") return "jpeg"
console.error(`警告:未知格式 "${formatArg}",已回退到 webp`)
}
if (sceneFormat) {
return sceneFormat
}
return DEFAULT_FORMAT
}
function resolveOutputPath(
scenePath: string,
outArg: string | undefined,
format: OutputFormat,
): string {
if (outArg) return resolve(outArg)
// Strip extension, take basename, place under dist/
const stem = scenePath.replace(/\.[^./\\]+$/, "").split(/[\\/]/).pop()!
const ext = format === "jpeg" ? "jpg" : format
return resolve("dist", `${stem}.${ext}`)
}
async function loadPersistentImages(
renderer: Renderer,
imageArgs: string[],
): Promise<void> {
for (const entry of imageArgs) {
const eqIdx = entry.indexOf("=")
if (eqIdx === -1) {
console.error(`警告:--image 格式应为 "key=path",已跳过:${entry}`)
continue
}
const key = entry.slice(0, eqIdx)
const imgPath = resolve(entry.slice(eqIdx + 1))
if (!existsSync(imgPath)) {
console.error(`警告:图片文件不存在,已跳过:${imgPath}`)
continue
}
const data = readFileSync(imgPath)
await renderer.putPersistentImage(key, data)
console.log(` 已注册持久图片:${key} → ${imgPath}`)
}
}
async function main(): Promise<void> {
const args = parseCliArgs()
const scenePath = resolve(args.scene)
if (!existsSync(scenePath)) {
exitInputError(`找不到 scene 文件:${scenePath}`)
}
// Dynamic import with tsx interop (tsx patches Node's module loader)
const sceneModule = (await import(
pathToFileURL(scenePath).href
)) as SceneModule
const SceneDefault = sceneModule.default
if (!SceneDefault) {
exitInputError("scene 模块缺少默认导出(应为 React 组件函数或 React 元素)")
}
// CLI args override scene's own renderOptions
const sceneOpts: Partial<RenderOptions> = sceneModule.renderOptions ?? {}
const format = resolveFormat(args.out, args.format, sceneOpts.format)
const width = parsePositiveInteger("width", args.width, sceneOpts.width ?? DEFAULT_WIDTH)
const height = parsePositiveInteger(
"height",
args.height,
sceneOpts.height ?? DEFAULT_HEIGHT,
)
const renderOptions: RenderOptions = { ...sceneOpts, width, height, format }
// Build React element: component receives --props JSON, element is used as-is
const parsedProps = parseProps(args.props)
let element: React.ReactElement
if (typeof SceneDefault === "function") {
element = React.createElement(
SceneDefault as React.ComponentType<Record<string, unknown>>,
parsedProps,
)
} else {
if (Object.keys(parsedProps).length > 0) {
console.warn("警告:scene 默认导出是 React 元素,--props 将被忽略。")
}
element = SceneDefault as React.ReactElement
}
// Set up renderer and register persistent images (--image key=path)
const renderer = new Renderer()
const imageArgs = (args.image as string[] | undefined) ?? []
if (imageArgs.length > 0) await loadPersistentImages(renderer, imageArgs)
// Convert JSX → Takumi node tree; fromJsx also emits Tailwind stylesheets
const { node, stylesheets } = await fromJsx(element)
if (stylesheets.length > 0) {
renderOptions.stylesheets = [
...(renderOptions.stylesheets ?? []),
...stylesheets,
]
}
const googleFontSpecs = uniqueStrings([
...(sceneModule.googleFonts ?? []),
...((args["google-font"] as string[] | undefined) ?? []),
])
if (googleFontSpecs.length > 0) {
const subsetText = uniqueCharacters(
[collectNodeText(node), sceneModule.googleFontText ?? "", args["google-font-text"] ?? ""].join(
"",
),
)
await loadGoogleFonts(renderer, googleFontSpecs, subsetText)
}
const buffer = await renderer.render(node, renderOptions)
const outPath = resolveOutputPath(args.scene, args.out, format)
mkdirSync(dirname(outPath), { recursive: true })
writeFileSync(outPath, buffer)
console.log(`✓ 渲染完成:${outPath} (${width}×${height}, ${format})`)
}
main().catch((err: unknown) => {
if (err instanceof InputError) {
console.error(`错误:${err.message}`)
process.exit(2)
}
console.error("渲染失败:", err instanceof Error ? err.message : err)
process.exit(1)
})
import "react"
declare module "react" {
interface HTMLAttributes<T> {
tw?: string
}
}
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import html
import sys
from pathlib import Path
_SCREEN_OPEN = "<x-trmnl::screen>"
_SCREEN_CLOSE = "</x-trmnl::screen>"
_VIEW_OPEN = " <x-trmnl::view>"
_VIEW_CLOSE = " </x-trmnl::view>"
_LAYOUT_OPEN = " <x-trmnl::layout>"
_LAYOUT_CLOSE = " </x-trmnl::layout>"
def _attr(value: str) -> str:
"""Escape a string for safe use inside an HTML double-quoted attribute."""
return html.escape(value, quote=True)
def _image_classes(fit: str, dither: bool) -> str:
classes = ["image", "w--full", "h--full"]
if dither:
classes.append("image-dither")
classes.append(f"image--{fit}")
return " ".join(classes)
def build_title_bar(title: str, instance: str | None) -> str:
if instance:
return (
f" <x-trmnl::title-bar"
f' title="{_attr(title)}"'
f' instance="{_attr(instance)}" />'
)
return f' <x-trmnl::title-bar title="{_attr(title)}" />'
def build_markup(
url: str,
alt: str,
title: str | None,
instance: str | None,
fit: str,
dither: bool,
) -> str:
img_class = _image_classes(fit, dither)
img_tag = f' <img class="{img_class}" src="{_attr(url)}" alt="{_attr(alt)}">'
lines = [
_SCREEN_OPEN,
_VIEW_OPEN,
_LAYOUT_OPEN,
img_tag,
_LAYOUT_CLOSE,
]
if title:
lines.append(build_title_bar(title, instance))
lines += [_VIEW_CLOSE, _SCREEN_CLOSE, ""]
return "\n".join(lines)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="将外部图片 URL 包装成最小 TRMNL Blade markup。",
epilog=(
"结构规则:\n"
" screen > view > layout\n"
" title-bar 是 layout 的兄弟节点(非子节点)\n"
' 图片使用框架原生 <img class="image w--full h--full image--{fit}"> 元素\n\n'
"退出码:\n"
" 0 成功\n"
" 1 运行时错误(写入失败)\n"
" 2 参数错误(URL 为空等)"
),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"--url",
required=True,
help="要嵌入的图片 URL(必填)。",
)
parser.add_argument(
"--alt",
default="",
help="图片 alt 文字(可选,默认为空)。",
)
parser.add_argument(
"--title",
help="title-bar 标题(省略则不生成 title-bar)。",
)
parser.add_argument(
"--instance",
help="title-bar 副标题 / 实例名(需配合 --title 使用)。",
)
parser.add_argument(
"--fit",
choices=["cover", "contain", "fill"],
default="contain",
help="图片适配方式:contain(留白缩入,默认)/ cover(裁剪填满)/ fill(拉伸填满)。",
)
parser.add_argument(
"--dither",
action="store_true",
help="添加 image-dither class,适用于 1-bit 黑白电子墨水屏。",
)
parser.add_argument(
"--out",
help="输出文件路径(省略则输出到 stdout)。",
)
return parser.parse_args()
def main() -> int:
try:
args = parse_args()
url = args.url.strip()
if not url:
raise ValueError("--url 不能为空")
if args.instance and not args.title:
raise ValueError("--instance 需要与 --title 一起使用")
markup = build_markup(
url,
args.alt,
args.title,
args.instance,
args.fit,
args.dither,
)
if args.out:
out_path = Path(args.out)
try:
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(markup, encoding="utf-8")
except OSError as error:
raise RuntimeError(f"写入文件失败:{out_path} ({error})") from error
print(f"已写入:{out_path}")
else:
print(markup, end="")
return 0
except ValueError as error:
print(str(error), file=sys.stderr)
return 2
except RuntimeError as error:
print(str(error), file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"types": ["node", "react"],
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"isolatedModules": true
},
"include": [
"*.d.ts",
"scripts/**/*.ts",
"scripts/**/*.tsx",
"assets/**/*.ts",
"assets/**/*.tsx"
]
}