- 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
434 lines
18 KiB
TypeScript
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;
|
|
}
|