coverizejs
Version:
Intelligent book cover typesetting for visually balanced, multi-line titles and authors.
521 lines (429 loc) • 18.6 kB
JavaScript
/*╭────────────────────────────────────────────────╮
│ COVERIZE.JS - Book Cover Generation Library │
│ Version: 1.0.0 │
│ License: GPL-3.0 │
╰────────────────────────────────────────────────╯*/
(function(window) {
'use strict';
// =================================================================
// CONSTANTS & CONFIGURATION
// =================================================================
const CONFIG = {
MAX_LINE_LENGTH: 12,
DEFAULT_GRADIENT: 'linear-gradient(145deg, #667eea 0%, #764ba2 100%)',
RESIZE_THROTTLE: 16, // ~60fps
FONT_SCALE_MIN: 0.3,
FONT_SCALE_MAX: 2.5,
BASE_WIDTH: 200
};
const COLOR_PRESETS = [
['#e6fdf5', '#2c3861'], ['#c5f3e3', '#3a4254'], ['#e6de88', '#385652'], ['#e8bf68', '#e77352'],
['#f4a436', '#6fb295'], ['#eada85', '#850b07'], ['#f3bebe', '#1271be'], ['#f87e85', '#857ef8'],
['#f5a665', '#3a4857'], ['#c0c0c0', '#4b545b'], ['#6d727f', '#e54c4c'], ['#547656', '#4e3135']
];
const COLOR_PRESET_NAMES = [
'Pearl Shore', 'Sage Abbey', 'Mossy Hollow', 'Honey Chapel', 'Olive Grove', 'Sienna Reach',
'Azure Vale', 'Carmine Bay', 'Copper Barrow', 'Pewter Steppe', 'Brandy Copse', 'Jasper Forge'
];
// Typography & Layout Constants
const SECONDARY_WORDS = new Set([
'a', 'an', 'the', 'and', 'but', 'or', 'for', 'nor', 'on', 'at', 'to', 'from', 'by', 'in', 'of',
'with', 'as', 'is', 'are', 'was', 'were', 'be', 'been', 'has', 'have', 'had', '&', 'vs', 'vs.',
'etc', 'etc.', 'i.e.', 'e.g.', 'et', 'al', 'et al', 'et al.'
]);
const COMMON_PHRASELETS = [
['of', 'the'], ['in', 'the'], ['on', 'the'], ['to', 'the'], ['at', 'the'], ['by', 'the'],
['for', 'the'], ['with', 'the'], ['from', 'the'], ['is', 'the'], ['was', 'the'], ['and', 'the'],
['of', 'a'], ['in', 'a'], ['on', 'a'], ['to', 'a'], ['at', 'a'], ['by', 'a'], ['for', 'a'], ['with', 'a']
];
// Layout Pattern Rules
const KNOWN_PATTERNS = {
'PP': [['P', 'P']], 'SP': [['S'], ['P']], 'PS': [['P'], ['S']], 'SS': [['S', 'S']],
'PSP': [['P'], ['S'], ['P']], 'PPS': [['P', 'P'], ['S']], 'SPP': [['S'], ['P'], ['P']],
'PPP': [['P'], ['P'], ['P']], 'SSP': [['S', 'S'], ['P']], 'PSS': [['P'], ['S', 'S']],
'SPS': [['S'], ['P'], ['S']], 'SSS': [['S', 'S'], ['S']],
'PSSP': [['P'], ['S', 'S'], ['P']], 'SPSP': [['S'], ['P'], ['S'], ['P']],
'PPPP': [['P'], ['P', 'P'], ['P']], 'SSPP': [['S', 'S'], ['P'], ['P']],
'PPSS': [['P'], ['P'], ['S', 'S']], 'SSSS': [['S', 'S'], ['S', 'S']],
'PSPSP': [['P'], ['S'], ['P'], ['S'], ['P']], 'SSPSP': [['S', 'S'], ['P'], ['S'], ['P']],
'SPSPP': [['S'], ['P'], ['S'], ['P'], ['P']]
};
// =================================================================
// TYPESETTING ENGINE
// =================================================================
/**
* Main typesetting function that processes title and author text
* @param {string} title - The book title
* @param {string} author - The author name
* @returns {Array} Array of line objects with text, emphasis, role, and size properties
*/
function typeset(title, author) {
if (!title && author) {
return [{ text: author, emphasis: false, role: 'author', size: 1.0 }];
}
if (!title && !author) return [];
const titleLayout = title ? typesetTitle(title) : [];
const processedLayout = titleLayout.map((line, index) => ({
...line,
text: applySmartTitleCase(line.text, index === 0)
}));
if (author) {
processedLayout.push({ text: author, emphasis: false, role: 'author', size: 1.0 });
}
return processedLayout.map(({ text, emphasis, role, size }) => ({ text, emphasis, role, size }));
}
/**
* Calculate responsive font scale based on text length
* @param {number} length - Character length of text
* @returns {number} Scale factor (0.2 to 1.0)
*/
function calculateScaleForLength(length) {
if (length <= 8) return 1.0;
if (length <= 12) return 1.0 - ((length - 8) / 4) * 0.15;
if (length <= 20) return 0.85 - ((length - 12) / 8) * 0.35;
if (length <= 30) return 0.5 - ((length - 20) / 10) * 0.2;
return Math.max(0.2, 0.3 - ((length - 30) / 20) * 0.1);
}
function typesetTitle(title) {
const words = title.split(' ');
if (words.length === 1) {
// Single word titles get smooth scaling based on length
const size = calculateScaleForLength(words[0].length);
return [{ text: words[0], emphasis: true, size }];
}
const groupedWords = groupPhraselets(words);
const wordData = analyzeWordImportance(groupedWords);
const result = determineLineBreaks(wordData);
return result.flatMap(strictSplit);
}
function applySmartTitleCase(text, isFirstWord) {
return text.split(' ').map((word, i) => {
const isSecondary = SECONDARY_WORDS.has(word.toLowerCase());
return (isSecondary && !(i === 0 && isFirstWord))
? word.toLowerCase()
: word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
}).join(' ');
}
function groupPhraselets(words) {
const processedWords = words.flatMap(word => {
if (word.includes('-') && word.length > 12) {
const parts = word.split('-');
return parts.flatMap((part, i) => i < parts.length - 1 ? [part, '-'] : [part]);
}
return [word];
});
const result = [];
let i = 0;
while (i < processedWords.length) {
if (processedWords[i] === '&') {
result.push(processedWords[i++]);
continue;
}
const phraselet = COMMON_PHRASELETS.find(p =>
i + p.length <= processedWords.length &&
p.every((word, j) => word.toLowerCase() === processedWords[i + j].toLowerCase())
);
if (phraselet) {
result.push(processedWords.slice(i, i + phraselet.length).join(' '));
i += phraselet.length;
} else {
result.push(processedWords[i++]);
}
}
return result;
}
/**
* Analyze word importance for layout decisions
* @param {Array} words - Array of word strings
* @returns {Array} Array of word objects with metadata
*/
function analyzeWordImportance(words) {
return words.map((word, index) => {
const isNaturallySecondary = word.includes(' ')
? word.split(' ').every(part => SECONDARY_WORDS.has(part.toLowerCase()))
: SECONDARY_WORDS.has(word.toLowerCase());
// Treat final secondary words as primary for emphasis
const isLastWord = index === words.length - 1;
const isSecondary = isNaturallySecondary && !isLastWord;
const weight = word.length * (isSecondary ? 0.6 : 1) * (/[A-Z]/.test(word) ? 1.2 : 1);
return { word, isSecondary, index, weight };
});
}
function determineLineBreaks(wordData) {
const pattern = wordData.map(w => w.isSecondary ? 'S' : 'P').join('');
const words = wordData.map(w => w.word);
// Handle special cases
if (words.length === 2 && pattern === 'PP') {
return words.map(word => ({ text: word, emphasis: true }));
}
// Pattern-based rules
if (KNOWN_PATTERNS[pattern]) {
return applyPattern(KNOWN_PATTERNS[pattern], words);
}
// Fallback to balanced approach
return balanceLinesByWeight(wordData);
}
function applyPattern(template, words) {
const result = [];
let wordIndex = 0;
template.forEach(group => {
const groupWords = words.slice(wordIndex, wordIndex + group.length);
wordIndex += group.length;
result.push({
text: groupWords.join(' '),
emphasis: group[0] === 'P'
});
});
return result;
}
function balanceLinesByWeight(wordData) {
const groups = [];
let currentGroup = [wordData[0]];
let currentIsSecondary = wordData[0].isSecondary;
for (let i = 1; i < wordData.length; i++) {
if (wordData[i].isSecondary === currentIsSecondary) {
currentGroup.push(wordData[i]);
} else {
groups.push({
words: currentGroup,
isSecondary: currentIsSecondary
});
currentGroup = [wordData[i]];
currentIsSecondary = wordData[i].isSecondary;
}
}
groups.push({ words: currentGroup, isSecondary: currentIsSecondary });
return groups.map(group => ({
text: group.words.map(w => w.word).join(' '),
emphasis: !group.isSecondary
}));
}
function strictSplit(line) {
if (!line.emphasis || line.text.length <= CONFIG.MAX_LINE_LENGTH) {
const length = line.text.length;
const size = line.emphasis ? calculateScaleForLength(length) : 1.0;
return [{ ...line, size }];
}
const words = line.text.split(' ');
if (words.length === 1) {
// Handle very long single words with smooth scaling
const size = calculateScaleForLength(line.text.length);
return [{ ...line, size }];
}
// Find optimal split point
let bestSplit = 1;
let bestScore = Infinity;
for (let i = 1; i < words.length; i++) {
const left = words.slice(0, i).join(' ');
const right = words.slice(i).join(' ');
let score = 0;
if (left.length > CONFIG.MAX_LINE_LENGTH) score += (left.length - CONFIG.MAX_LINE_LENGTH) * 2;
if (right.length > CONFIG.MAX_LINE_LENGTH) score += (right.length - CONFIG.MAX_LINE_LENGTH) * 2;
if (i === 1 && words.length > 2) score += 4;
if (i === words.length - 1 && words.length > 2) score += 4;
score += Math.abs(left.length - right.length);
if (score < bestScore) {
bestScore = score;
bestSplit = i;
}
}
const leftLine = { ...line, text: words.slice(0, bestSplit).join(' ') };
const rightLine = { ...line, text: words.slice(bestSplit).join(' ') };
return [...strictSplit(leftLine), ...strictSplit(rightLine)];
}
// COVER CLASS
class Cover {
constructor() {
this._title = '';
this._author = '';
this._colors = [];
this._image = null;
this._effects = { realism: false, texture: false, depth: false };
this._options = { ratio: 0.67, font: 'sans', emphasis: 'both', size: 'regular' };
}
title(text) { this._title = text; return this; }
author(text) { this._author = text; return this; }
image(src) { this._image = src; return this; }
effects(obj) { Object.assign(this._effects, obj); return this; }
options(obj) { Object.assign(this._options, obj); return this; }
size(newSize) {
this._options.size = newSize;
const covers = document.querySelectorAll('.coverize-cover');
covers.forEach(cover => {
// Remove existing size classes
cover.classList.remove('coverize-size-small', 'coverize-size-regular', 'coverize-size-large');
// Add new size class
cover.classList.add(`coverize-size-${newSize}`);
if (cover._updateFontSize) cover._updateFontSize();
});
return this;
}
color(color1, color2) {
if (typeof color1 === 'number' && color2 === undefined) {
if (color1 >= 0 && color1 < COLOR_PRESETS.length) {
this._colors = [...COLOR_PRESETS[color1]];
} else {
console.warn(`Color preset ${color1} not found. Valid presets are 0-${COLOR_PRESETS.length - 1}.`);
this._colors = [];
}
} else {
this._colors = color2 ? [color1, color2] : color1 ? [color1] : [];
}
return this;
}
render(container) {
const cover = this._createElement('div', 'coverize-cover coverize');
cover.style.aspectRatio = this._options.ratio.toString();
// Fallback for browsers without aspect-ratio support
if (!CSS.supports('aspect-ratio', '1')) {
cover.style.height = `${Math.round(100 / this._options.ratio)}px`;
}
cover.classList.add(`coverize-size-${this._options.size}`);
if (this._effects.realism) cover.setAttribute('data-realism', 'true');
if (this._effects.texture) cover.setAttribute('data-texture', 'true');
if (this._effects.depth) cover.setAttribute('data-depth', 'true');
cover.appendChild(this._createBackground());
cover.appendChild(this._createTypeset());
cover.appendChild(this._createEffects());
if (container) container.appendChild(cover);
this._setupResponsiveScaling(cover);
return cover;
}
_createElement(tag, className, textContent) {
const el = document.createElement(tag);
if (className) el.className = className;
if (textContent) el.textContent = textContent;
return el;
}
/**
* Create background element with colors or gradient
* @returns {HTMLElement} Background element
*/
_createBackground() {
const bg = this._createElement('div', 'coverize-background');
if (this._colors.length >= 2) {
bg.style.background = `linear-gradient(165deg, ${this._colors[0]} -25%, ${this._colors[1]} 125%)`;
} else if (this._colors.length === 1) {
bg.style.background = this._colors[0];
} else {
bg.style.background = CONFIG.DEFAULT_GRADIENT;
}
if (this._image) {
const img = this._createElement('img', 'coverize-image');
img.src = this._image;
bg.appendChild(img);
}
return bg;
}
_createTypeset() {
const typesetElement = this._createElement('div', 'coverize-typeset');
if (!this._title && !this._author) return typesetElement;
const lines = typeset(this._title, this._author);
let titleBlock, authorBlock;
lines.forEach((line, index) => {
const lineEl = this._createElement('div', '', line.text);
if (line.role === 'author') {
if (!authorBlock) {
authorBlock = this._createElement('div', 'coverize-author-block coverize-text-debossed');
typesetElement.appendChild(authorBlock);
}
lineEl.className = 'coverize-typeset-line coverize-author';
if (this._options.font === 'serif') lineEl.classList.add('coverize-author-serif');
authorBlock.appendChild(lineEl);
} else {
if (!titleBlock) {
titleBlock = this._createElement('div', 'coverize-title-block coverize-text-debossed');
typesetElement.appendChild(titleBlock);
}
lineEl.className = 'coverize-typeset-line coverize-title';
if (line.emphasis) {
if (this._options.emphasis === 'bold' || this._options.emphasis === 'both') {
lineEl.classList.add('coverize-line--bold');
}
if (this._options.emphasis === 'case' || this._options.emphasis === 'both') {
lineEl.classList.add('coverize-line--uppercase');
}
if (line.size !== 1) {
lineEl.style.fontSize = `calc(var(--coverize-title-size) * ${line.size})`;
}
} else {
lineEl.classList.add('coverize-line--secondary');
if (line.size !== 1) {
lineEl.style.fontSize = `calc(var(--coverize-secondary-size) * ${line.size})`;
} else {
lineEl.style.fontSize = 'var(--coverize-secondary-size)';
}
}
if (this._options.font === 'serif') lineEl.classList.add('coverize-title-serif');
titleBlock.appendChild(lineEl);
}
});
// Add fade-out class to title block if both title and author are present
if (titleBlock && authorBlock) {
titleBlock.classList.add('has-author');
}
return typesetElement;
}
_createEffects() {
const effects = this._createElement('div', 'coverize-effects');
if (this._effects.realism) this._addRealismEffects(effects);
if (this._effects.texture) this._addTextureEffects(effects);
if (this._effects.depth) this._addDepthEffects(effects);
return effects;
}
_addTextureEffects(container) {
container.appendChild(this._createElement('div', 'coverize-texture'));
}
_addRealismEffects(container) {
container.appendChild(this._createElement('div', 'coverize-realism'));
container.appendChild(this._createElement('div', 'coverize-spine'));
container.appendChild(this._createElement('div', 'coverize-sheen'));
}
_addDepthEffects(container) {
container.appendChild(this._createElement('div', 'coverize-depth'));
}
/**
* Setup responsive font scaling for the cover
* @param {HTMLElement} cover - The cover element
*/
_setupResponsiveScaling(cover) {
const updateFontSize = () => {
const width = cover.offsetWidth;
if (!width || width <= 0) return;
let baseFontSize = (width / CONFIG.BASE_WIDTH);
baseFontSize = Math.max(CONFIG.FONT_SCALE_MIN, Math.min(CONFIG.FONT_SCALE_MAX, baseFontSize));
const sizeMultipliers = { 'small': 0.5, 'regular': 1.0, 'large': 1.5 };
let currentSize = 'regular';
if (cover.classList.contains('coverize-size-small')) currentSize = 'small';
else if (cover.classList.contains('coverize-size-large')) currentSize = 'large';
const multiplier = sizeMultipliers[currentSize] || 1.0;
const finalSize = baseFontSize * multiplier;
cover.style.fontSize = `${finalSize.toFixed(2)}rem`;
};
cover._updateFontSize = updateFontSize;
updateFontSize(); // Initial update
// Throttled resize listener
let resizeTimeout;
const handleResize = () => {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(updateFontSize, CONFIG.RESIZE_THROTTLE);
};
window.addEventListener('resize', handleResize);
cover._resizeHandler = handleResize;
}
// Cleanup method
destroy(cover) {
if (cover._resizeHandler) {
window.removeEventListener('resize', cover._resizeHandler);
delete cover._resizeHandler;
}
return this;
}
}
// PUBLIC API
window.Coverize = {
cover: () => new Cover(),
typeset,
presets: { colors: COLOR_PRESETS, names: COLOR_PRESET_NAMES }
};
})(window);