/* ============================================================ * paper.jsx — PaperStrip component + presets * ============================================================ */ // === Paper color/texture presets (curated, refined) === const PAPER_COLORS = [ { id: "cream", name: "宣纸", bg: "#f3ead4", ink: "#1a1612" }, { id: "snow", name: "雪白", bg: "#f8f4ea", ink: "#1a1612" }, { id: "rice", name: "米黄", bg: "#eadaa6", ink: "#1a1612" }, { id: "kraft", name: "牛皮", bg: "#c9a878", ink: "#2a1c10" }, { id: "dan", name: "丹朱", bg: "#c4453a", ink: "#1a1612" }, { id: "vermilion", name: "正红", bg: "#a8261b", ink: "#f2dd9a" }, { id: "ochre", name: "赭石", bg: "#c97e3a", ink: "#1a1612" }, { id: "saffron", name: "藤黄", bg: "#e8a93a", ink: "#1a1612" }, { id: "celadon", name: "青瓷", bg: "#a8c2b3", ink: "#1a1612" }, { id: "jade", name: "石绿", bg: "#7fa089", ink: "#1a1612" }, { id: "indigo", name: "靛青", bg: "#9fb6c9", ink: "#1a1612" }, { id: "twilight", name: "天青", bg: "#7f9bb2", ink: "#1a1612" }, { id: "plum", name: "胭脂", bg: "#e9b6b1", ink: "#1a1612" }, { id: "lilac", name: "紫藤", bg: "#b8a4c4", ink: "#1a1612" }, { id: "sand", name: "枯叶", bg: "#a89172", ink: "#2a1c10" }, { id: "moss", name: "苔色", bg: "#8a8a48", ink: "#1a1612" }, { id: "ink", name: "墨色", bg: "#2a2520", ink: "#d4b56a" }, { id: "gold", name: "金粉", bg: "#caa55a", ink: "#1a1612" }, ]; // === Paper textures (overlay patterns) === const PAPER_TEXTURES = [ { id: "plain", name: "素", svg: null }, { id: "cloud", name: "祥云", svg: "cloud" }, { id: "gold", name: "洒金", svg: "gold-fleck" }, { id: "wave", name: "水波", svg: "wave" }, { id: "kikko", name: "龟甲", svg: "kikko" }, { id: "bamboo", name: "竹叶", svg: "bamboo" }, { id: "blossom", name: "梅花", svg: "blossom" }, { id: "fibers", name: "纸纹", svg: "fibers" }, ]; // === Fonts (calligraphy styles) === const FONTS = [ { id: "kai", name: "楷书", family: '"Noto Serif SC", serif', weight: 600 }, { id: "song", name: "宋体", family: '"Noto Serif SC", serif', weight: 700 }, { id: "xing", name: "行书", family: '"Ma Shan Zheng", cursive', weight: 400 }, { id: "cao", name: "草书", family: '"Long Cang", cursive', weight: 400 }, { id: "caoZhi", name: "狂草", family: '"Zhi Mang Xing", cursive', weight: 400 }, { id: "li", name: "隶书", family: '"ZCOOL XiaoWei", serif', weight: 400 }, { id: "zhuan", name: "篆书", family: '"ZCOOL QingKe HuangYou", serif', weight: 400 }, { id: "shou", name: "瘦金", family: '"ZCOOL XiaoWei", serif', weight: 300, letterSpacing: "-0.02em" }, { id: "maocao", name: "毛草", family: '"Liu Jian Mao Cao", cursive', weight: 400 }, ]; // === Shapes === // Each shape returns {aspectRatio, clipPath, isRound, defaultVertical} const SHAPES = { "rect-v": { name: "竖长条", ar: 0.28, defaultVertical: true, clip: null }, "rect-h": { name: "横长条", ar: 3.6, defaultVertical: false, clip: null }, "square": { name: "方胜", ar: 1, defaultVertical: false, clip: null }, "diamond": { name: "菱形", ar: 1, defaultVertical: false, clip: "polygon(50% 0, 100% 50%, 50% 100%, 0 50%)" }, "circle": { name: "圆福", ar: 1, defaultVertical: false, clip: "circle(50% at 50% 50%)" }, "coin": { name: "铜钱", ar: 1, defaultVertical: false, clip: "circle(50% at 50% 50%)", innerHole: true }, "gourd": { name: "葫芦", ar: 0.66, defaultVertical: true, clip: "path('M50 5 C30 5, 28 20, 32 32 C36 42, 22 48, 22 65 C22 85, 78 85, 78 65 C78 48, 64 42, 68 32 C72 20, 70 5, 50 5 Z')", clipSvgViewBox: "0 0 100 100" }, "flower": { name: "花瓣", ar: 1, defaultVertical: false, clip: "flower" }, "pouch": { name: "福袋", ar: 0.85, defaultVertical: false, clip: "pouch" }, "tall": { name: "细长条", ar: 0.18, defaultVertical: true, clip: null }, }; // Patterns are inline SVG data URLs function makePatternUrl(kind, color = "#000", opacity = 0.4) { const c = encodeURIComponent(color); const a = opacity; let svg = ""; switch (kind) { case "cloud": svg = ` `; break; case "wave": svg = ` `; break; case "kikko": svg = ` `; break; case "bamboo": svg = ` `; break; case "blossom": svg = ` `; break; case "fibers": svg = ` `; break; case "gold-fleck": // gold flecks rendered separately, handled by gold-fleck layer return null; default: return null; } return `url("data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}")`; } function makeGoldFleckSvg(seed = 0, density = 30) { // Deterministic random based on seed let s = seed * 9301 + 49297; const rand = () => { s = (s * 9301 + 49297) % 233280; return s / 233280; }; const flecks = []; for (let i = 0; i < density; i++) { const x = rand() * 100; const y = rand() * 100; const r = 0.4 + rand() * 1.2; const op = 0.4 + rand() * 0.5; flecks.push(``); } // larger flakes for (let i = 0; i < density / 3; i++) { const x = rand() * 100; const y = rand() * 100; const w = 1.5 + rand() * 2.5; const h = 0.8 + rand() * 1.2; const rot = rand() * 360; flecks.push(``); } return `${flecks.join("")}`; } // === Shape custom SVG masks (for flower, pouch, etc.) === function ShapeMaskDef({ id, kind }) { if (kind === "flower") { // 4-petal flower return ( ); } if (kind === "pouch") { return ( ); } if (kind === "gourd") { return ( ); } return null; } // === PaperStrip component === function PaperStrip({ text = "未来可期", shape = "rect-v", paperId = "cream", textureId = "plain", fontId = "xing", fontScale = 1, textColor = null, // overrides default ink paperColor = null, // overrides default bg showSeal = true, sealText = "福", sealPosition = "bottom", // top|bottom|tl|br sealRound = false, showBorder = false, borderStyle = "single", // single|double showGoldFleck = false, vertical = null, // null = use shape default width = 100, // % or px depending on parent fillSize = false, // collage-related rotate = 0, shadow = "soft", // soft|hard|none paperWear = false, seed = 1, }) { const paperPreset = PAPER_COLORS.find(p => p.id === paperId) || PAPER_COLORS[0]; const shapeDef = SHAPES[shape] || SHAPES["rect-v"]; const font = FONTS.find(f => f.id === fontId) || FONTS[0]; const isVertical = vertical !== null ? vertical : shapeDef.defaultVertical; const bg = paperColor || paperPreset.bg; const ink = textColor || paperPreset.ink; const aspectRatio = shapeDef.ar; const clipMaskId = `mask-${shape}-${seed}`; const needsSvgClip = shapeDef.clip === "flower" || shapeDef.clip === "pouch" || shapeDef.clip === "gourd"; const clipPath = needsSvgClip ? `url(#${clipMaskId})` : (shapeDef.clip || undefined); // Font size — strip-text is its own size container; chars fill it const chars = Array.from(text).filter(c => c.trim()); const charCount = chars.length || 1; let fontSize; if (isVertical) { fontSize = `calc(min(92cqw, ${(92 / charCount).toFixed(1)}cqh) * ${fontScale})`; } else { fontSize = `calc(min(92cqh, ${(92 / charCount).toFixed(1)}cqw) * ${fontScale})`; } const patternUrl = makePatternUrl(textureId === "gold" ? null : textureId, ink, 0.5); // shadow style let boxShadow; if (shadow === "soft") boxShadow = "0 6px 16px -8px rgba(0,0,0,0.35), 0 2px 4px -2px rgba(0,0,0,0.2)"; else if (shadow === "hard") boxShadow = "0 10px 0 -6px rgba(0,0,0,0.15), 0 14px 30px -10px rgba(0,0,0,0.4)"; else boxShadow = "none"; const sealCharFinal = (sealText && sealText.length > 0) ? sealText[0] : ""; // Seal layout mode const sealIsCorner = sealPosition === "tl" || sealPosition === "br"; const sealCornerStyle = { tl: { top: "4%", left: "4%" }, br: { bottom: "4%", right: "4%" }, }[sealPosition]; // Content container direction. Items are in JSX order: text, then seal. // We want: when sealPosition=top, seal first (at top). For 'col', seal needs to be first in DOM. // Simpler: render JSX order [text, seal], then for top use column-reverse / row-reverse. let contentClass = "strip-content "; if (!sealIsCorner && showSeal && sealCharFinal) { if (isVertical) { contentClass += sealPosition === "top" ? "rev" : "col"; } else { contentClass += sealPosition === "top" ? "rev-row" : "row"; } } else { contentClass += isVertical ? "col" : "row"; } // Glyph styling for vertical: each char is one cell const glyphStyle = { fontFamily: font.family, fontWeight: font.weight, color: ink, fontSize: fontSize, letterSpacing: font.letterSpacing || (isVertical ? "0" : "0.04em"), lineHeight: 1, }; // Background composition const baseBg = bg; // subtle inner shading const innerShade = `radial-gradient(120% 80% at 50% 0%, rgba(255,255,255,0.18), transparent 60%), radial-gradient(120% 80% at 50% 100%, rgba(0,0,0,0.1), transparent 60%)`; return (
{needsSvgClip && ( )} {/* paper base */}
{/* pattern */} {patternUrl && (
)} {/* gold fleck (texture or explicit) */} {(textureId === "gold" || showGoldFleck) && (
)} {/* border */} {showBorder && (
)} {/* Coin hole */} {shape === "coin" && (
)} {/* content (text + inline seal) */}
{chars.map((c, i) => ( {c} ))}
{showSeal && sealCharFinal && !sealIsCorner && (
{sealCharFinal}
)}
{/* corner seal (absolute) */} {showSeal && sealCharFinal && sealIsCorner && (
{sealCharFinal}
)}
); } // === Reusable shape icon (for shape picker) === function ShapeIcon({ kind }) { const s = SHAPES[kind] || SHAPES["rect-v"]; const fill = "var(--ink-soft)"; const w = 60; const h = 60; // Render a small representation if (kind === "rect-v") return (); if (kind === "tall") return (); if (kind === "rect-h") return (); if (kind === "square") return (); if (kind === "diamond")return (); if (kind === "circle") return (); if (kind === "coin") return (); if (kind === "gourd") return (); if (kind === "flower") return (); if (kind === "pouch") return (); return ; } // expose Object.assign(window, { PaperStrip, ShapeIcon, PAPER_COLORS, PAPER_TEXTURES, FONTS, SHAPES });