diff --git a/apps/web/scripts/generateCalendarComposite.tsx b/apps/web/scripts/generateCalendarComposite.tsx new file mode 100644 index 00000000..9393a33c --- /dev/null +++ b/apps/web/scripts/generateCalendarComposite.tsx @@ -0,0 +1,142 @@ +#!/usr/bin/env tsx + +/** + * Generate a complete monthly calendar as a single SVG + * This prevents multi-page overflow - one image scales to fit + * + * Usage: npx tsx scripts/generateCalendarComposite.tsx + * Example: npx tsx scripts/generateCalendarComposite.tsx 12 2025 + */ + +import React from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { AbacusStatic } from '@soroban/abacus-react/static' + +const month = parseInt(process.argv[2], 10) +const year = parseInt(process.argv[3], 10) + +if (isNaN(month) || isNaN(year) || month < 1 || month > 12) { + console.error('Usage: npx tsx scripts/generateCalendarComposite.tsx ') + process.exit(1) +} + +const MONTH_NAMES = [ + 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December', +] + +const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] + +function getDaysInMonth(year: number, month: number): number { + return new Date(year, month, 0).getDate() +} + +function getFirstDayOfWeek(year: number, month: number): number { + return new Date(year, month - 1, 1).getDay() +} + +const daysInMonth = getDaysInMonth(year, month) +const firstDayOfWeek = getFirstDayOfWeek(year, month) +const monthName = MONTH_NAMES[month - 1] + +// Layout constants for US Letter aspect ratio (8.5 x 11) +const WIDTH = 850 +const HEIGHT = 1100 +const MARGIN = 40 +const CONTENT_WIDTH = WIDTH - MARGIN * 2 +const CONTENT_HEIGHT = HEIGHT - MARGIN * 2 + +// Header +const HEADER_HEIGHT = 80 +const TITLE_Y = MARGIN + 30 +const YEAR_ABACUS_WIDTH = 120 +const YEAR_ABACUS_HEIGHT = 50 + +// Calendar grid +const GRID_START_Y = MARGIN + HEADER_HEIGHT + 20 +const GRID_HEIGHT = CONTENT_HEIGHT - HEADER_HEIGHT - 20 +const CELL_WIDTH = CONTENT_WIDTH / 7 +const CELL_HEIGHT = GRID_HEIGHT / 6.5 // ~6 weeks max + weekday headers +const WEEKDAY_ROW_HEIGHT = 30 +const DAY_CELL_HEIGHT = (GRID_HEIGHT - WEEKDAY_ROW_HEIGHT) / 6 + +// Generate calendar grid +const calendarCells: (number | null)[] = [] +for (let i = 0; i < firstDayOfWeek; i++) { + calendarCells.push(null) +} +for (let day = 1; day <= daysInMonth; day++) { + calendarCells.push(day) +} + +// Calculate how many columns needed for year +const yearColumns = Math.max(1, Math.ceil(Math.log10(year + 1))) + +// Render individual abacus SVGs as strings +function renderAbacusSVG(value: number, columns: number, scale: number): string { + return renderToStaticMarkup( + + ) +} + +// Main composite SVG +const compositeSVG = ` + + + + + + ${monthName} ${year} + + + + + ${renderAbacusSVG(year, yearColumns, 0.4).replace(/]*>/, '').replace('', '')} + + + + ${WEEKDAYS.map((day, i) => ` + + ${day} + `).join('')} + + + + + + ${calendarCells.map((day, index) => { + if (day === null) return '' + + const row = Math.floor(index / 7) + const col = index % 7 + const x = MARGIN + col * CELL_WIDTH + const y = GRID_START_Y + WEEKDAY_ROW_HEIGHT + row * DAY_CELL_HEIGHT + + // Calculate scale to fit abacus in cell (leaving some padding) + const abacusScale = Math.min(CELL_WIDTH / 120, DAY_CELL_HEIGHT / 230) * 0.7 + + const abacusSVG = renderAbacusSVG(day, 2, abacusScale) + .replace(/]*>/, '') + .replace('', '') + + return ` + + + + ${abacusSVG} + + ` + }).join('')} +` + +process.stdout.write(compositeSVG) diff --git a/apps/web/src/app/api/create/calendar/generate/route.ts b/apps/web/src/app/api/create/calendar/generate/route.ts index 31b71efe..9b870acc 100644 --- a/apps/web/src/app/api/create/calendar/generate/route.ts +++ b/apps/web/src/app/api/create/calendar/generate/route.ts @@ -32,48 +32,62 @@ export async function POST(request: NextRequest) { // Generate SVGs using script (avoids Next.js react-dom/server restriction) const daysInMonth = getDaysInMonth(year, month) - const maxDay = format === 'daily' ? daysInMonth : 31 // For monthly, pre-generate all - const scriptPath = join(process.cwd(), 'scripts', 'generateCalendarAbacus.tsx') - // Generate day SVGs (1 to maxDay) - for (let day = 1; day <= maxDay; day++) { + if (format === 'monthly') { + // Generate single composite SVG for monthly calendar (prevents multi-page overflow) + const compositeScriptPath = join(process.cwd(), 'scripts', 'generateCalendarComposite.tsx') try { - const svg = execSync(`npx tsx "${scriptPath}" ${day} 2`, { + const compositeSvg = execSync(`npx tsx "${compositeScriptPath}" ${month} ${year}`, { encoding: 'utf-8', cwd: process.cwd(), stdio: ['pipe', 'pipe', 'pipe'], }) - if (!svg || svg.trim().length === 0) { - console.error(`Empty SVG for day ${day}`) - throw new Error(`Generated empty SVG for day ${day}`) + if (!compositeSvg || compositeSvg.trim().length === 0) { + throw new Error(`Generated empty composite calendar SVG`) } - writeFileSync(join(tempDir, `day-${day}.svg`), svg) + writeFileSync(join(tempDir, 'calendar.svg'), compositeSvg) } catch (error: any) { - console.error(`Error generating day ${day} SVG:`, error.stderr || error.message) + console.error(`Error generating composite calendar:`, error.stderr || error.message) throw error } - } + } else { + // Daily format: generate individual SVGs for each day + const scriptPath = join(process.cwd(), 'scripts', 'generateCalendarAbacus.tsx') - // Generate year SVG - const yearColumns = Math.max(1, Math.ceil(Math.log10(year + 1))) - try { - const yearSvg = execSync(`npx tsx "${scriptPath}" ${year} ${yearColumns}`, { - encoding: 'utf-8', - cwd: process.cwd(), - stdio: ['pipe', 'pipe', 'pipe'], - }) - console.log(`Year SVG length: ${yearSvg.length}, starts with: ${yearSvg.substring(0, 100)}`) - if (!yearSvg || yearSvg.trim().length === 0) { - console.error(`Empty SVG for year ${year}`) - throw new Error(`Generated empty SVG for year ${year}`) + // Generate day SVGs (1 to daysInMonth) + for (let day = 1; day <= daysInMonth; day++) { + try { + const svg = execSync(`npx tsx "${scriptPath}" ${day} 2`, { + encoding: 'utf-8', + cwd: process.cwd(), + stdio: ['pipe', 'pipe', 'pipe'], + }) + if (!svg || svg.trim().length === 0) { + throw new Error(`Generated empty SVG for day ${day}`) + } + writeFileSync(join(tempDir, `day-${day}.svg`), svg) + } catch (error: any) { + console.error(`Error generating day ${day} SVG:`, error.stderr || error.message) + throw error + } + } + + // Generate year SVG + const yearColumns = Math.max(1, Math.ceil(Math.log10(year + 1))) + try { + const yearSvg = execSync(`npx tsx "${scriptPath}" ${year} ${yearColumns}`, { + encoding: 'utf-8', + cwd: process.cwd(), + stdio: ['pipe', 'pipe', 'pipe'], + }) + if (!yearSvg || yearSvg.trim().length === 0) { + throw new Error(`Generated empty SVG for year ${year}`) + } + writeFileSync(join(tempDir, 'year.svg'), yearSvg) + } catch (error: any) { + console.error(`Error generating year ${year} SVG:`, error.stderr || error.message) + throw error } - writeFileSync(join(tempDir, 'year.svg'), yearSvg) - // Debug: also save to /tmp for inspection - writeFileSync('/tmp/debug-year.svg', yearSvg) - console.log('Saved debug year.svg to /tmp/debug-year.svg') - } catch (error: any) { - console.error(`Error generating year ${year} SVG:`, error.stderr || error.message) - throw error } // Generate Typst document diff --git a/apps/web/src/app/api/create/calendar/utils/typstGenerator.ts b/apps/web/src/app/api/create/calendar/utils/typstGenerator.ts index 54f55100..074a883b 100644 --- a/apps/web/src/app/api/create/calendar/utils/typstGenerator.ts +++ b/apps/web/src/app/api/create/calendar/utils/typstGenerator.ts @@ -59,55 +59,18 @@ function getPaperConfig(size: string): PaperConfig { export function generateMonthlyTypst(config: TypstConfig): string { const { month, year, paperSize, tempDir, daysInMonth } = config const paperConfig = getPaperConfig(paperSize) - const firstDayOfWeek = getFirstDayOfWeek(year, month) - const monthName = MONTH_NAMES[month - 1] - - // Generate calendar cells with proper empty cells before the first day - let cells = '' - - // Empty cells before first day - for (let i = 0; i < firstDayOfWeek; i++) { - cells += ' [],\n' - } - - // Day cells - for (let day = 1; day <= daysInMonth; day++) { - cells += ` [#image("day-${day}.svg", width: 90%)],\n` - } + // Single-page design: use one composite SVG that scales to fit + // This prevents overflow - Typst will scale the image to fit available space return `#set page( paper: "${paperConfig.typstName}", margin: (x: ${paperConfig.marginX}, y: ${paperConfig.marginY}), ) -#set text(font: "Arial", size: 10pt) - -// Title and year - compact header -#align(center)[ - #text(size: 18pt, weight: "bold")[${monthName} ${year}] - #h(1em) - #box(baseline: 25%, image("year.svg", width: 20%)) +// Composite calendar SVG - scales to fit page (prevents multi-page overflow) +#align(center + horizon)[ + #image("calendar.svg", width: 100%, fit: "contain") ] - -#v(0.5em) - -// Calendar grid - maximizes available space -#grid( - columns: (1fr, 1fr, 1fr, 1fr, 1fr, 1fr, 1fr), - gutter: 2pt, - row-gutter: 2pt, - - // Weekday headers - [#align(center)[#text(size: 9pt, weight: "bold")[Sun]]], - [#align(center)[#text(size: 9pt, weight: "bold")[Mon]]], - [#align(center)[#text(size: 9pt, weight: "bold")[Tue]]], - [#align(center)[#text(size: 9pt, weight: "bold")[Wed]]], - [#align(center)[#text(size: 9pt, weight: "bold")[Thu]]], - [#align(center)[#text(size: 9pt, weight: "bold")[Fri]]], - [#align(center)[#text(size: 9pt, weight: "bold")[Sat]]], - - // Calendar days -${cells}) ` }