Files
typogenie/src/services/templateRenderer.ts
TypoGenie a6f664088c feat: port all template categories to JSON format
- Ported Minimalist templates to JSON (Swiss Grid, Brutalist, etc.)
- Ported Tech templates to JSON (SaaS, Terminal, Cyberpunk, etc.)
- Ported Creative templates to JSON (Art Gallery, Zine, Pop Art, etc.)
- Ported Industrial templates to JSON (Blueprint, Factory, Schematic, etc.)
- Ported Nature templates to JSON (Botanical, Ocean, Mountain, etc.)
- Ported Lifestyle templates to JSON (Cookbook, Travel, Coffee House, etc.)
- Ported Vintage templates to JSON (Art Deco, Medieval, Retro 80s, etc.)
- Updated README.md to reflect the new JSON-based style system (example configuration and contribution workflow)
- Completed migration of over 150 styles to the new architecture
2026-02-01 18:51:43 +02:00

434 lines
18 KiB
TypeScript

import { StyleOption, ElementStyle, FontConfig, ColorPalette } from '../types';
/**
* Resolves a color reference (e.g., 'accent', 'background') or returns the color directly
*/
export function resolveColor(color: string | undefined, palette: ColorPalette): string {
if (!color) return '#000000';
// Check if it's a palette reference
if (palette[color as keyof ColorPalette]) {
return '#' + palette[color as keyof ColorPalette];
}
// Return as-is with # prefix if not already present
return color.startsWith('#') ? color : '#' + color;
}
/**
* Resolves a font reference (e.g., 'heading', 'body', 'code') to actual font name
*/
export function resolveFont(font: string | undefined, fonts: FontConfig): string {
if (!font) return 'serif';
// Check if it's a font reference
if (fonts[font as keyof FontConfig]) {
return fonts[font as keyof FontConfig]!;
}
// Return as-is (already an actual font name)
return font;
}
/**
* Converts element style to CSS string
*/
function elementToCss(
selector: string,
style: ElementStyle | undefined,
fonts: FontConfig,
palette: ColorPalette
): string {
if (!style) return '';
const css: string[] = [];
if (style.font) css.push(`font-family: ${resolveFont(style.font, fonts)}`);
if (style.size) css.push(`font-size: ${style.size}pt`);
if (style.color) css.push(`color: ${resolveColor(style.color, palette)}`);
if (style.background) css.push(`background: ${resolveColor(style.background, palette)}`);
if (style.bold) css.push('font-weight: 700');
if (style.italic) css.push('font-style: italic');
if (style.underline) css.push('text-decoration: underline');
if (style.allCaps) css.push('text-transform: uppercase');
if (style.align) css.push(`text-align: ${style.align === 'both' ? 'justify' : style.align}`);
if (style.spacing) {
if (style.spacing.before) css.push(`margin-top: ${style.spacing.before}pt`);
if (style.spacing.after) css.push(`margin-bottom: ${style.spacing.after}pt`);
if (style.spacing.line) css.push(`line-height: ${style.spacing.line}`);
}
if (style.indent) css.push(`text-indent: ${style.indent}pt`);
if (style.padding) css.push(`padding: ${style.padding}pt`);
if (style.border) {
css.push(`border: ${style.border.width}px ${style.border.style} ${resolveColor(style.border.color, palette)}`);
}
if (style.borderTop) {
css.push(`border-top: ${style.borderTop.width}px ${style.borderTop.style} ${resolveColor(style.borderTop.color, palette)}`);
}
if (style.borderBottom) {
css.push(`border-bottom: ${style.borderBottom.width}px ${style.borderBottom.style} ${resolveColor(style.borderBottom.color, palette)}`);
}
if (style.borderLeft) {
css.push(`border-left: ${style.borderLeft.width}px ${style.borderLeft.style} ${resolveColor(style.borderLeft.color, palette)}`);
}
if (style.borderRight) {
css.push(`border-right: ${style.borderRight.width}px ${style.borderRight.style} ${resolveColor(style.borderRight.color, palette)}`);
}
if (css.length === 0) return '';
return `${selector} { ${css.join('; ')} }`;
}
/**
* Generates preview CSS from unified template structure
* This is the SINGLE SOURCE OF TRUTH for both preview and export styling
* All selectors are scoped to .page to avoid affecting the app background
*/
export function generatePreviewCss(template: StyleOption): string {
const fonts = template.typography?.fonts || { heading: 'serif', body: 'serif', code: 'monospace' };
const palette = template.typography?.colors || { text: '000000', background: 'FFFFFF' };
const elements = template.elements || {};
const cssParts: string[] = [];
// .page base styles - scoped to avoid affecting app background
const baseStyles: string[] = [];
baseStyles.push(`font-family: ${resolveFont('body', fonts)}`);
baseStyles.push(`background: ${resolveColor('background', palette)}`);
baseStyles.push(`color: ${resolveColor('text', palette)}`);
baseStyles.push(`line-height: 1.5`);
cssParts.push(`.page { ${baseStyles.join('; ')}; min-height: 100%; }`);
cssParts.push(`.page blockquote, .page table, .page img { break-inside: avoid; }`);
// Headings - scoped to .page
cssParts.push(elementToCss('.page h1', elements.h1, fonts, palette));
cssParts.push(elementToCss('.page h2', elements.h2, fonts, palette));
cssParts.push(elementToCss('.page h3', elements.h3, fonts, palette));
cssParts.push(elementToCss('.page h4', elements.h4, fonts, palette));
cssParts.push(elementToCss('.page h5', elements.h5, fonts, palette));
cssParts.push(elementToCss('.page h6', elements.h6, fonts, palette));
// Paragraph - scoped to .page
cssParts.push(elementToCss('.page p', elements.p, fonts, palette));
// Blockquote - scoped to .page
cssParts.push(elementToCss('.page blockquote', elements.blockquote, fonts, palette));
// Code - scoped to .page
cssParts.push(elementToCss('.page code', elements.code, fonts, palette));
cssParts.push(elementToCss('.page pre', elements.pre, fonts, palette));
cssParts.push('.page pre code { background: transparent !important; padding: 0 !important }');
// Lists - scoped to .page
cssParts.push(elementToCss('.page ul', elements.ul, fonts, palette));
cssParts.push(elementToCss('.page ol', elements.ol, fonts, palette));
cssParts.push(elementToCss('.page li', elements.li, fonts, palette));
// Inline styles - scoped to .page
cssParts.push(elementToCss('.page strong', elements.strong, fonts, palette));
cssParts.push(elementToCss('.page em', elements.em, fonts, palette));
cssParts.push(elementToCss('.page a', elements.a, fonts, palette));
// Tables - scoped to .page
cssParts.push('.page table { border-collapse: collapse; border-spacing: 0; width: 100%; }');
cssParts.push(elementToCss('.page table', elements.table, fonts, palette));
cssParts.push(elementToCss('.page th', elements.th, fonts, palette));
cssParts.push(elementToCss('.page td', elements.td, fonts, palette));
// Horizontal rule - scoped to .page
if (elements.hr?.border) {
const borderColor = resolveColor(elements.hr.border.color, palette);
cssParts.push(`.page hr { border: none; border-top: ${elements.hr.border.width}px ${elements.hr.border.style} ${borderColor}; margin: ${elements.hr.spacing?.before || 12}pt 0 }`);
}
// Images - scoped to .page
if (elements.img?.align) {
cssParts.push(`.page img { display: block; text-align: ${elements.img.align}; margin: ${elements.img.spacing?.before || 12}pt auto }`);
}
// Remove empty rules and join
return cssParts.filter(part => part.trim()).join('\n');
}
/**
* Generates CSS from legacy wordConfig structure
* Used when unified elements/typography structure isn't available
* All selectors are scoped to .page to avoid affecting the app background
*/
function generateCssFromWordConfig(template: StyleOption): string {
const cfg = template.wordConfig;
if (!cfg) return '.page { font-family: serif; }';
const cssParts: string[] = [];
// .page base styles - scoped to avoid affecting app background
const pageStyles: string[] = [];
if (cfg.body?.font) pageStyles.push(`font-family: ${cfg.body.font}`);
if (cfg.body?.size) pageStyles.push(`font-size: ${cfg.body.size}pt`);
if (cfg.body?.color) pageStyles.push(`color: #${cfg.body.color}`);
if (cfg.body?.align) pageStyles.push(`text-align: ${cfg.body.align === 'both' ? 'justify' : cfg.body.align}`);
if (cfg.body?.spacing?.line) {
pageStyles.push(`line-height: ${cfg.body.spacing.line / 240}`);
}
// White background for the document page
pageStyles.push('background: #ffffff');
cssParts.push(`.page { ${pageStyles.join('; ')} }`);
// Headings - scoped to .page
if (cfg.heading1) {
const h1Styles: string[] = [];
if (cfg.heading1.font) h1Styles.push(`font-family: ${cfg.heading1.font}`);
if (cfg.heading1.size) h1Styles.push(`font-size: ${cfg.heading1.size}pt`);
if (cfg.heading1.color) h1Styles.push(`color: #${cfg.heading1.color}`);
if (cfg.heading1.bold) h1Styles.push('font-weight: 700');
if (cfg.heading1.italic) h1Styles.push('font-style: italic');
if (cfg.heading1.allCaps) h1Styles.push('text-transform: uppercase');
if (cfg.heading1.align) h1Styles.push(`text-align: ${cfg.heading1.align}`);
if (cfg.heading1.spacing?.before) {
h1Styles.push(`margin-top: ${cfg.heading1.spacing.before / 20}pt`);
}
if (cfg.heading1.spacing?.after) {
h1Styles.push(`margin-bottom: ${cfg.heading1.spacing.after / 20}pt`);
}
cssParts.push(`.page h1 { ${h1Styles.join('; ')} }`);
}
if (cfg.heading2) {
const h2Styles: string[] = [];
if (cfg.heading2.font) h2Styles.push(`font-family: ${cfg.heading2.font}`);
if (cfg.heading2.size) h2Styles.push(`font-size: ${cfg.heading2.size}pt`);
if (cfg.heading2.color) h2Styles.push(`color: #${cfg.heading2.color}`);
if (cfg.heading2.bold) h2Styles.push('font-weight: 700');
if (cfg.heading2.italic) h2Styles.push('font-style: italic');
if (cfg.heading2.allCaps) h2Styles.push('text-transform: uppercase');
if (cfg.heading2.align) h2Styles.push(`text-align: ${cfg.heading2.align}`);
if (cfg.heading2.spacing?.before) {
h2Styles.push(`margin-top: ${cfg.heading2.spacing.before / 20}pt`);
}
if (cfg.heading2.spacing?.after) {
h2Styles.push(`margin-bottom: ${cfg.heading2.spacing.after / 20}pt`);
}
cssParts.push(`.page h2 { ${h2Styles.join('; ')} }`);
}
// Paragraph - scoped to .page
if (cfg.body) {
const pStyles: string[] = [];
if (cfg.body.font) pStyles.push(`font-family: ${cfg.body.font}`);
if (cfg.body.size) pStyles.push(`font-size: ${cfg.body.size}pt`);
if (cfg.body.color) pStyles.push(`color: #${cfg.body.color}`);
if (cfg.body.align) pStyles.push(`text-align: ${cfg.body.align === 'both' ? 'justify' : cfg.body.align}`);
if (cfg.body.spacing?.line) {
pStyles.push(`line-height: ${cfg.body.spacing.line / 240}`);
}
if (cfg.body.spacing?.after) {
pStyles.push(`margin-bottom: ${cfg.body.spacing.after / 20}pt`);
}
cssParts.push(`.page p { ${pStyles.join('; ')} }`);
}
// All other elements scoped to .page
cssParts.push('.page blockquote { border-left: 3px solid #' + (cfg.accentColor || 'cccccc') + '; padding-left: 16px; margin: 16px 0; font-style: italic; }');
cssParts.push('.page ul, .page ol { margin: 16px 0; padding-left: 32px; }');
cssParts.push('.page li { margin-bottom: 8px; }');
cssParts.push('.page strong { font-weight: 700; }');
cssParts.push('.page em { font-style: italic; }');
cssParts.push('.page code { font-family: monospace; background: #f5f5f5; padding: 2px 4px; border-radius: 3px; }');
cssParts.push('.page pre { background: #f5f5f5; padding: 16px; border-radius: 4px; overflow-x: auto; }');
cssParts.push('.page table { border-collapse: collapse; width: 100%; margin: 16px 0; }');
cssParts.push('.page th, .page td { border: 1px solid #ddd; padding: 8px 12px; text-align: left; }');
cssParts.push('.page th { background: #f5f5f5; font-weight: 700; }');
cssParts.push('.page hr { border: none; border-top: 1px solid #' + (cfg.accentColor || 'cccccc') + '; margin: 24px 0; }');
return cssParts.join('\n');
}
/**
* Gets complete CSS for preview
* Always generates fresh CSS from unified template structure
*/
export function getPreviewCss(template: StyleOption): string {
// Always generate CSS from unified structure if available
if (template.elements && template.typography) {
return generatePreviewCss(template);
}
// Generate CSS from legacy wordConfig structure
if (template.wordConfig) {
return generateCssFromWordConfig(template);
}
// Final fallback
return 'body { font-family: serif; color: #000000; background: #ffffff; }';
}
/**
* Generates Word-compatible wordConfig from unified template structure
*/
// Export this function to be used by templateLoader
export function generateWordConfig(
elements: Record<string, ElementStyle>,
fonts: FontConfig,
palette: ColorPalette
) {
const h1 = elements.h1;
const h2 = elements.h2;
const body = elements.p;
const toTwips = (pts?: number) => pts;
const toLineTwips = (line?: number) => line;
// Helper to ensure borders are preserved in the config
const mapBorder = (el?: ElementStyle, side?: 'top' | 'bottom' | 'left' | 'right') => {
// If specific side border exists
let sideBorder;
if (side === 'left') sideBorder = el?.borderLeft;
else if (side === 'top') sideBorder = el?.borderTop;
else if (side === 'bottom') sideBorder = el?.borderBottom;
else if (side === 'right') sideBorder = el?.borderRight;
if (sideBorder) {
return {
color: resolveColor(sideBorder.color, palette).replace('#', ''),
space: 24, // Standard space
style: sideBorder.style || 'single',
size: (sideBorder.width || 1) * 8
};
}
// If global border exists
if (el?.border) {
return {
color: resolveColor(el.border.color, palette).replace('#', ''),
space: 24, // Standard space
style: el.border.style || 'single',
size: (el.border.width || 1) * 8
};
}
return undefined;
};
return {
heading1: h1 ? {
font: resolveFont(h1.font, fonts),
size: h1.size || 24,
color: h1.color ? resolveColor(h1.color, palette).replace('#', '') : '000000',
bold: h1.bold,
italic: h1.italic,
allCaps: h1.allCaps,
align: h1.align,
spacing: h1.spacing ? {
before: toTwips(h1.spacing.before) || 0,
after: toTwips(h1.spacing.after) || 0,
line: toLineTwips(h1.spacing.line) || 240
} : undefined,
// Map borders specifically for H1 if they exist
border: {
left: mapBorder(h1, 'left'),
bottom: mapBorder(h1, 'bottom'),
top: mapBorder(h1, 'top'),
right: mapBorder(h1, 'right')
},
shading: h1.background ? {
fill: resolveColor(h1.background, palette).replace('#', ''),
type: 'clear', // Always use clear for shading to avoid black blocks
color: 'auto'
} : undefined
} : undefined,
heading2: h2 ? {
font: resolveFont(h2.font, fonts),
size: h2.size || 18,
color: h2.color ? resolveColor(h2.color, palette).replace('#', '') : '333333',
bold: h2.bold,
italic: h2.italic,
allCaps: h2.allCaps,
align: h2.align,
spacing: h2.spacing ? {
before: toTwips(h2.spacing.before) || 0,
after: toTwips(h2.spacing.after) || 0,
line: toLineTwips(h2.spacing.line) || 240
} : undefined,
shading: h2.background ? {
fill: resolveColor(h2.background, palette).replace('#', ''),
type: 'clear',
color: 'auto'
} : undefined
} : undefined,
body: body ? {
font: resolveFont(body.font, fonts),
size: body.size || 11,
color: body.color ? resolveColor(body.color, palette).replace('#', '') : '000000',
align: body.align,
spacing: body.spacing ? {
before: toTwips(body.spacing.before) || 0,
after: toTwips(body.spacing.after) || 0,
line: toLineTwips(body.spacing.line) || 1.2
} : undefined
} : undefined,
accentColor: palette.accent || '000000',
heading3: elements.h3 ? {
font: resolveFont(elements.h3.font, fonts),
size: elements.h3.size || 14,
color: elements.h3.color ? resolveColor(elements.h3.color, palette).replace('#', '') : '111111',
bold: elements.h3.bold,
italic: elements.h3.italic,
allCaps: elements.h3.allCaps,
align: elements.h3.align,
spacing: elements.h3.spacing ? {
before: toTwips(elements.h3.spacing.before) || 0,
after: toTwips(elements.h3.spacing.after) || 0,
line: toLineTwips(elements.h3.spacing.line) || 240
} : undefined
} : undefined,
heading4: elements.h4 ? {
font: resolveFont(elements.h4.font, fonts),
size: elements.h4.size || 12,
color: elements.h4.color ? resolveColor(elements.h4.color, palette).replace('#', '') : '111111',
bold: elements.h4.bold,
italic: elements.h4.italic,
align: elements.h4.align,
spacing: elements.h4.spacing ? {
before: toTwips(elements.h4.spacing.before) || 0,
after: toTwips(elements.h4.spacing.after) || 0,
line: toLineTwips(elements.h4.spacing.line) || 240
} : undefined
} : undefined,
heading5: elements.h5 ? {
font: resolveFont(elements.h5.font, fonts),
size: elements.h5.size || 11,
color: elements.h5.color ? resolveColor(elements.h5.color, palette).replace('#', '') : '111111',
bold: elements.h5.bold,
italic: elements.h5.italic,
align: elements.h5.align,
spacing: elements.h5.spacing ? {
before: toTwips(elements.h5.spacing.before) || 0,
after: toTwips(elements.h5.spacing.after) || 0,
line: toLineTwips(elements.h5.spacing.line) || 240
} : undefined
} : undefined,
heading6: elements.h6 ? {
font: resolveFont(elements.h6.font, fonts),
size: elements.h6.size || 10,
color: elements.h6.color ? resolveColor(elements.h6.color, palette).replace('#', '') : '111111',
bold: elements.h6.bold,
italic: elements.h6.italic,
align: elements.h6.align,
spacing: elements.h6.spacing ? {
before: toTwips(elements.h6.spacing.before) || 0,
after: toTwips(elements.h6.spacing.after) || 0,
line: toLineTwips(elements.h6.spacing.line) || 240
} : undefined
} : undefined
};
}
/**
* Gets complete wordConfig, preferring generated config over legacy
*/
export function getWordConfig(template: StyleOption) {
// If template has unified structure, generate config from it
if (template.elements && template.typography) {
const fonts = template.typography.fonts || {};
const palette = template.typography.colors || {};
const elements = template.elements;
return generateWordConfig(elements, fonts, palette);
}
// Fall back to legacy wordConfig
return template.wordConfig;
}