Download index.html from ysharma/text-behind-image-cp1: direct link, hf CLI and curl.
- Browser
- Download file 43.3 kB
-
https://huggingface.co/spaces/ysharma/text-behind-image-cp1/resolve/main/index.html
- Command line
-
hf download hf://spaces/ysharma/text-behind-image-cp1/index.html
-
curl -L -o index.html https://huggingface.co/spaces/ysharma/text-behind-image-cp1/resolve/main/index.html
43.3 kB
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Text Behind Image</title> | |
| <link href="https://fonts.googleapis.com/css2?family=Anton&family=Bebas+Neue&family=Dancing+Script&family=Lobster&family=Montserrat:wght@100;400;700;900&family=Open+Sans:wght@300;400;700&family=Oswald:wght@400;700&family=Pacifico&family=Permanent+Marker&family=Playfair+Display:wght@400;700;900&family=Raleway:wght@400;700;900&family=Roboto:wght@100;400;700;900&family=Abril+Fatface&family=Righteous&family=Bangers&family=Fredoka+One&family=Press+Start+2P&family=Satisfy" rel="stylesheet"> | |
| <style> | |
| *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } | |
| :root { | |
| --bg: #1a1a2e; | |
| --surface: #16213e; | |
| --surface2: #0f3460; | |
| --accent: #e94560; | |
| --accent2: #533483; | |
| --text: #eee; | |
| --text-dim: #999; | |
| --border: #2a2a4a; | |
| --input-bg: #0d1b2a; | |
| --radius: 8px; | |
| } | |
| body { | |
| font-family: 'Roboto', sans-serif; | |
| background: var(--bg); | |
| color: var(--text); | |
| min-height: 100vh; | |
| overflow-x: hidden; | |
| } | |
| /* βββ Upload Screen βββ */ | |
| #upload-screen { | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| min-height: 100vh; | |
| padding: 2rem; | |
| } | |
| #upload-screen h1 { | |
| font-family: 'Montserrat', sans-serif; | |
| font-size: 2.5rem; | |
| font-weight: 900; | |
| margin-bottom: 0.5rem; | |
| background: linear-gradient(135deg, var(--accent), var(--accent2)); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| } | |
| #upload-screen .subtitle { | |
| color: var(--text-dim); | |
| margin-bottom: 2rem; | |
| font-size: 1.1rem; | |
| } | |
| #drop-zone { | |
| width: 500px; | |
| max-width: 90vw; | |
| height: 300px; | |
| border: 3px dashed var(--border); | |
| border-radius: 16px; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| cursor: pointer; | |
| transition: all 0.3s; | |
| background: var(--surface); | |
| } | |
| #drop-zone:hover, #drop-zone.drag-over { | |
| border-color: var(--accent); | |
| background: rgba(233, 69, 96, 0.05); | |
| } | |
| #drop-zone svg { width: 64px; height: 64px; fill: var(--text-dim); margin-bottom: 1rem; } | |
| #drop-zone p { color: var(--text-dim); font-size: 1.1rem; } | |
| #drop-zone .hint { font-size: 0.85rem; margin-top: 0.5rem; } | |
| #file-input { display: none; } | |
| /* βββ Loading Overlay βββ */ | |
| #loading-overlay { | |
| display: none; | |
| position: fixed; | |
| inset: 0; | |
| background: rgba(0,0,0,0.8); | |
| z-index: 9999; | |
| align-items: center; | |
| justify-content: center; | |
| flex-direction: column; | |
| gap: 1.5rem; | |
| } | |
| #loading-overlay.visible { display: flex; } | |
| .spinner { | |
| width: 60px; height: 60px; | |
| border: 5px solid var(--border); | |
| border-top-color: var(--accent); | |
| border-radius: 50%; | |
| animation: spin 0.8s linear infinite; | |
| } | |
| @keyframes spin { to { transform: rotate(360deg); } } | |
| #loading-overlay p { font-size: 1.2rem; color: var(--text-dim); } | |
| /* βββ Editor Screen βββ */ | |
| #editor-screen { display: none; height: 100vh; flex-direction: column; } | |
| #editor-screen.visible { display: flex; } | |
| /* Top bar */ | |
| #topbar { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| padding: 0.6rem 1.2rem; | |
| background: var(--surface); | |
| border-bottom: 1px solid var(--border); | |
| flex-shrink: 0; | |
| } | |
| #topbar .logo { | |
| font-family: 'Montserrat', sans-serif; | |
| font-weight: 900; | |
| font-size: 1.2rem; | |
| background: linear-gradient(135deg, var(--accent), var(--accent2)); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| } | |
| #topbar .actions { display: flex; gap: 0.5rem; } | |
| /* Buttons */ | |
| .btn { | |
| padding: 0.5rem 1rem; | |
| border: none; | |
| border-radius: var(--radius); | |
| cursor: pointer; | |
| font-size: 0.85rem; | |
| font-weight: 600; | |
| transition: all 0.2s; | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 0.4rem; | |
| } | |
| .btn-primary { background: var(--accent); color: #fff; } | |
| .btn-primary:hover { background: #d63851; } | |
| .btn-secondary { background: var(--surface2); color: var(--text); } | |
| .btn-secondary:hover { background: #1a4a7a; } | |
| .btn-danger { background: #c0392b; color: #fff; } | |
| .btn-danger:hover { background: #a93226; } | |
| .btn-sm { padding: 0.35rem 0.7rem; font-size: 0.8rem; } | |
| /* Main editor layout */ | |
| #editor-main { | |
| display: flex; | |
| flex: 1; | |
| overflow: hidden; | |
| } | |
| /* Canvas area */ | |
| #canvas-area { | |
| flex: 1; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| padding: 1.5rem; | |
| background: #111; | |
| overflow: auto; | |
| position: relative; | |
| } | |
| #canvas-container { | |
| position: relative; | |
| display: inline-block; | |
| box-shadow: 0 4px 30px rgba(0,0,0,0.5); | |
| overflow: hidden; | |
| } | |
| #canvas-container img { | |
| display: block; | |
| max-width: 70vw; | |
| max-height: 80vh; | |
| user-select: none; | |
| -webkit-user-drag: none; | |
| } | |
| #background-layer { position: relative; z-index: 1; } | |
| #text-layer { | |
| position: absolute; | |
| inset: 0; | |
| z-index: 2; | |
| pointer-events: all; | |
| } | |
| #foreground-layer { | |
| position: absolute; | |
| inset: 0; | |
| z-index: 3; | |
| width: 100%; | |
| height: 100%; | |
| object-fit: fill; | |
| pointer-events: none; | |
| } | |
| /* Text elements */ | |
| .text-element { | |
| position: absolute; | |
| cursor: grab; | |
| white-space: pre; | |
| user-select: none; | |
| line-height: 1.2; | |
| transition: outline 0.15s; | |
| outline: 2px solid transparent; | |
| outline-offset: 4px; | |
| } | |
| .text-element:hover { outline-color: rgba(233,69,96,0.4); } | |
| .text-element.selected { outline-color: var(--accent); } | |
| .text-element.dragging { cursor: grabbing; } | |
| /* βββ Controls Panel βββ */ | |
| #controls-panel { | |
| width: 320px; | |
| flex-shrink: 0; | |
| background: var(--surface); | |
| border-left: 1px solid var(--border); | |
| overflow-y: auto; | |
| padding: 0; | |
| } | |
| .panel-section { | |
| border-bottom: 1px solid var(--border); | |
| padding: 0.8rem 1rem; | |
| } | |
| .panel-section h3 { | |
| font-size: 0.75rem; | |
| text-transform: uppercase; | |
| letter-spacing: 1px; | |
| color: var(--text-dim); | |
| margin-bottom: 0.6rem; | |
| display: flex; | |
| align-items: center; | |
| gap: 0.4rem; | |
| } | |
| .control-row { | |
| display: flex; | |
| align-items: center; | |
| gap: 0.5rem; | |
| margin-bottom: 0.5rem; | |
| } | |
| .control-row label { | |
| font-size: 0.8rem; | |
| color: var(--text-dim); | |
| min-width: 65px; | |
| flex-shrink: 0; | |
| } | |
| .control-row input[type="text"], | |
| .control-row input[type="number"], | |
| .control-row select, | |
| .control-row textarea { | |
| flex: 1; | |
| background: var(--input-bg); | |
| border: 1px solid var(--border); | |
| border-radius: 4px; | |
| color: var(--text); | |
| padding: 0.35rem 0.5rem; | |
| font-size: 0.85rem; | |
| font-family: inherit; | |
| } | |
| .control-row textarea { | |
| resize: vertical; | |
| min-height: 50px; | |
| } | |
| .control-row input[type="range"] { | |
| flex: 1; | |
| accent-color: var(--accent); | |
| } | |
| .control-row input[type="color"] { | |
| width: 32px; | |
| height: 28px; | |
| border: 1px solid var(--border); | |
| border-radius: 4px; | |
| background: none; | |
| cursor: pointer; | |
| padding: 1px; | |
| } | |
| .range-value { | |
| font-size: 0.75rem; | |
| color: var(--text-dim); | |
| min-width: 35px; | |
| text-align: right; | |
| } | |
| .control-row .checkbox-label { | |
| display: flex; | |
| align-items: center; | |
| gap: 0.4rem; | |
| font-size: 0.85rem; | |
| cursor: pointer; | |
| min-width: auto; | |
| color: var(--text); | |
| } | |
| .control-row .checkbox-label input { accent-color: var(--accent); } | |
| /* Text list */ | |
| #text-list { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 0.3rem; | |
| } | |
| .text-list-item { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| padding: 0.4rem 0.6rem; | |
| background: var(--input-bg); | |
| border-radius: 4px; | |
| font-size: 0.82rem; | |
| cursor: pointer; | |
| border: 1px solid transparent; | |
| } | |
| .text-list-item:hover { border-color: var(--border); } | |
| .text-list-item.active { border-color: var(--accent); background: rgba(233,69,96,0.1); } | |
| .text-list-item .delete-text-btn { | |
| background: none; | |
| border: none; | |
| color: var(--text-dim); | |
| cursor: pointer; | |
| font-size: 1rem; | |
| padding: 0 0.2rem; | |
| } | |
| .text-list-item .delete-text-btn:hover { color: var(--accent); } | |
| #no-text-msg { color: var(--text-dim); font-size: 0.82rem; font-style: italic; } | |
| /* Disabled state */ | |
| #controls-panel.disabled .panel-section:not(.always-active) { | |
| opacity: 0.4; | |
| pointer-events: none; | |
| } | |
| /* Export canvas (hidden) */ | |
| #export-canvas { display: none; } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- Upload Screen --> | |
| <div id="upload-screen"> | |
| <h1>Text Behind Image</h1> | |
| <p class="subtitle">Add stunning text effects behind your image's subject</p> | |
| <div id="drop-zone"> | |
| <svg viewBox="0 0 24 24"><path d="M19 7v2.99s-1.99.01-2 0V7h-3s.01-1.99 0-2h3V2h2v3h3v2h-3zm-3 4V8h-3V5H5c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2v-8h-3zM5 19l3-4 2 3 3-4 4 5H5z"/></svg> | |
| <p>Drop an image here or click to upload</p> | |
| <p class="hint">Supports JPG, PNG, WebP</p> | |
| </div> | |
| <input type="file" id="file-input" accept="image/*"> | |
| </div> | |
| <!-- Loading Overlay --> | |
| <div id="loading-overlay"> | |
| <div class="spinner"></div> | |
| <p>Removing background...</p> | |
| </div> | |
| <!-- Editor Screen --> | |
| <div id="editor-screen"> | |
| <div id="topbar"> | |
| <div class="logo">Text Behind Image</div> | |
| <div class="actions"> | |
| <button class="btn btn-secondary btn-sm" onclick="addText()">+ Add Text</button> | |
| <button class="btn btn-primary btn-sm" onclick="exportImage()">Export PNG</button> | |
| <button class="btn btn-secondary btn-sm" onclick="resetApp()">New Image</button> | |
| </div> | |
| </div> | |
| <div id="editor-main"> | |
| <div id="canvas-area"> | |
| <div id="canvas-container"> | |
| <img id="background-layer" /> | |
| <div id="text-layer"></div> | |
| <img id="foreground-layer" /> | |
| </div> | |
| </div> | |
| <div id="controls-panel" class="disabled"> | |
| <!-- Text List --> | |
| <div class="panel-section always-active"> | |
| <h3>Text Layers</h3> | |
| <div style="display:flex;gap:0.4rem;margin-bottom:0.5rem;"> | |
| <button class="btn btn-primary btn-sm" style="flex:1" onclick="addText()">+ Add Text</button> | |
| <button class="btn btn-danger btn-sm" onclick="deleteSelected()">Delete</button> | |
| </div> | |
| <div id="text-list"> | |
| <p id="no-text-msg">No text added yet</p> | |
| </div> | |
| </div> | |
| <!-- Content --> | |
| <div class="panel-section"> | |
| <h3>Content</h3> | |
| <div class="control-row"> | |
| <textarea id="ctrl-text" placeholder="Type your text..." oninput="updateProp('text', this.value)"></textarea> | |
| </div> | |
| </div> | |
| <!-- Font --> | |
| <div class="panel-section"> | |
| <h3>Font</h3> | |
| <div class="control-row"> | |
| <label>Family</label> | |
| <select id="ctrl-font" onchange="updateProp('fontFamily', this.value)"> | |
| <option value="Arial">Arial</option> | |
| <option value="Helvetica">Helvetica</option> | |
| <option value="Georgia">Georgia</option> | |
| <option value="Times New Roman">Times New Roman</option> | |
| <option value="Courier New">Courier New</option> | |
| <option value="Impact" selected>Impact</option> | |
| <option value="Verdana">Verdana</option> | |
| <option value="Roboto">Roboto</option> | |
| <option value="Open Sans">Open Sans</option> | |
| <option value="Montserrat">Montserrat</option> | |
| <option value="Playfair Display">Playfair Display</option> | |
| <option value="Oswald">Oswald</option> | |
| <option value="Raleway">Raleway</option> | |
| <option value="Dancing Script">Dancing Script</option> | |
| <option value="Permanent Marker">Permanent Marker</option> | |
| <option value="Bebas Neue">Bebas Neue</option> | |
| <option value="Pacifico">Pacifico</option> | |
| <option value="Anton">Anton</option> | |
| <option value="Lobster">Lobster</option> | |
| <option value="Abril Fatface">Abril Fatface</option> | |
| <option value="Righteous">Righteous</option> | |
| <option value="Bangers">Bangers</option> | |
| <option value="Fredoka One">Fredoka One</option> | |
| <option value="Press Start 2P">Press Start 2P</option> | |
| <option value="Satisfy">Satisfy</option> | |
| <option value="Comic Sans MS">Comic Sans MS</option> | |
| </select> | |
| </div> | |
| <div class="control-row"> | |
| <label>Size</label> | |
| <input type="range" id="ctrl-size" min="12" max="300" value="72" oninput="updateProp('fontSize', this.value)"> | |
| <span class="range-value" id="ctrl-size-val">72px</span> | |
| </div> | |
| <div class="control-row"> | |
| <label>Weight</label> | |
| <input type="range" id="ctrl-weight" min="100" max="900" step="100" value="900" oninput="updateProp('fontWeight', this.value)"> | |
| <span class="range-value" id="ctrl-weight-val">900</span> | |
| </div> | |
| <div class="control-row"> | |
| <label>Spacing</label> | |
| <input type="range" id="ctrl-spacing" min="-10" max="50" value="0" oninput="updateProp('letterSpacing', this.value)"> | |
| <span class="range-value" id="ctrl-spacing-val">0px</span> | |
| </div> | |
| <div class="control-row"> | |
| <label>Line H.</label> | |
| <input type="range" id="ctrl-lineheight" min="0.5" max="3" step="0.1" value="1.2" oninput="updateProp('lineHeight', this.value)"> | |
| <span class="range-value" id="ctrl-lineheight-val">1.2</span> | |
| </div> | |
| </div> | |
| <!-- Color --> | |
| <div class="panel-section"> | |
| <h3>Color & Fill</h3> | |
| <div class="control-row"> | |
| <label>Color</label> | |
| <input type="color" id="ctrl-color" value="#ffffff" oninput="updateProp('color', this.value)"> | |
| <label style="min-width:auto">Opacity</label> | |
| <input type="range" id="ctrl-opacity" min="0" max="1" step="0.05" value="1" oninput="updateProp('opacity', this.value)"> | |
| <span class="range-value" id="ctrl-opacity-val">1</span> | |
| </div> | |
| <div class="control-row"> | |
| <label class="checkbox-label"><input type="checkbox" id="ctrl-bg-on" onchange="updateProp('bgEnabled', this.checked)"> Background</label> | |
| <input type="color" id="ctrl-bg-color" value="#000000" oninput="updateProp('bgColor', this.value)"> | |
| <input type="range" id="ctrl-bg-opacity" min="0" max="1" step="0.05" value="0.5" oninput="updateProp('bgOpacity', this.value)"> | |
| <span class="range-value" id="ctrl-bg-opacity-val">0.5</span> | |
| </div> | |
| <div class="control-row"> | |
| <label>Padding</label> | |
| <input type="range" id="ctrl-bg-padding" min="0" max="40" value="8" oninput="updateProp('bgPadding', this.value)"> | |
| <span class="range-value" id="ctrl-bg-padding-val">8px</span> | |
| </div> | |
| </div> | |
| <!-- Stroke / Outline --> | |
| <div class="panel-section"> | |
| <h3>Stroke / Outline</h3> | |
| <div class="control-row"> | |
| <label class="checkbox-label"><input type="checkbox" id="ctrl-stroke-on" onchange="updateProp('strokeEnabled', this.checked)"> Enable</label> | |
| <input type="color" id="ctrl-stroke-color" value="#000000" oninput="updateProp('strokeColor', this.value)"> | |
| </div> | |
| <div class="control-row"> | |
| <label>Width</label> | |
| <input type="range" id="ctrl-stroke-width" min="0" max="10" step="0.5" value="2" oninput="updateProp('strokeWidth', this.value)"> | |
| <span class="range-value" id="ctrl-stroke-width-val">2px</span> | |
| </div> | |
| </div> | |
| <!-- Shadow --> | |
| <div class="panel-section"> | |
| <h3>Shadow</h3> | |
| <div class="control-row"> | |
| <label class="checkbox-label"><input type="checkbox" id="ctrl-shadow-on" onchange="updateProp('shadowEnabled', this.checked)"> Enable</label> | |
| <input type="color" id="ctrl-shadow-color" value="#000000" oninput="updateProp('shadowColor', this.value)"> | |
| </div> | |
| <div class="control-row"> | |
| <label>X</label> | |
| <input type="range" id="ctrl-shadow-x" min="-30" max="30" value="3" oninput="updateProp('shadowX', this.value)"> | |
| <span class="range-value" id="ctrl-shadow-x-val">3px</span> | |
| </div> | |
| <div class="control-row"> | |
| <label>Y</label> | |
| <input type="range" id="ctrl-shadow-y" min="-30" max="30" value="3" oninput="updateProp('shadowY', this.value)"> | |
| <span class="range-value" id="ctrl-shadow-y-val">3px</span> | |
| </div> | |
| <div class="control-row"> | |
| <label>Blur</label> | |
| <input type="range" id="ctrl-shadow-blur" min="0" max="30" value="5" oninput="updateProp('shadowBlur', this.value)"> | |
| <span class="range-value" id="ctrl-shadow-blur-val">5px</span> | |
| </div> | |
| </div> | |
| <!-- 3D Effect --> | |
| <div class="panel-section"> | |
| <h3>3D Extrusion</h3> | |
| <div class="control-row"> | |
| <label class="checkbox-label"><input type="checkbox" id="ctrl-3d-on" onchange="updateProp('threeDEnabled', this.checked)"> Enable</label> | |
| <input type="color" id="ctrl-3d-color" value="#333333" oninput="updateProp('threeDColor', this.value)"> | |
| </div> | |
| <div class="control-row"> | |
| <label>Depth</label> | |
| <input type="range" id="ctrl-3d-depth" min="1" max="30" value="8" oninput="updateProp('threeDDepth', this.value)"> | |
| <span class="range-value" id="ctrl-3d-depth-val">8</span> | |
| </div> | |
| <div class="control-row"> | |
| <label>Angle</label> | |
| <input type="range" id="ctrl-3d-angle" min="0" max="360" value="135" oninput="updateProp('threeDAngle', this.value)"> | |
| <span class="range-value" id="ctrl-3d-angle-val">135°</span> | |
| </div> | |
| </div> | |
| <!-- Transform --> | |
| <div class="panel-section"> | |
| <h3>Transform</h3> | |
| <div class="control-row"> | |
| <label>Rotate</label> | |
| <input type="range" id="ctrl-rotate" min="-180" max="180" value="0" oninput="updateProp('rotate', this.value)"> | |
| <span class="range-value" id="ctrl-rotate-val">0°</span> | |
| </div> | |
| <div class="control-row"> | |
| <label>Skew X</label> | |
| <input type="range" id="ctrl-skewx" min="-60" max="60" value="0" oninput="updateProp('skewX', this.value)"> | |
| <span class="range-value" id="ctrl-skewx-val">0°</span> | |
| </div> | |
| <div class="control-row"> | |
| <label>Skew Y</label> | |
| <input type="range" id="ctrl-skewy" min="-60" max="60" value="0" oninput="updateProp('skewY', this.value)"> | |
| <span class="range-value" id="ctrl-skewy-val">0°</span> | |
| </div> | |
| </div> | |
| <!-- Perspective (lay text on ground, etc.) --> | |
| <div class="panel-section"> | |
| <h3>Perspective</h3> | |
| <div class="control-row"> | |
| <label>Distance</label> | |
| <input type="range" id="ctrl-persp" min="50" max="2000" value="500" oninput="updateProp('perspective', this.value)"> | |
| <span class="range-value" id="ctrl-persp-val">500px</span> | |
| </div> | |
| <div class="control-row"> | |
| <label>Tilt X</label> | |
| <input type="range" id="ctrl-rotx" min="-85" max="85" value="0" oninput="updateProp('rotateX', this.value)"> | |
| <span class="range-value" id="ctrl-rotx-val">0°</span> | |
| </div> | |
| <div class="control-row"> | |
| <label>Tilt Y</label> | |
| <input type="range" id="ctrl-roty" min="-85" max="85" value="0" oninput="updateProp('rotateY', this.value)"> | |
| <span class="range-value" id="ctrl-roty-val">0°</span> | |
| </div> | |
| <div class="control-row"> | |
| <label>Origin</label> | |
| <select id="ctrl-persp-origin" onchange="updateProp('perspOrigin', this.value)"> | |
| <option value="center center">Center</option> | |
| <option value="center top">Top</option> | |
| <option value="center bottom" selected>Bottom</option> | |
| <option value="left center">Left</option> | |
| <option value="right center">Right</option> | |
| <option value="left top">Top Left</option> | |
| <option value="right top">Top Right</option> | |
| <option value="left bottom">Bottom Left</option> | |
| <option value="right bottom">Bottom Right</option> | |
| </select> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <canvas id="export-canvas"></canvas> | |
| <script> | |
| // βββ State βββ | |
| let textElements = []; | |
| let selectedId = null; | |
| let nextId = 1; | |
| let originalImageData = null; // base64 data URL of the original image | |
| let foregroundImageData = null; // base64 data URL of the foreground (transparent) | |
| let naturalWidth = 0; | |
| let naturalHeight = 0; | |
| const defaults = { | |
| text: 'YOUR TEXT', | |
| fontFamily: 'Impact', | |
| fontSize: 72, | |
| fontWeight: 900, | |
| letterSpacing: 0, | |
| lineHeight: 1.2, | |
| color: '#ffffff', | |
| opacity: 1, | |
| bgEnabled: false, | |
| bgColor: '#000000', | |
| bgOpacity: 0.5, | |
| bgPadding: 8, | |
| strokeEnabled: false, | |
| strokeColor: '#000000', | |
| strokeWidth: 2, | |
| shadowEnabled: false, | |
| shadowColor: '#000000', | |
| shadowX: 3, | |
| shadowY: 3, | |
| shadowBlur: 5, | |
| threeDEnabled: false, | |
| threeDColor: '#333333', | |
| threeDDepth: 8, | |
| threeDAngle: 135, | |
| rotate: 0, | |
| skewX: 0, | |
| skewY: 0, | |
| perspective: 500, | |
| rotateX: 0, | |
| rotateY: 0, | |
| perspOrigin: 'center bottom', | |
| x: 50, // percentage | |
| y: 50, | |
| }; | |
| // βββ Upload βββ | |
| const dropZone = document.getElementById('drop-zone'); | |
| const fileInput = document.getElementById('file-input'); | |
| dropZone.addEventListener('click', () => fileInput.click()); | |
| dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over'); }); | |
| dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over')); | |
| dropZone.addEventListener('drop', e => { | |
| e.preventDefault(); | |
| dropZone.classList.remove('drag-over'); | |
| if (e.dataTransfer.files.length) handleFile(e.dataTransfer.files[0]); | |
| }); | |
| fileInput.addEventListener('change', e => { if (e.target.files.length) handleFile(e.target.files[0]); }); | |
| function handleFile(file) { | |
| if (!file.type.startsWith('image/')) return; | |
| // Read as data URL for display | |
| const reader = new FileReader(); | |
| reader.onload = e => { | |
| originalImageData = e.target.result; | |
| // Get natural dimensions | |
| const img = new Image(); | |
| img.onload = () => { | |
| naturalWidth = img.naturalWidth; | |
| naturalHeight = img.naturalHeight; | |
| uploadAndProcess(file); | |
| }; | |
| img.src = originalImageData; | |
| }; | |
| reader.readAsDataURL(file); | |
| } | |
| async function uploadAndProcess(file) { | |
| const overlay = document.getElementById('loading-overlay'); | |
| overlay.classList.add('visible'); | |
| try { | |
| const form = new FormData(); | |
| form.append('file', file); | |
| const resp = await fetch('/api/remove-background', { method: 'POST', body: form }); | |
| if (!resp.ok) throw new Error('Background removal failed'); | |
| const data = await resp.json(); | |
| foregroundImageData = data.foreground; | |
| enterEditor(); | |
| } catch (err) { | |
| alert('Error: ' + err.message); | |
| } finally { | |
| overlay.classList.remove('visible'); | |
| } | |
| } | |
| // βββ Editor Setup βββ | |
| function enterEditor() { | |
| document.getElementById('upload-screen').style.display = 'none'; | |
| document.getElementById('editor-screen').classList.add('visible'); | |
| const bgImg = document.getElementById('background-layer'); | |
| const fgImg = document.getElementById('foreground-layer'); | |
| bgImg.src = originalImageData; | |
| fgImg.src = foregroundImageData; | |
| // Click on text layer background to deselect | |
| document.getElementById('text-layer').addEventListener('mousedown', e => { | |
| if (e.target === e.currentTarget) selectText(null); | |
| }); | |
| } | |
| function resetApp() { | |
| textElements = []; | |
| selectedId = null; | |
| nextId = 1; | |
| originalImageData = null; | |
| foregroundImageData = null; | |
| document.getElementById('text-layer').innerHTML = ''; | |
| document.getElementById('editor-screen').classList.remove('visible'); | |
| document.getElementById('upload-screen').style.display = ''; | |
| fileInput.value = ''; | |
| updateTextList(); | |
| } | |
| // βββ Text Management βββ | |
| function addText() { | |
| const id = nextId++; | |
| const te = { ...defaults, id, x: 30 + Math.random() * 40, y: 40 + Math.random() * 20 }; | |
| textElements.push(te); | |
| createTextDom(te); | |
| selectText(id); | |
| updateTextList(); | |
| } | |
| function createTextDom(te) { | |
| const el = document.createElement('div'); | |
| el.className = 'text-element'; | |
| el.dataset.id = te.id; | |
| el.textContent = te.text; | |
| applyStyles(el, te); | |
| // Positioning | |
| el.style.left = te.x + '%'; | |
| el.style.top = te.y + '%'; | |
| el.style.transform = buildTransform(te); | |
| // Drag | |
| let dragging = false, startX, startY, startLeft, startTop; | |
| el.addEventListener('mousedown', e => { | |
| e.preventDefault(); | |
| selectText(te.id); | |
| dragging = true; | |
| el.classList.add('dragging'); | |
| const container = document.getElementById('canvas-container'); | |
| const rect = container.getBoundingClientRect(); | |
| startX = e.clientX; | |
| startY = e.clientY; | |
| startLeft = te.x; | |
| startTop = te.y; | |
| function onMove(e) { | |
| if (!dragging) return; | |
| const dx = e.clientX - startX; | |
| const dy = e.clientY - startY; | |
| te.x = startLeft + (dx / rect.width) * 100; | |
| te.y = startTop + (dy / rect.height) * 100; | |
| el.style.left = te.x + '%'; | |
| el.style.top = te.y + '%'; | |
| } | |
| function onUp() { | |
| dragging = false; | |
| el.classList.remove('dragging'); | |
| document.removeEventListener('mousemove', onMove); | |
| document.removeEventListener('mouseup', onUp); | |
| } | |
| document.addEventListener('mousemove', onMove); | |
| document.addEventListener('mouseup', onUp); | |
| }); | |
| // Touch drag | |
| el.addEventListener('touchstart', e => { | |
| e.preventDefault(); | |
| selectText(te.id); | |
| dragging = true; | |
| const container = document.getElementById('canvas-container'); | |
| const rect = container.getBoundingClientRect(); | |
| const touch = e.touches[0]; | |
| startX = touch.clientX; | |
| startY = touch.clientY; | |
| startLeft = te.x; | |
| startTop = te.y; | |
| function onTouchMove(e) { | |
| if (!dragging) return; | |
| const t = e.touches[0]; | |
| const dx = t.clientX - startX; | |
| const dy = t.clientY - startY; | |
| te.x = startLeft + (dx / rect.width) * 100; | |
| te.y = startTop + (dy / rect.height) * 100; | |
| el.style.left = te.x + '%'; | |
| el.style.top = te.y + '%'; | |
| } | |
| function onTouchEnd() { | |
| dragging = false; | |
| document.removeEventListener('touchmove', onTouchMove); | |
| document.removeEventListener('touchend', onTouchEnd); | |
| } | |
| document.addEventListener('touchmove', onTouchMove, { passive: false }); | |
| document.addEventListener('touchend', onTouchEnd); | |
| }, { passive: false }); | |
| document.getElementById('text-layer').appendChild(el); | |
| } | |
| function applyStyles(el, te) { | |
| el.style.fontFamily = `"${te.fontFamily}", sans-serif`; | |
| el.style.fontSize = te.fontSize + 'px'; | |
| el.style.fontWeight = te.fontWeight; | |
| el.style.letterSpacing = te.letterSpacing + 'px'; | |
| el.style.lineHeight = te.lineHeight; | |
| el.style.color = te.color; | |
| el.style.opacity = te.opacity; | |
| // Background | |
| if (te.bgEnabled) { | |
| const r = parseInt(te.bgColor.slice(1, 3), 16); | |
| const g = parseInt(te.bgColor.slice(3, 5), 16); | |
| const b = parseInt(te.bgColor.slice(5, 7), 16); | |
| el.style.backgroundColor = `rgba(${r},${g},${b},${te.bgOpacity})`; | |
| el.style.padding = te.bgPadding + 'px'; | |
| } else { | |
| el.style.backgroundColor = 'transparent'; | |
| el.style.padding = '0'; | |
| } | |
| // Stroke | |
| if (te.strokeEnabled) { | |
| el.style.webkitTextStroke = `${te.strokeWidth}px ${te.strokeColor}`; | |
| } else { | |
| el.style.webkitTextStroke = ''; | |
| } | |
| // Shadow + 3D | |
| const shadows = []; | |
| if (te.threeDEnabled) { | |
| const rad = (te.threeDAngle * Math.PI) / 180; | |
| const dx = Math.cos(rad); | |
| const dy = Math.sin(rad); | |
| for (let i = 1; i <= te.threeDDepth; i++) { | |
| shadows.push(`${Math.round(dx * i)}px ${Math.round(dy * i)}px 0 ${te.threeDColor}`); | |
| } | |
| } | |
| if (te.shadowEnabled) { | |
| shadows.push(`${te.shadowX}px ${te.shadowY}px ${te.shadowBlur}px ${te.shadowColor}`); | |
| } | |
| el.style.textShadow = shadows.length ? shadows.join(', ') : 'none'; | |
| // Transform | |
| el.style.transform = buildTransform(te); | |
| el.style.transformOrigin = te.perspOrigin; | |
| } | |
| function buildTransform(te) { | |
| const parts = []; | |
| parts.push(`translate(-50%, -50%)`); | |
| // Perspective must come first for rotateX/rotateY to work | |
| if (te.rotateX != 0 || te.rotateY != 0) { | |
| parts.push(`perspective(${te.perspective}px)`); | |
| if (te.rotateX != 0) parts.push(`rotateX(${te.rotateX}deg)`); | |
| if (te.rotateY != 0) parts.push(`rotateY(${te.rotateY}deg)`); | |
| } | |
| if (te.rotate != 0) parts.push(`rotate(${te.rotate}deg)`); | |
| if (te.skewX != 0) parts.push(`skewX(${te.skewX}deg)`); | |
| if (te.skewY != 0) parts.push(`skewY(${te.skewY}deg)`); | |
| return parts.join(' '); | |
| } | |
| function selectText(id) { | |
| selectedId = id; | |
| // Update DOM selection | |
| document.querySelectorAll('.text-element').forEach(el => { | |
| el.classList.toggle('selected', parseInt(el.dataset.id) === id); | |
| }); | |
| // Update panel | |
| const panel = document.getElementById('controls-panel'); | |
| if (id === null) { | |
| panel.classList.add('disabled'); | |
| updateTextList(); | |
| return; | |
| } | |
| panel.classList.remove('disabled'); | |
| const te = textElements.find(t => t.id === id); | |
| if (!te) return; | |
| // Sync controls to selected element's properties | |
| document.getElementById('ctrl-text').value = te.text; | |
| document.getElementById('ctrl-font').value = te.fontFamily; | |
| document.getElementById('ctrl-size').value = te.fontSize; | |
| document.getElementById('ctrl-size-val').textContent = te.fontSize + 'px'; | |
| document.getElementById('ctrl-weight').value = te.fontWeight; | |
| document.getElementById('ctrl-weight-val').textContent = te.fontWeight; | |
| document.getElementById('ctrl-spacing').value = te.letterSpacing; | |
| document.getElementById('ctrl-spacing-val').textContent = te.letterSpacing + 'px'; | |
| document.getElementById('ctrl-lineheight').value = te.lineHeight; | |
| document.getElementById('ctrl-lineheight-val').textContent = te.lineHeight; | |
| document.getElementById('ctrl-color').value = te.color; | |
| document.getElementById('ctrl-opacity').value = te.opacity; | |
| document.getElementById('ctrl-opacity-val').textContent = te.opacity; | |
| document.getElementById('ctrl-bg-on').checked = te.bgEnabled; | |
| document.getElementById('ctrl-bg-color').value = te.bgColor; | |
| document.getElementById('ctrl-bg-opacity').value = te.bgOpacity; | |
| document.getElementById('ctrl-bg-opacity-val').textContent = te.bgOpacity; | |
| document.getElementById('ctrl-bg-padding').value = te.bgPadding; | |
| document.getElementById('ctrl-bg-padding-val').textContent = te.bgPadding + 'px'; | |
| document.getElementById('ctrl-stroke-on').checked = te.strokeEnabled; | |
| document.getElementById('ctrl-stroke-color').value = te.strokeColor; | |
| document.getElementById('ctrl-stroke-width').value = te.strokeWidth; | |
| document.getElementById('ctrl-stroke-width-val').textContent = te.strokeWidth + 'px'; | |
| document.getElementById('ctrl-shadow-on').checked = te.shadowEnabled; | |
| document.getElementById('ctrl-shadow-color').value = te.shadowColor; | |
| document.getElementById('ctrl-shadow-x').value = te.shadowX; | |
| document.getElementById('ctrl-shadow-x-val').textContent = te.shadowX + 'px'; | |
| document.getElementById('ctrl-shadow-y').value = te.shadowY; | |
| document.getElementById('ctrl-shadow-y-val').textContent = te.shadowY + 'px'; | |
| document.getElementById('ctrl-shadow-blur').value = te.shadowBlur; | |
| document.getElementById('ctrl-shadow-blur-val').textContent = te.shadowBlur + 'px'; | |
| document.getElementById('ctrl-3d-on').checked = te.threeDEnabled; | |
| document.getElementById('ctrl-3d-color').value = te.threeDColor; | |
| document.getElementById('ctrl-3d-depth').value = te.threeDDepth; | |
| document.getElementById('ctrl-3d-depth-val').textContent = te.threeDDepth; | |
| document.getElementById('ctrl-3d-angle').value = te.threeDAngle; | |
| document.getElementById('ctrl-3d-angle-val').innerHTML = te.threeDAngle + '°'; | |
| document.getElementById('ctrl-rotate').value = te.rotate; | |
| document.getElementById('ctrl-rotate-val').innerHTML = te.rotate + '°'; | |
| document.getElementById('ctrl-skewx').value = te.skewX; | |
| document.getElementById('ctrl-skewx-val').innerHTML = te.skewX + '°'; | |
| document.getElementById('ctrl-skewy').value = te.skewY; | |
| document.getElementById('ctrl-skewy-val').innerHTML = te.skewY + '°'; | |
| document.getElementById('ctrl-persp').value = te.perspective; | |
| document.getElementById('ctrl-persp-val').textContent = te.perspective + 'px'; | |
| document.getElementById('ctrl-rotx').value = te.rotateX; | |
| document.getElementById('ctrl-rotx-val').innerHTML = te.rotateX + '°'; | |
| document.getElementById('ctrl-roty').value = te.rotateY; | |
| document.getElementById('ctrl-roty-val').innerHTML = te.rotateY + '°'; | |
| document.getElementById('ctrl-persp-origin').value = te.perspOrigin; | |
| updateTextList(); | |
| } | |
| function updateProp(prop, value) { | |
| if (selectedId === null) return; | |
| const te = textElements.find(t => t.id === selectedId); | |
| if (!te) return; | |
| // Cast numerics | |
| const numerics = ['fontSize','fontWeight','letterSpacing','lineHeight','opacity', | |
| 'bgOpacity','bgPadding','strokeWidth','shadowX','shadowY','shadowBlur', | |
| 'threeDDepth','threeDAngle','rotate','skewX','skewY', | |
| 'perspective','rotateX','rotateY']; | |
| if (numerics.includes(prop)) value = parseFloat(value); | |
| te[prop] = value; | |
| // Update range value display | |
| const valDisplays = { | |
| fontSize: ['ctrl-size-val', v => v + 'px'], | |
| fontWeight: ['ctrl-weight-val', v => v], | |
| letterSpacing: ['ctrl-spacing-val', v => v + 'px'], | |
| lineHeight: ['ctrl-lineheight-val', v => v], | |
| opacity: ['ctrl-opacity-val', v => v], | |
| bgOpacity: ['ctrl-bg-opacity-val', v => v], | |
| bgPadding: ['ctrl-bg-padding-val', v => v + 'px'], | |
| strokeWidth: ['ctrl-stroke-width-val', v => v + 'px'], | |
| shadowX: ['ctrl-shadow-x-val', v => v + 'px'], | |
| shadowY: ['ctrl-shadow-y-val', v => v + 'px'], | |
| shadowBlur: ['ctrl-shadow-blur-val', v => v + 'px'], | |
| threeDDepth: ['ctrl-3d-depth-val', v => v], | |
| threeDAngle: ['ctrl-3d-angle-val', v => v + 'Β°'], | |
| rotate: ['ctrl-rotate-val', v => v + 'Β°'], | |
| skewX: ['ctrl-skewx-val', v => v + 'Β°'], | |
| skewY: ['ctrl-skewy-val', v => v + 'Β°'], | |
| perspective: ['ctrl-persp-val', v => v + 'px'], | |
| rotateX: ['ctrl-rotx-val', v => v + 'Β°'], | |
| rotateY: ['ctrl-roty-val', v => v + 'Β°'], | |
| }; | |
| if (valDisplays[prop]) { | |
| const [elId, fmt] = valDisplays[prop]; | |
| document.getElementById(elId).textContent = fmt(value); | |
| } | |
| // Update DOM element | |
| const el = document.querySelector(`.text-element[data-id="${te.id}"]`); | |
| if (!el) return; | |
| if (prop === 'text') { | |
| el.textContent = value; | |
| updateTextList(); | |
| } | |
| applyStyles(el, te); | |
| } | |
| function deleteSelected() { | |
| if (selectedId === null) return; | |
| const el = document.querySelector(`.text-element[data-id="${selectedId}"]`); | |
| if (el) el.remove(); | |
| textElements = textElements.filter(t => t.id !== selectedId); | |
| selectText(null); | |
| updateTextList(); | |
| } | |
| function updateTextList() { | |
| const list = document.getElementById('text-list'); | |
| const noMsg = document.getElementById('no-text-msg'); | |
| // Clear old items (keep noMsg) | |
| list.querySelectorAll('.text-list-item').forEach(el => el.remove()); | |
| if (textElements.length === 0) { | |
| noMsg.style.display = ''; | |
| return; | |
| } | |
| noMsg.style.display = 'none'; | |
| textElements.forEach(te => { | |
| const item = document.createElement('div'); | |
| item.className = 'text-list-item' + (te.id === selectedId ? ' active' : ''); | |
| const preview = te.text.length > 20 ? te.text.slice(0, 20) + '...' : te.text; | |
| item.innerHTML = ` | |
| <span>${preview}</span> | |
| <button class="delete-text-btn" title="Delete">×</button> | |
| `; | |
| item.addEventListener('click', e => { | |
| if (e.target.classList.contains('delete-text-btn')) { | |
| const old = selectedId; | |
| selectedId = te.id; | |
| deleteSelected(); | |
| return; | |
| } | |
| selectText(te.id); | |
| }); | |
| list.appendChild(item); | |
| }); | |
| } | |
| // βββ Export Helpers βββ | |
| /** Render a text element (with all effects) onto a temporary offscreen canvas. */ | |
| function renderTextToTempCanvas(te, fontSize, scaleX, scaleY) { | |
| // Measure text to determine canvas size | |
| const measureCanvas = document.createElement('canvas'); | |
| const mCtx = measureCanvas.getContext('2d'); | |
| mCtx.font = `${te.fontWeight} ${fontSize}px "${te.fontFamily}", sans-serif`; | |
| if (te.letterSpacing != 0) mCtx.letterSpacing = (te.letterSpacing * scaleX) + 'px'; | |
| const lines = te.text.split('\n'); | |
| const lineH = fontSize * te.lineHeight; | |
| const totalH = lineH * lines.length; | |
| let maxW = 0; | |
| lines.forEach(line => { | |
| const m = mCtx.measureText(line); | |
| if (m.width > maxW) maxW = m.width; | |
| }); | |
| // Add generous padding for shadow, 3D, stroke, bg | |
| const extraDepth = te.threeDEnabled ? te.threeDDepth * scaleX : 0; | |
| const extraShadow = te.shadowEnabled ? Math.max(Math.abs(te.shadowX * scaleX) + te.shadowBlur * scaleX, Math.abs(te.shadowY * scaleY) + te.shadowBlur * scaleX) : 0; | |
| const extraStroke = te.strokeEnabled ? te.strokeWidth * scaleX : 0; | |
| const extraBg = te.bgEnabled ? te.bgPadding * scaleX : 0; | |
| const pad = Math.ceil(Math.max(extraDepth, extraShadow, extraStroke, extraBg) + 20 * scaleX); | |
| const cw = Math.ceil(maxW + pad * 2); | |
| const ch = Math.ceil(totalH + pad * 2); | |
| const tc = document.createElement('canvas'); | |
| tc.width = cw; | |
| tc.height = ch; | |
| const tCtx = tc.getContext('2d'); | |
| // Center the drawing | |
| tCtx.translate(cw / 2, ch / 2); | |
| tCtx.font = `${te.fontWeight} ${fontSize}px "${te.fontFamily}", sans-serif`; | |
| tCtx.textAlign = 'center'; | |
| tCtx.textBaseline = 'middle'; | |
| if (te.letterSpacing != 0) tCtx.letterSpacing = (te.letterSpacing * scaleX) + 'px'; | |
| const startY = -totalH / 2 + lineH / 2; | |
| // Background | |
| if (te.bgEnabled) { | |
| const bgPad = te.bgPadding * scaleX; | |
| const r = parseInt(te.bgColor.slice(1, 3), 16); | |
| const g = parseInt(te.bgColor.slice(3, 5), 16); | |
| const b = parseInt(te.bgColor.slice(5, 7), 16); | |
| tCtx.fillStyle = `rgba(${r},${g},${b},${te.bgOpacity})`; | |
| tCtx.fillRect(-maxW / 2 - bgPad, startY - lineH / 2 - bgPad, maxW + bgPad * 2, totalH + bgPad * 2); | |
| } | |
| // 3D extrusion | |
| if (te.threeDEnabled) { | |
| const rad = (te.threeDAngle * Math.PI) / 180; | |
| const dx = Math.cos(rad); | |
| const dy = Math.sin(rad); | |
| tCtx.fillStyle = te.threeDColor; | |
| for (let i = Math.ceil(te.threeDDepth * scaleX); i > 0; i--) { | |
| lines.forEach((line, li) => { | |
| tCtx.fillText(line, Math.round(dx * i), startY + li * lineH + Math.round(dy * i)); | |
| }); | |
| } | |
| } | |
| // Shadow | |
| if (te.shadowEnabled) { | |
| tCtx.shadowColor = te.shadowColor; | |
| tCtx.shadowOffsetX = te.shadowX * scaleX; | |
| tCtx.shadowOffsetY = te.shadowY * scaleY; | |
| tCtx.shadowBlur = te.shadowBlur * scaleX; | |
| } | |
| // Stroke | |
| if (te.strokeEnabled) { | |
| tCtx.strokeStyle = te.strokeColor; | |
| tCtx.lineWidth = te.strokeWidth * scaleX; | |
| tCtx.lineJoin = 'round'; | |
| lines.forEach((line, li) => { | |
| tCtx.strokeText(line, 0, startY + li * lineH); | |
| }); | |
| tCtx.shadowColor = 'transparent'; | |
| } | |
| // Fill text | |
| tCtx.fillStyle = te.color; | |
| if (te.strokeEnabled) tCtx.shadowColor = 'transparent'; | |
| lines.forEach((line, li) => { | |
| tCtx.fillText(line, 0, startY + li * lineH); | |
| }); | |
| return tc; | |
| } | |
| /** Draw a temp canvas onto the main ctx with perspective warp (scanline approach). */ | |
| function drawPerspectiveWarp(ctx, srcCanvas, te) { | |
| const w = srcCanvas.width; | |
| const h = srcCanvas.height; | |
| const p = te.perspective; | |
| const radX = (te.rotateX * Math.PI) / 180; | |
| const radY = (te.rotateY * Math.PI) / 180; | |
| // Compute transform origin relative to the temp canvas center | |
| let originY = 0; // center | |
| if (te.perspOrigin.includes('bottom')) originY = h / 2; | |
| else if (te.perspOrigin.includes('top')) originY = -h / 2; | |
| let originX = 0; | |
| if (te.perspOrigin.includes('right')) originX = w / 2; | |
| else if (te.perspOrigin.includes('left')) originX = -w / 2; | |
| // Scanline warp: process each row | |
| for (let row = 0; row < h; row++) { | |
| const relY = row - h / 2 - originY; | |
| const relYNorm = relY; | |
| // Perspective projection for rotateX | |
| let zY = 0; | |
| let projY = relYNorm; | |
| if (radX !== 0) { | |
| zY = relYNorm * Math.sin(radX); | |
| projY = relYNorm * Math.cos(radX); | |
| } | |
| // For each column scanline for rotateY | |
| let scaleRow = p / (p + zY); | |
| if (scaleRow <= 0.01) continue; // behind camera | |
| const destY = originY + projY * scaleRow + h / 2 - h / 2; | |
| // rotateY affects horizontal scaling per column β approximate as uniform row scale | |
| let zX = 0; | |
| if (radY !== 0) { | |
| // Apply average horizontal perspective | |
| zX = 0; // center column has zero z from rotateY | |
| } | |
| const destW = w * scaleRow; | |
| const destX = -(destW / 2); | |
| ctx.drawImage(srcCanvas, | |
| 0, row, w, 1, | |
| destX, destY, destW, 1 | |
| ); | |
| } | |
| // Handle rotateY with a column-based pass if needed | |
| if (radY !== 0) { | |
| // Create another temp canvas with the rotateX result, then warp columns | |
| const midCanvas = document.createElement('canvas'); | |
| // For simplicity, approximate rotateY with a horizontal skew | |
| // This gives a good visual approximation | |
| const skewAmount = Math.tan(radY) * 0.5; | |
| ctx.transform(1, 0, skewAmount, 1, 0, 0); | |
| } | |
| } | |
| // βββ Export βββ | |
| function exportImage() { | |
| const canvas = document.getElementById('export-canvas'); | |
| const ctx = canvas.getContext('2d'); | |
| canvas.width = naturalWidth; | |
| canvas.height = naturalHeight; | |
| const bgImg = new Image(); | |
| bgImg.crossOrigin = 'anonymous'; | |
| bgImg.onload = () => { | |
| // 1. Draw background | |
| ctx.drawImage(bgImg, 0, 0, naturalWidth, naturalHeight); | |
| // 2. Draw text elements | |
| const container = document.getElementById('canvas-container'); | |
| const displayW = container.clientWidth; | |
| const displayH = container.clientHeight; | |
| const scaleX = naturalWidth / displayW; | |
| const scaleY = naturalHeight / displayH; | |
| textElements.forEach(te => { | |
| const cx = (te.x / 100) * naturalWidth; | |
| const cy = (te.y / 100) * naturalHeight; | |
| const fontSize = te.fontSize * scaleX; | |
| // Render text to a temporary canvas first | |
| const tmpCanvas = renderTextToTempCanvas(te, fontSize, scaleX, scaleY); | |
| ctx.save(); | |
| ctx.globalAlpha = te.opacity; | |
| ctx.translate(cx, cy); | |
| // Apply rotate + skew | |
| if (te.rotate != 0) { | |
| ctx.rotate((te.rotate * Math.PI) / 180); | |
| } | |
| if (te.skewX != 0 || te.skewY != 0) { | |
| const sx = Math.tan((te.skewX * Math.PI) / 180); | |
| const sy = Math.tan((te.skewY * Math.PI) / 180); | |
| ctx.transform(1, sy, sx, 1, 0, 0); | |
| } | |
| if (te.rotateX != 0 || te.rotateY != 0) { | |
| // Perspective warp via scanline rendering | |
| drawPerspectiveWarp(ctx, tmpCanvas, te); | |
| } else { | |
| ctx.drawImage(tmpCanvas, -tmpCanvas.width / 2, -tmpCanvas.height / 2); | |
| } | |
| ctx.restore(); | |
| }); | |
| // 3. Draw foreground on top | |
| const fgImg = new Image(); | |
| fgImg.crossOrigin = 'anonymous'; | |
| fgImg.onload = () => { | |
| ctx.drawImage(fgImg, 0, 0, naturalWidth, naturalHeight); | |
| // Download | |
| canvas.toBlob(blob => { | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = 'text-behind-image.png'; | |
| a.click(); | |
| URL.revokeObjectURL(url); | |
| }, 'image/png'); | |
| }; | |
| fgImg.src = foregroundImageData; | |
| }; | |
| bgImg.src = originalImageData; | |
| } | |
| </script> | |
| </body> | |
| </html> | |