UNPKG

page-visualizer

Version:

TypeScript library for rendering and visualizing pages of digital books, comics, manga, or interactive content

531 lines (446 loc) 17.4 kB
/** * SVG-based renderer for PageVisualizer * Provides scalable vector graphics rendering */ import { Page, Background, TextBlock, ImageElement, RenderingContext, Position, TextFormatting, BorderOptions, Animation, } from '../types'; import { parsePositionValue, loadImage, createSVG, sanitizeHtml, } from '../utils'; export class SVGRenderer { private context: RenderingContext; private loadedImages: Map<string, string> = new Map(); constructor(context: RenderingContext) { this.context = context; } /** * Render a complete page */ public async renderPage(page: Page): Promise<void> { if (!this.context.svg) { throw new Error('SVG context not available'); } const { svg, width, height } = this.context; // Clear SVG svg.innerHTML = ''; try { // Render background if (page.background) { await this.renderBackground(page.background, width, height); } // Render images first (lower z-index) if (page.images) { const sortedImages = [...page.images].sort((a, b) => (a.zIndex || 0) - (b.zIndex || 0)); for (const image of sortedImages) { await this.renderImage(image, width, height); } } // Render text blocks if (page.textBlocks) { const sortedTextBlocks = [...page.textBlocks].sort((a, b) => (a.zIndex || 0) - (b.zIndex || 0)); for (const textBlock of sortedTextBlocks) { this.renderTextBlock(textBlock, width, height); } } // Apply animations if (page.animations) { this.applyAnimations(page.animations); } } catch (error) { console.error('Error rendering page:', error); throw error; } } /** * Render background */ private async renderBackground(background: Background, width: number, height: number): Promise<void> { if (!this.context.svg) return; const { svg } = this.context; if (background.color) { const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); rect.setAttribute('x', '0'); rect.setAttribute('y', '0'); rect.setAttribute('width', width.toString()); rect.setAttribute('height', height.toString()); rect.setAttribute('fill', background.color); svg.appendChild(rect); } if (background.gradient) { const gradientId = `gradient-${Math.random().toString(36).substr(2, 9)}`; const gradientMatch = background.gradient.match(/linear-gradient\((\d+)deg,\s*([^,]+),\s*([^)]+)\)/); if (gradientMatch) { const [, angle, color1, color2] = gradientMatch; // Create gradient definition const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs'); const gradient = document.createElementNS('http://www.w3.org/2000/svg', 'linearGradient'); gradient.setAttribute('id', gradientId); gradient.setAttribute('x1', '0%'); gradient.setAttribute('y1', '0%'); gradient.setAttribute('x2', '100%'); gradient.setAttribute('y2', '100%'); gradient.setAttribute('gradientTransform', `rotate(${angle})`); const stop1 = document.createElementNS('http://www.w3.org/2000/svg', 'stop'); stop1.setAttribute('offset', '0%'); stop1.setAttribute('stop-color', (color1 ?? '').trim()); const stop2 = document.createElementNS('http://www.w3.org/2000/svg', 'stop'); stop2.setAttribute('offset', '100%'); stop2.setAttribute('stop-color', (color2 ?? '').trim()); gradient.appendChild(stop1); gradient.appendChild(stop2); defs.appendChild(gradient); svg.appendChild(defs); // Create rectangle with gradient const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); rect.setAttribute('x', '0'); rect.setAttribute('y', '0'); rect.setAttribute('width', width.toString()); rect.setAttribute('height', height.toString()); rect.setAttribute('fill', `url(#${gradientId})`); svg.appendChild(rect); } } if (background.image) { try { const imageDataUrl = await this.loadImageAsDataURL(background.image); const image = document.createElementNS('http://www.w3.org/2000/svg', 'image'); image.setAttribute('x', '0'); image.setAttribute('y', '0'); image.setAttribute('width', width.toString()); image.setAttribute('height', height.toString()); image.setAttribute('href', imageDataUrl); image.setAttribute('preserveAspectRatio', 'xMidYMid slice'); svg.appendChild(image); } catch (error) { console.warn('Failed to load background image:', error); } } } /** * Render text block */ private renderTextBlock(textBlock: TextBlock, containerWidth: number, containerHeight: number): void { if (!this.context.svg) return; const { svg } = this.context; const { x, y, width, height } = this.parsePosition(textBlock.position, containerWidth, containerHeight); // Create text element const text = document.createElementNS('http://www.w3.org/2000/svg', 'text'); text.setAttribute('x', x.toString()); text.setAttribute('y', (y + parsePositionValue(textBlock.formatting.fontSize, 16)).toString()); text.setAttribute('width', width.toString()); text.setAttribute('height', height.toString()); // Apply text formatting this.applyTextFormatting(text, textBlock.formatting); // Handle text alignment const alignment = textBlock.position.alignment || 'left'; if (alignment === 'center') { text.setAttribute('text-anchor', 'middle'); text.setAttribute('x', (x + width / 2).toString()); } else if (alignment === 'right') { text.setAttribute('text-anchor', 'end'); text.setAttribute('x', (x + width).toString()); } // Handle text wrapping const lines = this.wrapText(textBlock.content, textBlock.formatting, width); if (lines.length === 1) { text.textContent = sanitizeHtml(lines[0] ?? ''); svg.appendChild(text); } else { // Create tspan elements for multi-line text const lineHeight = parsePositionValue(textBlock.formatting.lineHeight || textBlock.formatting.fontSize, 16); for (let i = 0; i < lines.length; i++) { const tspan = document.createElementNS('http://www.w3.org/2000/svg', 'tspan'); tspan.setAttribute('x', text.getAttribute('x') || '0'); tspan.setAttribute('dy', i === 0 ? '0' : lineHeight.toString()); tspan.textContent = sanitizeHtml(lines[i] ?? ''); text.appendChild(tspan); } svg.appendChild(text); } // Add text decoration if (textBlock.formatting.textDecoration === 'underline') { const line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); line.setAttribute('x1', x.toString()); line.setAttribute('y1', (y + height - 2).toString()); line.setAttribute('x2', (x + width).toString()); line.setAttribute('y2', (y + height - 2).toString()); line.setAttribute('stroke', textBlock.formatting.color); line.setAttribute('stroke-width', '1'); svg.appendChild(line); } } /** * Render image element */ private async renderImage(image: ImageElement, containerWidth: number, containerHeight: number): Promise<void> { if (!this.context.svg) return; const { svg } = this.context; const { x, y, width, height } = this.parsePosition(image.position, containerWidth, containerHeight); try { const imageDataUrl = await this.loadImageAsDataURL(image.src); const imageElement = document.createElementNS('http://www.w3.org/2000/svg', 'image'); imageElement.setAttribute('x', x.toString()); imageElement.setAttribute('y', y.toString()); imageElement.setAttribute('width', width.toString()); imageElement.setAttribute('height', height.toString()); imageElement.setAttribute('href', imageDataUrl); imageElement.setAttribute('preserveAspectRatio', 'xMidYMid meet'); if (image.altText) { imageElement.setAttribute('alt', image.altText); } // Apply filters if specified if (image.filters && image.filters.length > 0) { const filterId = `filter-${Math.random().toString(36).substr(2, 9)}`; this.createFilter(filterId, image.filters); imageElement.setAttribute('filter', `url(#${filterId})`); } svg.appendChild(imageElement); // Draw border if specified if (image.border) { this.drawBorder(x, y, width, height, image.border); } } catch (error) { console.warn('Failed to load image:', image.src, error); // Draw placeholder const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); rect.setAttribute('x', x.toString()); rect.setAttribute('y', y.toString()); rect.setAttribute('width', width.toString()); rect.setAttribute('height', height.toString()); rect.setAttribute('fill', '#f0f0f0'); rect.setAttribute('stroke', '#ccc'); rect.setAttribute('stroke-width', '1'); svg.appendChild(rect); const text = document.createElementNS('http://www.w3.org/2000/svg', 'text'); text.setAttribute('x', (x + width / 2).toString()); text.setAttribute('y', (y + height / 2).toString()); text.setAttribute('text-anchor', 'middle'); text.setAttribute('fill', '#666'); text.setAttribute('font-family', 'Arial'); text.setAttribute('font-size', '14'); text.textContent = 'Image not found'; svg.appendChild(text); } } /** * Apply animations */ private applyAnimations(animations: Animation[]): void { for (const animation of animations) { const element = document.getElementById(animation.target); if (element) { element.style.transition = `all ${animation.duration}ms ease`; element.style.opacity = '0'; setTimeout(() => { element.style.opacity = '1'; }, animation.delay || 0); } } } /** * Load image as data URL */ private async loadImageAsDataURL(src: string): Promise<string> { if (this.loadedImages.has(src)) { return this.loadedImages.get(src)!; } const img = await loadImage(src); const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d')!; canvas.width = img.width; canvas.height = img.height; ctx.drawImage(img, 0, 0); const dataURL = canvas.toDataURL('image/png'); this.loadedImages.set(src, dataURL); return dataURL; } /** * Parse position to absolute coordinates */ private parsePosition(position: Position, containerWidth: number, containerHeight: number) { return { x: parsePositionValue(position.x, containerWidth), y: parsePositionValue(position.y, containerHeight), width: parsePositionValue(position.width, containerWidth), height: parsePositionValue(position.height, containerHeight), }; } /** * Apply text formatting to SVG text element */ private applyTextFormatting(textElement: SVGTextElement, formatting: TextFormatting): void { const fontSize = typeof formatting.fontSize === 'number' ? formatting.fontSize : parsePositionValue(formatting.fontSize, 16); textElement.setAttribute('font-family', formatting.fontFamily); textElement.setAttribute('font-size', fontSize.toString()); textElement.setAttribute('fill', formatting.color); if (formatting.fontStyle) { textElement.setAttribute('font-style', formatting.fontStyle); } if (formatting.fontWeight) { textElement.setAttribute('font-weight', formatting.fontWeight.toString()); } if (formatting.letterSpacing) { textElement.setAttribute('letter-spacing', formatting.letterSpacing.toString()); } if (formatting.opacity) { textElement.setAttribute('opacity', formatting.opacity.toString()); } if (formatting.shadow) { const shadowParts = formatting.shadow.split(' '); if (shadowParts.length >= 3) { const filterId = `shadow-${Math.random().toString(36).substr(2, 9)}`; this.createShadowFilter(filterId, shadowParts); textElement.setAttribute('filter', `url(#${filterId})`); } } } /** * Wrap text to fit within width */ private wrapText(content: string, formatting: TextFormatting, maxWidth: number): string[] { // Simple word wrapping implementation // In a full implementation, this would use proper text measurement const words = content.split(' '); const lines: string[] = []; let currentLine = ''; for (const word of words) { const testLine = currentLine + (currentLine ? ' ' : '') + word; // Approximate character width (this is simplified) const fontSize = typeof formatting.fontSize === 'number' ? formatting.fontSize : parsePositionValue(formatting.fontSize, 16); const estimatedWidth = testLine.length * fontSize * 0.6; if (estimatedWidth > maxWidth && currentLine) { lines.push(currentLine); currentLine = word; } else { currentLine = testLine; } } if (currentLine) { lines.push(currentLine); } return lines; } /** * Create SVG filter */ private createFilter(filterId: string, filters: string[]): void { if (!this.context.svg) return; let defs = this.context.svg.querySelector('defs'); if (!defs) { defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs'); this.context.svg.appendChild(defs); } const filter = document.createElementNS('http://www.w3.org/2000/svg', 'filter'); filter.setAttribute('id', filterId); for (const filterString of filters) { // Parse common CSS filters and convert to SVG if (filterString.includes('blur')) { const blurMatch = filterString.match(/blur\((\d+)px\)/); if (blurMatch) { const feGaussianBlur = document.createElementNS('http://www.w3.org/2000/svg', 'feGaussianBlur'); feGaussianBlur.setAttribute('stdDeviation', blurMatch[1] ?? '0'); filter.appendChild(feGaussianBlur); } } // Add more filter types as needed } defs.appendChild(filter); } /** * Create shadow filter */ private createShadowFilter(filterId: string, shadowParts: string[]): void { if (!this.context.svg) return; let defs = this.context.svg.querySelector('defs'); if (!defs) { defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs'); this.context.svg.appendChild(defs); } const filter = document.createElementNS('http://www.w3.org/2000/svg', 'filter'); filter.setAttribute('id', filterId); const feDropShadow = document.createElementNS('http://www.w3.org/2000/svg', 'feDropShadow'); feDropShadow.setAttribute('dx', shadowParts[0] || '0'); feDropShadow.setAttribute('dy', shadowParts[1] || '0'); feDropShadow.setAttribute('stdDeviation', shadowParts[3] || '0'); feDropShadow.setAttribute('flood-color', shadowParts[2] || '#000'); filter.appendChild(feDropShadow); defs.appendChild(filter); } /** * Draw border */ private drawBorder(x: number, y: number, width: number, height: number, border: BorderOptions): void { if (!this.context.svg) return; const { svg } = this.context; const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); rect.setAttribute('x', x.toString()); rect.setAttribute('y', y.toString()); rect.setAttribute('width', width.toString()); rect.setAttribute('height', height.toString()); rect.setAttribute('fill', 'none'); rect.setAttribute('stroke', border.color); rect.setAttribute('stroke-width', border.width.toString()); if (border.style === 'dashed') { rect.setAttribute('stroke-dasharray', '5,5'); } else if (border.style === 'dotted') { rect.setAttribute('stroke-dasharray', '2,2'); } if (border.radius) { rect.setAttribute('rx', border.radius.toString()); rect.setAttribute('ry', border.radius.toString()); } svg.appendChild(rect); } /** * Resize SVG */ public resize(width: number, height: number): void { if (!this.context.svg) return; this.context.svg.setAttribute('width', width.toString()); this.context.svg.setAttribute('height', height.toString()); this.context.svg.setAttribute('viewBox', `0 0 ${width} ${height}`); this.context.width = width; this.context.height = height; } /** * Get SVG data URL */ public getDataURL(): string { if (!this.context.svg) { throw new Error('SVG not available'); } const serializer = new XMLSerializer(); const svgString = serializer.serializeToString(this.context.svg); const encodedSvg = encodeURIComponent(svgString); return `data:image/svg+xml,${encodedSvg}`; } /** * Clear SVG */ public clear(): void { if (!this.context.svg) return; this.context.svg.innerHTML = ''; } /** * Cleanup resources */ public destroy(): void { this.loadedImages.clear(); } }