feat: integrate typst.ts for browser-native SVG generation

- Install @myriaddreamin/typst.ts package for WebAssembly Typst rendering
- Create server-side API endpoints for template loading and SVG generation
- Implement TypstSoroban React component with error handling and loading states
- Add test page for verifying typst.ts integration
- Configure webpack for WASM support and resolve browser compatibility

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-09-14 21:57:46 -05:00
parent 5c3231c170
commit c703a3e027
11 changed files with 1459 additions and 0 deletions

View File

@@ -2,8 +2,33 @@
const nextConfig = {
experimental: {
optimizePackageImports: ['@soroban/core', '@soroban/client'],
serverComponentsExternalPackages: ['@myriaddreamin/typst.ts'],
},
transpilePackages: ['@soroban/core', '@soroban/client'],
webpack: (config, { isServer }) => {
config.experiments = {
...config.experiments,
asyncWebAssembly: true,
layers: true,
}
// Fix for WASM modules
config.module.rules.push({
test: /\.wasm$/,
type: 'asset/resource',
})
// Handle typst.ts WASM files specifically
if (!isServer) {
config.resolve.fallback = {
...config.resolve.fallback,
fs: false,
path: false,
}
}
return config
},
}
module.exports = nextConfig

View File

@@ -11,6 +11,7 @@
"clean": "rm -rf .next"
},
"dependencies": {
"@myriaddreamin/typst.ts": "0.6.1-rc3",
"@pandacss/dev": "^0.20.0",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-checkbox": "^1.0.4",

View File

@@ -0,0 +1,136 @@
import { NextRequest, NextResponse } from 'next/server'
import { $typst } from '@myriaddreamin/typst.ts/dist/esm/contrib/snippet.mjs'
import fs from 'fs'
import path from 'path'
export interface TypstSVGRequest {
number: number
beadShape?: 'diamond' | 'circle' | 'square'
colorScheme?: 'monochrome' | 'place-value' | 'heaven-earth' | 'alternating'
colorPalette?: 'default' | 'colorblind' | 'mnemonic' | 'grayscale' | 'nature'
hideInactiveBeads?: boolean
showEmptyColumns?: boolean
columns?: number | 'auto'
scaleFactor?: number
width?: string
height?: string
fontSize?: string
fontFamily?: string
transparent?: boolean
}
// Cache for template content
let flashcardsTemplate: string | null = null
async function getFlashcardsTemplate(): Promise<string> {
if (flashcardsTemplate) {
return flashcardsTemplate
}
try {
const templatesDir = path.join(process.cwd(), '../../packages/core/templates')
flashcardsTemplate = fs.readFileSync(path.join(templatesDir, 'flashcards.typ'), 'utf-8')
return flashcardsTemplate
} catch (error) {
console.error('Failed to load flashcards template:', error)
throw new Error('Template loading failed')
}
}
function createTypstContent(config: TypstSVGRequest, template: string): string {
const {
number,
beadShape = 'diamond',
colorScheme = 'place-value',
colorPalette = 'default',
hideInactiveBeads = false,
showEmptyColumns = false,
columns = 'auto',
scaleFactor = 1.0,
width = '120pt',
height = '160pt',
fontSize = '48pt',
fontFamily = 'DejaVu Sans',
transparent = false
} = config
return `
${template}
#set page(
width: ${width},
height: ${height},
margin: 0pt,
fill: ${transparent ? 'none' : 'white'}
)
#set text(font: "${fontFamily}", size: ${fontSize}, fallback: true)
#align(center + horizon)[
#box(
width: ${width} - 2 * (${width} * 0.05),
height: ${height} - 2 * (${height} * 0.05)
)[
#align(center + horizon)[
#scale(x: ${scaleFactor * 100}%, y: ${scaleFactor * 100}%)[
#draw-soroban(
${number},
columns: ${columns},
show-empty: ${showEmptyColumns},
hide-inactive: ${hideInactiveBeads},
bead-shape: "${beadShape}",
color-scheme: "${colorScheme}",
color-palette: "${colorPalette}",
base-size: 1.0
)
]
]
]
]
`
}
export async function POST(request: NextRequest) {
try {
const config: TypstSVGRequest = await request.json()
console.log('🎨 Generating typst.ts SVG for number:', config.number)
// Load template
const template = await getFlashcardsTemplate()
// Create typst content
const typstContent = createTypstContent(config, template)
// Generate SVG using typst.ts
const svg = await $typst.svg({ mainContent: typstContent })
console.log('✅ Generated typst.ts SVG, length:', svg.length)
return NextResponse.json({
svg,
success: true,
number: config.number
})
} catch (error) {
console.error('❌ Typst SVG generation failed:', error)
return NextResponse.json(
{
error: error instanceof Error ? error.message : 'Unknown error',
success: false
},
{ status: 500 }
)
}
}
// Health check
export async function GET() {
return NextResponse.json({
status: 'healthy',
endpoint: 'typst-svg',
message: 'Typst.ts SVG generation API is running'
})
}

View File

@@ -0,0 +1,25 @@
import { NextResponse } from 'next/server'
import fs from 'fs'
import path from 'path'
// API endpoint to serve the flashcards.typ template content
export async function GET() {
try {
const templatesDir = path.join(process.cwd(), '../../packages/core/templates')
const flashcardsTemplate = fs.readFileSync(path.join(templatesDir, 'flashcards.typ'), 'utf-8')
return NextResponse.json({
template: flashcardsTemplate,
success: true
})
} catch (error) {
console.error('Failed to load typst template:', error)
return NextResponse.json(
{
error: 'Failed to load template',
success: false
},
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,248 @@
'use client'
import { useState } from 'react'
import { TypstSoroban } from '@/components/TypstSoroban'
import { css } from '../../../styled-system/css'
import { container, stack, grid, hstack } from '../../../styled-system/patterns'
export default function TestTypstPage() {
const [selectedNumber, setSelectedNumber] = useState(23)
const [generationCount, setGenerationCount] = useState(0)
const [errorCount, setErrorCount] = useState(0)
const testNumbers = [5, 23, 67, 123, 456]
return (
<div className={css({ minH: 'screen', bg: 'gray.50', py: '8' })}>
<div className={container({ maxW: '6xl', px: '4' })}>
<div className={stack({ gap: '8' })}>
{/* Header */}
<div className={stack({ gap: '4', textAlign: 'center' })}>
<h1 className={css({
fontSize: '3xl',
fontWeight: 'bold',
color: 'gray.900'
})}>
Typst.ts Integration Test
</h1>
<p className={css({
fontSize: 'lg',
color: 'gray.600'
})}>
Testing browser-native Soroban SVG generation with typst.ts
</p>
<div className={hstack({ gap: '6', justify: 'center' })}>
<div className={css({
px: '3',
py: '1',
bg: 'green.100',
color: 'green.700',
rounded: 'full',
fontSize: 'sm'
})}>
Generated: {generationCount}
</div>
<div className={css({
px: '3',
py: '1',
bg: errorCount > 0 ? 'red.100' : 'gray.100',
color: errorCount > 0 ? 'red.700' : 'gray.700',
rounded: 'full',
fontSize: 'sm'
})}>
Errors: {errorCount}
</div>
</div>
</div>
{/* Number Selector */}
<div className={css({
bg: 'white',
rounded: 'xl',
p: '6',
shadow: 'card'
})}>
<h3 className={css({
fontSize: 'lg',
fontWeight: 'semibold',
mb: '4'
})}>
Select Number to Generate
</h3>
<div className={hstack({ gap: '3', flexWrap: 'wrap' })}>
{testNumbers.map((num) => (
<button
key={num}
onClick={() => setSelectedNumber(num)}
className={css({
px: '4',
py: '2',
rounded: 'lg',
border: '2px solid',
borderColor: selectedNumber === num ? 'brand.600' : 'gray.200',
bg: selectedNumber === num ? 'brand.50' : 'white',
color: selectedNumber === num ? 'brand.700' : 'gray.700',
fontWeight: 'medium',
transition: 'all',
_hover: {
borderColor: 'brand.400',
bg: 'brand.25'
}
})}
>
{num}
</button>
))}
<input
type="number"
value={selectedNumber}
onChange={(e) => setSelectedNumber(parseInt(e.target.value) || 0)}
className={css({
w: '20',
px: '3',
py: '2',
border: '2px solid',
borderColor: 'gray.200',
rounded: 'lg',
fontSize: 'sm',
_focus: {
borderColor: 'brand.400',
outline: 'none'
}
})}
min="0"
max="9999"
/>
</div>
</div>
{/* Generated Soroban */}
<div className={css({
bg: 'white',
rounded: 'xl',
p: '6',
shadow: 'card'
})}>
<h3 className={css({
fontSize: 'lg',
fontWeight: 'semibold',
mb: '4'
})}>
Generated Soroban (Number: {selectedNumber})
</h3>
<div className={css({
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minH: '300px',
bg: 'gray.50',
rounded: 'lg',
border: '1px solid',
borderColor: 'gray.200'
})}>
<TypstSoroban
number={selectedNumber}
width="200pt"
height="250pt"
onSuccess={() => setGenerationCount(prev => prev + 1)}
onError={() => setErrorCount(prev => prev + 1)}
className={css({
maxW: 'sm',
maxH: '400px'
})}
/>
</div>
</div>
{/* Test Grid */}
<div className={css({
bg: 'white',
rounded: 'xl',
p: '6',
shadow: 'card'
})}>
<h3 className={css({
fontSize: 'lg',
fontWeight: 'semibold',
mb: '4'
})}>
Test Grid (Multiple Numbers)
</h3>
<div className={grid({
columns: { base: 2, md: 3, lg: 5 },
gap: '4'
})}>
{testNumbers.map((num) => (
<div
key={num}
className={css({
aspectRatio: '3/4',
bg: 'gray.50',
rounded: 'lg',
border: '1px solid',
borderColor: 'gray.200',
overflow: 'hidden'
})}
>
<div className={css({
p: '2',
bg: 'white',
borderBottom: '1px solid',
borderColor: 'gray.200',
textAlign: 'center',
fontSize: 'sm',
fontWeight: 'medium'
})}>
{num}
</div>
<div className={css({ p: '2', h: 'full' })}>
<TypstSoroban
number={num}
width="100pt"
height="120pt"
onSuccess={() => setGenerationCount(prev => prev + 1)}
onError={() => setErrorCount(prev => prev + 1)}
className={css({ w: 'full', h: 'full' })}
/>
</div>
</div>
))}
</div>
</div>
{/* Info */}
<div className={css({
bg: 'blue.50',
border: '1px solid',
borderColor: 'blue.200',
rounded: 'xl',
p: '6'
})}>
<h3 className={css({
fontSize: 'lg',
fontWeight: 'semibold',
color: 'blue.900',
mb: '3'
})}>
About This Test
</h3>
<div className={stack({ gap: '2' })}>
<p className={css({ color: 'blue.800', fontSize: 'sm' })}>
This page tests the typst.ts integration for generating Soroban SVGs directly in the browser
</p>
<p className={css({ color: 'blue.800', fontSize: 'sm' })}>
No Python bridge required - everything runs natively in TypeScript/WebAssembly
</p>
<p className={css({ color: 'blue.800', fontSize: 'sm' })}>
Uses the same Typst templates as the existing system for consistency
</p>
<p className={css({ color: 'blue.800', fontSize: 'sm' })}>
Global abacus display settings are automatically applied
</p>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,196 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { generateSorobanSVG, type SorobanConfig } from '@/lib/typst-soroban'
import { css } from '../../styled-system/css'
import { useAbacusConfig } from '@/contexts/AbacusDisplayContext'
interface TypstSorobanProps {
number: number
width?: string
height?: string
className?: string
onError?: (error: string) => void
onSuccess?: () => void
}
export function TypstSoroban({
number,
width = '120pt',
height = '160pt',
className,
onError,
onSuccess
}: TypstSorobanProps) {
const [svg, setSvg] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const globalConfig = useAbacusConfig()
useEffect(() => {
async function generateSVG() {
setIsLoading(true)
setError(null)
setSvg(null)
try {
const config: SorobanConfig = {
number,
width,
height,
beadShape: globalConfig.beadShape,
colorScheme: globalConfig.colorScheme,
hideInactiveBeads: globalConfig.hideInactiveBeads,
coloredNumerals: globalConfig.coloredNumerals,
scaleFactor: globalConfig.scaleFactor,
transparent: false
}
const generatedSvg = await generateSorobanSVG(config)
setSvg(generatedSvg)
// Call success callback after state is set
setTimeout(() => onSuccess?.(), 0)
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error'
setError(errorMessage)
console.error('TypstSoroban generation error:', err)
// Call error callback after state is set
setTimeout(() => onError?.(errorMessage), 0)
} finally {
setIsLoading(false)
}
}
generateSVG()
}, [number, width, height, globalConfig.beadShape, globalConfig.colorScheme, globalConfig.hideInactiveBeads, globalConfig.coloredNumerals, globalConfig.scaleFactor])
if (isLoading) {
return (
<div className={className}>
<div className={css({
w: 'full',
h: 'full',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bg: 'gray.50',
rounded: 'md',
minH: '200px'
})}>
<div className={css({
w: '8',
h: '8',
border: '2px solid',
borderColor: 'gray.300',
borderTopColor: 'brand.600',
rounded: 'full',
animation: 'spin 1s linear infinite'
})} />
</div>
</div>
)
}
if (error) {
return (
<div className={className}>
<div className={css({
w: 'full',
h: 'full',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
bg: 'red.50',
border: '1px solid',
borderColor: 'red.200',
rounded: 'md',
p: '4',
minH: '200px'
})}>
<div className={css({ fontSize: '2xl', mb: '2' })}></div>
<p className={css({ color: 'red.700', fontSize: 'sm', textAlign: 'center' })}>
Failed to generate soroban
</p>
<p className={css({ color: 'red.600', fontSize: 'xs', mt: '1' })}>
{error}
</p>
</div>
</div>
)
}
if (!svg) {
return (
<div className={className}>
<div className={css({
w: 'full',
h: 'full',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bg: 'gray.50',
rounded: 'md',
minH: '200px'
})}>
<div className={css({ color: 'gray.500', fontSize: 'sm' })}>
No SVG generated
</div>
</div>
</div>
)
}
return (
<div className={className}>
<div
className={css({
w: 'full',
h: 'full',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
'& svg': {
maxWidth: '100%',
maxHeight: '100%',
width: 'auto',
height: 'auto'
}
})}
dangerouslySetInnerHTML={{ __html: svg }}
/>
</div>
)
}
// Optional: Create a hook for easier usage
export function useTypstSoroban(config: SorobanConfig) {
const [svg, setSvg] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const generate = async () => {
setIsLoading(true)
setError(null)
setSvg(null)
try {
const generatedSvg = await generateSorobanSVG(config)
setSvg(generatedSvg)
return generatedSvg
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error'
setError(errorMessage)
throw err
} finally {
setIsLoading(false)
}
}
return {
svg,
isLoading,
error,
generate
}
}

View File

@@ -0,0 +1,208 @@
// TypeScript module for generating Soroban SVGs using typst.ts
// This replaces the Python bridge with a browser-native solution
// Try different import approach for Next.js compatibility
let $typst: any = null
async function getTypstRenderer() {
if ($typst) return $typst
try {
// Try the ES module import first
const typstModule = await import('@myriaddreamin/typst.ts/dist/esm/contrib/snippet.mjs')
$typst = typstModule.$typst
return $typst
} catch (error) {
console.warn('ES module import failed, trying alternative:', error)
try {
// Fallback to dynamic import
const typstModule = await import('@myriaddreamin/typst.ts')
$typst = typstModule
return $typst
} catch (fallbackError) {
console.error('All typst.ts import methods failed:', fallbackError)
throw new Error('Failed to load typst.ts renderer')
}
}
}
// We'll load the template content via an API endpoint or inline it here
// For now, let's create a minimal template with the draw-soroban function
export interface SorobanConfig {
number: number
beadShape?: 'diamond' | 'circle' | 'square'
colorScheme?: 'monochrome' | 'place-value' | 'heaven-earth' | 'alternating'
colorPalette?: 'default' | 'colorblind' | 'mnemonic' | 'grayscale' | 'nature'
hideInactiveBeads?: boolean
showEmptyColumns?: boolean
columns?: number | 'auto'
scaleFactor?: number
width?: string
height?: string
fontSize?: string
fontFamily?: string
transparent?: boolean
}
// Cache for compiled templates to avoid recompilation
const templateCache = new Map<string, Promise<string>>()
// Lazy-loaded template content
let flashcardsTemplate: string | null = null
async function getFlashcardsTemplate(): Promise<string> {
if (flashcardsTemplate) {
return flashcardsTemplate
}
try {
const response = await fetch('/api/typst-template')
const data = await response.json()
if (data.success) {
flashcardsTemplate = data.template
return flashcardsTemplate
} else {
throw new Error(data.error || 'Failed to load template')
}
} catch (error) {
console.error('Failed to fetch typst template:', error)
throw new Error('Template loading failed')
}
}
async function getTypstTemplate(config: SorobanConfig): Promise<string> {
const template = await getFlashcardsTemplate()
const {
number,
beadShape = 'diamond',
colorScheme = 'place-value',
colorPalette = 'default',
hideInactiveBeads = false,
showEmptyColumns = false,
columns = 'auto',
scaleFactor = 1.0,
width = '120pt',
height = '160pt',
fontSize = '48pt',
fontFamily = 'DejaVu Sans',
transparent = false
} = config
return `
${template}
#set page(
width: ${width},
height: ${height},
margin: 0pt,
fill: ${transparent ? 'none' : 'white'}
)
#set text(font: "${fontFamily}", size: ${fontSize}, fallback: true)
#align(center + horizon)[
#box(
width: ${width} - 2 * (${width} * 0.05),
height: ${height} - 2 * (${height} * 0.05)
)[
#align(center + horizon)[
#scale(x: ${scaleFactor * 100}%, y: ${scaleFactor * 100}%)[
#draw-soroban(
${number},
columns: ${columns},
show-empty: ${showEmptyColumns},
hide-inactive: ${hideInactiveBeads},
bead-shape: "${beadShape}",
color-scheme: "${colorScheme}",
color-palette: "${colorPalette}",
base-size: 1.0
)
]
]
]
]
`
}
export async function generateSorobanSVG(config: SorobanConfig): Promise<string> {
try {
// Create a cache key based on the configuration
const cacheKey = JSON.stringify(config)
// Check if we have a cached result
if (templateCache.has(cacheKey)) {
return await templateCache.get(cacheKey)!
}
// Generate the SVG using the server-side API
const response = await fetch('/api/typst-svg', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(config),
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(errorData.error || 'SVG generation failed')
}
const data = await response.json()
if (!data.success) {
throw new Error(data.error || 'SVG generation failed')
}
const svg = data.svg
// Cache the result
templateCache.set(cacheKey, Promise.resolve(svg))
// Clean up the cache if it gets too large (keep last 50 entries)
if (templateCache.size > 50) {
const entries = Array.from(templateCache.entries())
const toKeep = entries.slice(-25) // Keep last 25
templateCache.clear()
toKeep.forEach(([key, value]) => templateCache.set(key, value))
}
return svg
} catch (error) {
console.error('Failed to generate Soroban SVG with typst.ts:', error)
throw new Error(`SVG generation failed: ${error instanceof Error ? error.message : 'Unknown error'}`)
}
}
export async function generateSorobanPreview(
numbers: number[],
config: Omit<SorobanConfig, 'number'>
): Promise<Array<{ number: number; svg: string }>> {
const results = await Promise.allSettled(
numbers.map(async (number) => ({
number,
svg: await generateSorobanSVG({ ...config, number })
}))
)
return results.map((result, index) => {
if (result.status === 'fulfilled') {
return result.value
} else {
console.error(`Failed to generate SVG for number ${numbers[index]}:`, result.reason)
return {
number: numbers[index],
svg: `<svg width="200" height="300" viewBox="0 0 200 300" xmlns="http://www.w3.org/2000/svg">
<rect x="10" y="10" width="180" height="280" fill="none" stroke="#ccc" stroke-width="2"/>
<line x1="20" y1="150" x2="180" y2="150" stroke="#ccc" stroke-width="2"/>
<text x="100" y="160" text-anchor="middle" font-size="24" fill="#666">Generation Error</text>
<text x="100" y="180" text-anchor="middle" font-size="16" fill="#999">${numbers[index]}</text>
</svg>`
}
}
})
}

View File

@@ -0,0 +1,153 @@
// Test file for typst.ts integration
// This will test if we can render our existing Typst templates using typst.ts
import { $typst } from '@myriaddreamin/typst.ts/dist/esm/contrib/snippet.mjs';
import fs from 'fs';
import path from 'path';
async function testBasicTypst() {
console.log('🧪 Testing basic typst.ts functionality...');
try {
// Test basic rendering
const result = await $typst.svg({ mainContent: 'Hello, typst!' });
console.log('✅ Basic typst.ts working!');
console.log('📏 SVG length:', result.length);
return true;
} catch (error) {
console.error('❌ Basic typst.ts failed:', error);
return false;
}
}
async function testSorobanTemplate() {
console.log('🧮 Testing soroban template rendering...');
try {
// Read our existing flashcards.typ template
const templatesDir = path.join(process.cwd(), '../../packages/core/templates');
const flashcardsTemplate = fs.readFileSync(path.join(templatesDir, 'flashcards.typ'), 'utf-8');
const singleCardTemplate = fs.readFileSync(path.join(templatesDir, 'single-card.typ'), 'utf-8');
console.log('📁 Templates loaded successfully');
console.log('📏 flashcards.typ length:', flashcardsTemplate.length);
console.log('📏 single-card.typ length:', singleCardTemplate.length);
// Create a simple test document that uses our templates
const testContent = `
${flashcardsTemplate}
// Test drawing a simple soroban for number 5
#draw-soroban(5, columns: auto, show-empty: false, hide-inactive: false, bead-shape: "diamond", color-scheme: "place-value", base-size: 1.0)
`;
console.log('🎯 Attempting to render soroban for number 5...');
const result = await $typst.svg({ mainContent: testContent });
console.log('✅ Soroban template rendering successful!');
console.log('📏 Generated SVG length:', result.length);
console.log('🔍 SVG preview:', result.substring(0, 200) + '...');
// Save the result for inspection
fs.writeFileSync('/tmp/soroban-test.svg', result);
console.log('💾 Saved test SVG to /tmp/soroban-test.svg');
return result;
} catch (error) {
console.error('❌ Soroban template rendering failed:', error);
console.error('📋 Error details:', error.message);
return null;
}
}
async function testSingleCard() {
console.log('🃏 Testing single card template...');
try {
// Read templates
const templatesDir = path.join(process.cwd(), '../../packages/core/templates');
const flashcardsTemplate = fs.readFileSync(path.join(templatesDir, 'flashcards.typ'), 'utf-8');
const singleCardTemplate = fs.readFileSync(path.join(templatesDir, 'single-card.typ'), 'utf-8');
// Extract just the functions we need from single-card.typ and inline them
// Remove the import line and create an inlined version
const singleCardInlined = singleCardTemplate
.replace('#import "flashcards.typ": draw-soroban', '// Inlined draw-soroban from flashcards.typ');
// Create test content using inlined single-card template
const testContent = `
${flashcardsTemplate}
${singleCardInlined}
#set page(
width: 120pt,
height: 160pt,
margin: 0pt,
fill: white
)
#set text(font: "DejaVu Sans", size: 48pt, fallback: true)
#align(center + horizon)[
#box(
width: 120pt - 2 * (120pt * 0.05),
height: 160pt - 2 * (160pt * 0.05)
)[
#align(center + horizon)[
#scale(x: 100%, y: 100%)[
#draw-soroban(
23,
columns: auto,
show-empty: false,
hide-inactive: false,
bead-shape: "diamond",
color-scheme: "place-value",
color-palette: "default",
base-size: 1.0
)
]
]
]
]
`;
console.log('🎯 Attempting to render single card for number 23...');
const result = await $typst.svg({ mainContent: testContent });
console.log('✅ Single card rendering successful!');
console.log('📏 Generated SVG length:', result.length);
// Save the result
fs.writeFileSync('/tmp/single-card-test.svg', result);
console.log('💾 Saved single card test SVG to /tmp/single-card-test.svg');
return result;
} catch (error) {
console.error('❌ Single card rendering failed:', error);
console.error('📋 Error details:', error.message);
return null;
}
}
// Run the tests
async function runTests() {
console.log('🚀 Starting typst.ts integration tests...\n');
const basicTest = await testBasicTypst();
if (!basicTest) {
console.log('❌ Basic test failed, aborting further tests');
return;
}
console.log('\n');
await testSorobanTemplate();
console.log('\n');
await testSingleCard();
console.log('\n🏁 Tests completed!');
}
runTests().catch(console.error);

View File

@@ -0,0 +1,316 @@
@layer utilities {
.min-h_screen {
min-height: 100vh
}
.py_8 {
padding-block: var(--spacing-8)
}
.fs_3xl {
font-size: var(--font-sizes-3xl)
}
.font_bold {
font-weight: var(--font-weights-bold)
}
.text_gray\.900 {
color: var(--colors-gray-900)
}
.text_gray\.600 {
color: var(--colors-gray-600)
}
.bg_green\.100 {
background: var(--colors-green-100)
}
.text_green\.700 {
color: var(--colors-green-700)
}
.bg_red\.100 {
background: var(--colors-red-100)
}
.bg_gray\.100 {
background: var(--colors-gray-100)
}
.text_red\.700 {
color: var(--colors-red-700)
}
.py_1 {
padding-block: var(--spacing-1)
}
.rounded_full {
border-radius: var(--radii-full)
}
.border_brand\.600 {
border-color: var(--colors-brand-600)
}
.bg_brand\.50 {
background: var(--colors-brand-50)
}
.text_brand\.700 {
color: var(--colors-brand-700)
}
.text_gray\.700 {
color: var(--colors-gray-700)
}
.transition_all {
transition-property: var(--transition-prop, all);
transition-timing-function: var(--transition-easing, cubic-bezier(0.4, 0, 0.2, 1));
transition-duration: var(--transition-duration, 150ms)
}
.w_20 {
width: var(--sizes-20)
}
.px_3 {
padding-inline: var(--spacing-3)
}
.py_2 {
padding-block: var(--spacing-2)
}
.border_2px_solid {
border: 2px solid
}
.min-h_300px {
min-height: 300px
}
.max-w_sm {
max-width: var(--sizes-sm)
}
.max-h_400px {
max-height: 400px
}
.shadow_card {
box-shadow: var(--shadows-card)
}
.mb_4 {
margin-bottom: var(--spacing-4)
}
.aspect_3\/4 {
aspect-ratio: 3/4
}
.bg_gray\.50 {
background: var(--colors-gray-50)
}
.rounded_lg {
border-radius: var(--radii-lg)
}
.overflow_hidden {
overflow: hidden
}
.bg_white {
background: var(--colors-white)
}
.border-b_1px_solid {
border-bottom: 1px solid
}
.border_gray\.200 {
border-color: var(--colors-gray-200)
}
.font_medium {
font-weight: var(--font-weights-medium)
}
.p_2 {
padding: var(--spacing-2)
}
.w_full {
width: var(--sizes-full)
}
.h_full {
height: var(--sizes-full)
}
.bg_blue\.50 {
background: var(--colors-blue-50)
}
.border_1px_solid {
border: 1px solid
}
.border_blue\.200 {
border-color: var(--colors-blue-200)
}
.rounded_xl {
border-radius: var(--radii-xl)
}
.p_6 {
padding: var(--spacing-6)
}
.fs_lg {
font-size: var(--font-sizes-lg)
}
.font_semibold {
font-weight: var(--font-weights-semibold)
}
.text_blue\.900 {
color: var(--colors-blue-900)
}
.mb_3 {
margin-bottom: var(--spacing-3)
}
.text_blue\.800 {
color: var(--colors-blue-800)
}
.fs_sm {
font-size: var(--font-sizes-sm)
}
.w_200pt {
width: 200pt
}
.h_250pt {
height: 250pt
}
.w_100pt {
width: 100pt
}
.h_120pt {
height: 120pt
}
.pos_relative {
position: relative
}
.max-w_6xl {
max-width: var(--sizes-6xl)
}
.mx_auto {
margin-inline: auto
}
.px_4 {
padding-inline: var(--spacing-4)
}
.gap_8 {
gap: var(--spacing-8)
}
.text_center {
text-align: center
}
.flex_column {
flex-direction: column
}
.gap_2 {
gap: var(--spacing-2)
}
.justify_center {
justify-content: center
}
.gap_6 {
gap: var(--spacing-6)
}
.d_flex {
display: flex
}
.items_center {
align-items: center
}
.gap_3 {
gap: var(--spacing-3)
}
.flex_row {
flex-direction: row
}
.flex-wrap_wrap {
flex-wrap: wrap
}
.d_grid {
display: grid
}
.grid-cols_repeat\(2\,_minmax\(0\,_1fr\)\) {
grid-template-columns: repeat(2, minmax(0, 1fr))
}
.gap_4 {
gap: var(--spacing-4)
}
.focus\:border_brand\.400:is(:focus, [data-focus]) {
border-color: var(--colors-brand-400)
}
.focus\:ring_none:is(:focus, [data-focus]) {
outline: var(--borders-none)
}
.hover\:border_brand\.400:is(:hover, [data-hover]) {
border-color: var(--colors-brand-400)
}
.hover\:bg_brand\.25:is(:hover, [data-hover]) {
background: brand.25
}
@media screen and (min-width: 48em) {
.md\:grid-cols_repeat\(3\,_minmax\(0\,_1fr\)\) {
grid-template-columns: repeat(3, minmax(0, 1fr))
}
}
@media screen and (min-width: 64em) {
.lg\:grid-cols_repeat\(5\,_minmax\(0\,_1fr\)\) {
grid-template-columns: repeat(5, minmax(0, 1fr))
}
}
}

View File

@@ -0,0 +1,130 @@
@layer utilities {
.w_8 {
width: var(--sizes-8)
}
.h_8 {
height: var(--sizes-8)
}
.border_2px_solid {
border: 2px solid
}
.border_gray\.300 {
border-color: var(--colors-gray-300)
}
.border-t_brand\.600 {
border-top-color: var(--colors-brand-600)
}
.rounded_full {
border-radius: var(--radii-full)
}
.animation_spin_1s_linear_infinite {
animation: spin 1s linear infinite
}
.flex_column {
flex-direction: column
}
.bg_red\.50 {
background: var(--colors-red-50)
}
.border_1px_solid {
border: 1px solid
}
.border_red\.200 {
border-color: var(--colors-red-200)
}
.p_4 {
padding: var(--spacing-4)
}
.fs_2xl {
font-size: var(--font-sizes-2xl)
}
.mb_2 {
margin-bottom: var(--spacing-2)
}
.text_red\.700 {
color: var(--colors-red-700)
}
.text_center {
text-align: center
}
.text_red\.600 {
color: var(--colors-red-600)
}
.fs_xs {
font-size: var(--font-sizes-xs)
}
.mt_1 {
margin-top: var(--spacing-1)
}
.bg_gray\.50 {
background: var(--colors-gray-50)
}
.rounded_md {
border-radius: var(--radii-md)
}
.min-h_200px {
min-height: 200px
}
.text_gray\.500 {
color: var(--colors-gray-500)
}
.fs_sm {
font-size: var(--font-sizes-sm)
}
.w_full {
width: var(--sizes-full)
}
.h_full {
height: var(--sizes-full)
}
.d_flex {
display: flex
}
.items_center {
align-items: center
}
.justify_center {
justify-content: center
}
.\[\&_svg\]\:max-w_100\% svg {
max-width: 100%
}
.\[\&_svg\]\:max-h_100\% svg {
max-height: 100%
}
.\[\&_svg\]\:w_auto svg {
width: auto
}
.\[\&_svg\]\:h_auto svg {
height: auto
}
}

21
pnpm-lock.yaml generated
View File

@@ -25,6 +25,9 @@ importers:
apps/web:
dependencies:
'@myriaddreamin/typst.ts':
specifier: 0.6.1-rc3
version: 0.6.1-rc3
'@pandacss/dev':
specifier: ^0.20.0
version: 0.20.1(typescript@5.0.2)
@@ -985,6 +988,20 @@ packages:
resolution: {integrity: sha512-P/eIJ5RnfElj0NYzn5PI296t/IwWtgqUyyTMi5Jm5X3V5kZfskkH+LI7mSQe8tEyxwgCvxbxvFe5adinA3K8Gg==}
dev: false
/@myriaddreamin/typst.ts@0.6.1-rc3:
resolution: {integrity: sha512-pGzaJ5SV0JjrNWn14Bicy0nPTtfD5+4kHwGUDYSebSMbfAtNHfi+Et7k4PiXqunwRm7Obkk5iZufiRM2jOlfbg==}
peerDependencies:
'@myriaddreamin/typst-ts-renderer': ^0.6.1-rc3
'@myriaddreamin/typst-ts-web-compiler': ^0.6.1-rc3
peerDependenciesMeta:
'@myriaddreamin/typst-ts-renderer':
optional: true
'@myriaddreamin/typst-ts-web-compiler':
optional: true
dependencies:
idb: 7.1.1
dev: false
/@napi-rs/wasm-runtime@0.2.12:
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
requiresBuild: true
@@ -4332,6 +4349,10 @@ packages:
engines: {node: '>=16.17.0'}
dev: true
/idb@7.1.1:
resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==}
dev: false
/ignore@4.0.6:
resolution: {integrity: sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==}
engines: {node: '>= 4'}