import type { CSSProperties, ReactNode } from 'react'; import { useId } from 'react'; import type { BookTextElement } from '@trek/shared'; import { bookDrawnLines, bookTextCurvePath, bookTextIsDrawn, bookTextPaintFill } from '@trek/shared'; import { fontStack } from './bookFonts'; import { photoSrc } from './bookRender'; import { paintParts, type PaintParts } from './paint/paintParts'; import { caseStyle, curveAnchor, isHollow, letterFill, lineAnchor, lineCentres, outlineStrokeMm, photoCover, ptToMm, svgId, withAlpha, } from './textEffects'; /** * A text with effects, drawn. * * ── Two ways of drawing, both vector in print ────────────────────────────── * * A text that still wraps (an outline, a shadow, a band, a case, a vertical * place) stays HTML type, drawn as a stack of identical layers: the shadow, a * copy offset behind; the band, a span background behind the words; the * outline, a copy stroked twice its width under the fill, so the line grows * outward and never thins the letter; then the fill. Every layer lays its * words out the same way, so they land on each other exactly, and none of them * is a blur, a filter or a `background-clip`, which print as pixels or wait * for nothing. * * A text on a curve or filled with a photograph is SVG: the curve is * `textPath` on a path computed in millimetres (book-text-curve.ts in shared), * the photo an `` clipped to the letters, which the print view waits * for like any other picture. Such a text is set line by line as written. * * Every id is this instance's own: a page drawn twice on one sheet (page mode) * must not have its second copy clip to the first copy's letters. */ const JUSTIFY = { top: 'flex-start', middle: 'center', bottom: 'flex-end' } as const; /** The type itself, the same on every layer of an HTML text. */ function typeStyle(el: BookTextElement): CSSProperties { return { position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', justifyContent: JUSTIFY[el.vAlign ?? 'top'], fontSize: `${el.size}pt`, fontFamily: fontStack(el.font), fontWeight: el.weight, fontStyle: el.italic ? 'italic' : undefined, lineHeight: el.leading, letterSpacing: `${el.tracking}em`, textAlign: el.align, whiteSpace: 'pre-wrap', hyphens: 'auto', ...caseStyle(el), }; } /** The words of one layer, padded as the band pads them so every layer breaks its lines alike. */ function Words({ el, band }: { el: BookTextElement; band?: string }) { const hl = el.highlight; const style: CSSProperties | undefined = hl ? { padding: `${hl.padY}mm ${hl.padX}mm`, borderRadius: hl.radius ? `${hl.radius}mm` : undefined, background: band, boxDecorationBreak: 'clone', WebkitBoxDecorationBreak: 'clone', } : undefined; return (
{el.text}
); } function LayeredText({ el, frameStyle }: { el: BookTextElement; frameStyle: CSSProperties }) { const type = typeStyle(el); const { outline, shadow, highlight } = el; const hollow = isHollow(el); const stroke = outline ? `${outlineStrokeMm(el)}mm` : undefined; const reaches = !!(outline || shadow || highlight); const layers: ReactNode[] = []; if (shadow) { const ink = withAlpha(shadow.color, shadow.alpha); layers.push(
); } if (highlight) { layers.push(
); } if (outline) { layers.push(
); } layers.push(
); return
{layers}
; } interface GlyphPaint { fill: string; stroke?: string; strokeWidth?: number; } /** A gradient or pattern in the letters, laid out over the frame as a shape's paint is over its box. */ function lettersPaint(el: BookTextElement, id: string): PaintParts | null { const paint = bookTextPaintFill(el); return paint ? paintParts(paint, { x: 0, y: 0, w: el.frame.w, h: el.frame.h }, id) : null; } /** What the letters are filled with: the paint's fill, else their own colour. */ function lettersFill(el: BookTextElement, paint: PaintParts | null): string { if (paint) return paint.fill; return letterFill(el) ?? el.color; } /** A pattern's ground and its marks, clipped to the letters. */ function PatternLetters({ parts, w, h, clipId }: { parts: PaintParts; w: number; h: number; clipId: string }) { const pattern = parts.pattern; if (!pattern) return null; return ( {parts.fill !== 'none' && } {pattern.layers.map((layer, i) => ( ))} ); } function DrawnText({ el, frameStyle, big }: { el: BookTextElement; frameStyle: CSSProperties; big: boolean }) { const reactId = useId(); const pathId = svgId('tp-', reactId); const clipId = svgId('tc-', reactId); const paintId = svgId('tf-', reactId); const { w, h } = el.frame; const fontMm = ptToMm(el.size); const lines = bookDrawnLines(el); const curve = el.curve ? bookTextCurvePath(el.curve, w, h, fontMm) : null; const photo = el.fillPaint?.type === 'photo' ? el.fillPaint : null; const letterPaint = lettersPaint(el, paintId); const pattern = letterPaint ? letterPaint.pattern : null; const hollow = isHollow(el); const { outline, shadow } = el; const textProps = { fontFamily: fontStack(el.font), fontSize: fontMm, fontWeight: el.weight, fontStyle: el.italic ? 'italic' : undefined, style: { letterSpacing: `${el.tracking}em`, fontVariantCaps: caseStyle(el).fontVariantCaps }, }; const glyphs = (paint: GlyphPaint | null, key: string) => { const painted = paint ? { ...paint, strokeLinejoin: 'round' as const } : {}; if (curve) { const at = curveAnchor(el.align); return ( {lines[0]} ); } const at = lineAnchor(el.align, w); const ys = lineCentres(lines.length, fontMm, el.leading, h, el.vAlign); return ( {lines.map((line, i) => ( {line || ' '} ))} ); }; const stroke = outlineStrokeMm(el); const layers: ReactNode[] = []; if (shadow) { const ink = withAlpha(shadow.color, shadow.alpha); layers.push( {glyphs( { fill: hollow ? 'none' : ink, stroke: stroke ? ink : undefined, strokeWidth: stroke || undefined }, 'g' )} ); } if (outline) { layers.push( glyphs({ fill: hollow ? 'none' : outline.color, stroke: outline.color, strokeWidth: stroke }, 'outline') ); } if (photo) { const cover = photoCover(w, h, photo); layers.push(); } else if (pattern && letterPaint) { layers.push(); } else { layers.push(glyphs({ fill: lettersFill(el, letterPaint) }, 'fill')); } return (
{curve && } {letterPaint?.defs} {(photo || pattern) && ( {glyphs(null, 'clip')} )} {layers}
); } /** A text element that has effects; SpreadView keeps drawing the plain ones itself. */ export function TextEffectsView({ el, frameStyle, big, }: { el: BookTextElement; frameStyle: CSSProperties; big: boolean; }) { if (bookTextIsDrawn(el)) return ; return ; }