Spaces:
Sleeping
Sleeping
Download static/app.js from Cnass/fonteditor: direct link, hf CLI and curl.
- Browser
- Download file 24.2 kB
-
https://huggingface.co/spaces/Cnass/fonteditor/resolve/main/static/app.js
- Command line
-
hf download hf://spaces/Cnass/fonteditor/static/app.js
-
curl -L -o app.js https://huggingface.co/spaces/Cnass/fonteditor/resolve/main/static/app.js
24.2 kB
| (() => { | |
| "use strict"; | |
| const state = { | |
| sessionId: null, | |
| fontInfo: null, | |
| chars: {}, // char -> { status, label, codepoint, recipe, originalRecipe, warnings, include, open } | |
| }; | |
| const STATUS_LABEL = { | |
| present: "Zaten Mevcut", | |
| existing_unmapped: "Fontta Var (Eşlenecek)", | |
| composable: "Otomatik Oluşturulacak", | |
| composable_synthetic: "Oluşturulacak (Sentetik Aksan)", | |
| needs_attention: "Dikkat Gerekiyor", | |
| }; | |
| const STATUS_DOT_COLOR = { | |
| present: "#2f7d4f", | |
| existing_unmapped: "#3a5ba0", | |
| composable: "#3a5ba0", | |
| composable_synthetic: "#a5680c", | |
| needs_attention: "#b23a3a", | |
| }; | |
| const $ = (sel, root = document) => root.querySelector(sel); | |
| const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel)); | |
| function debounce(fn, ms) { | |
| let t; | |
| return (...args) => { | |
| clearTimeout(t); | |
| t = setTimeout(() => fn(...args), ms); | |
| }; | |
| } | |
| // ---------------------------------------------------------------- upload | |
| const dropzone = $("#dropzone"); | |
| const fileInput = $("#fileInput"); | |
| const browseBtn = $("#browseBtn"); | |
| const uploadError = $("#uploadError"); | |
| browseBtn.addEventListener("click", () => fileInput.click()); | |
| dropzone.addEventListener("click", (e) => { | |
| if (e.target === browseBtn) return; | |
| fileInput.click(); | |
| }); | |
| fileInput.addEventListener("change", () => { | |
| if (fileInput.files[0]) handleFile(fileInput.files[0]); | |
| }); | |
| ["dragenter", "dragover"].forEach((evt) => | |
| dropzone.addEventListener(evt, (e) => { | |
| e.preventDefault(); | |
| dropzone.classList.add("dragover"); | |
| }) | |
| ); | |
| ["dragleave", "drop"].forEach((evt) => | |
| dropzone.addEventListener(evt, (e) => { | |
| e.preventDefault(); | |
| dropzone.classList.remove("dragover"); | |
| }) | |
| ); | |
| dropzone.addEventListener("drop", (e) => { | |
| const f = e.dataTransfer.files[0]; | |
| if (f) handleFile(f); | |
| }); | |
| $("#newFontBtn").addEventListener("click", () => { | |
| if (state.sessionId) { | |
| fetch(`/api/session/${state.sessionId}`, { method: "DELETE" }).catch(() => {}); | |
| } | |
| state.sessionId = null; | |
| state.chars = {}; | |
| $("#workspace").classList.add("hidden"); | |
| $("#uploadSection").classList.remove("hidden"); | |
| $("#newFontBtn").classList.add("hidden"); | |
| $("#fontInfoBadge").classList.add("hidden"); | |
| fileInput.value = ""; | |
| }); | |
| async function handleFile(file) { | |
| uploadError.classList.add("hidden"); | |
| if (!/\.(ttf|otf)$/i.test(file.name)) { | |
| showUploadError("Lütfen .ttf ya da .otf uzantılı bir dosya seçin."); | |
| return; | |
| } | |
| const fd = new FormData(); | |
| fd.append("font", file); | |
| dropzone.classList.add("dragover"); | |
| dropzone.querySelector(".dropzone-title").textContent = "Yükleniyor..."; | |
| try { | |
| const res = await fetch("/api/upload", { method: "POST", body: fd }); | |
| const data = await res.json(); | |
| if (!res.ok) throw new Error(data.detail || "Yükleme başarısız."); | |
| onUploaded(data); | |
| } catch (err) { | |
| showUploadError(err.message); | |
| } finally { | |
| dropzone.classList.remove("dragover"); | |
| dropzone.querySelector(".dropzone-title").textContent = "TTF veya OTF font dosyanızı buraya sürükleyin"; | |
| } | |
| } | |
| function showUploadError(msg) { | |
| uploadError.textContent = msg; | |
| uploadError.classList.remove("hidden"); | |
| } | |
| function onUploaded(data) { | |
| state.sessionId = data.session_id; | |
| state.fontInfo = data.font_info; | |
| state.chars = {}; | |
| for (const c of data.chars) { | |
| state.chars[c.char] = { | |
| status: c.status, | |
| label: c.label, | |
| codepoint: c.codepoint, | |
| recipe: c.recipe, | |
| originalRecipe: JSON.parse(JSON.stringify(c.recipe)), | |
| warnings: c.warnings, | |
| include: c.status !== "needs_attention" || !!c.recipe.base_glyph, | |
| open: false, | |
| }; | |
| } | |
| $("#uploadSection").classList.add("hidden"); | |
| $("#workspace").classList.remove("hidden"); | |
| $("#newFontBtn").classList.remove("hidden"); | |
| $("#subsetSuccessBanner").classList.add("hidden"); | |
| const badge = $("#fontInfoBadge"); | |
| badge.textContent = `${data.font_info.family_name} · ${data.font_info.format}`; | |
| badge.classList.remove("hidden"); | |
| renderFontWarningBanner(); | |
| renderSidebarInfo(); | |
| renderCharList(); | |
| prefetchAllPreviews(); | |
| } | |
| function renderFontWarningBanner() { | |
| const info = state.fontInfo; | |
| const banner = $("#fontWarningBanner"); | |
| if (!info.has_basic_latin) { | |
| banner.innerHTML = | |
| "<strong class=\"banner-title\">Bu font temel Latin harfleri içermiyor</strong>" + | |
| "Muhtemelen bir ikon/sembol fontu (ör. Font Awesome). Türkçe karakterler mevcut harf " + | |
| "tasarımlarından türetildiği için, hiç harf içermeyen bir fontta otomatik oluşturma yapılamaz " + | |
| "— aşağıdaki karakterler bu yüzden \"Dikkat Gerekiyor\" olarak işaretli."; | |
| banner.classList.remove("hidden"); | |
| } else if (info.glyphs_remaining < info.chars_needing_new_glyphs) { | |
| const needed = info.chars_needing_new_glyphs; | |
| const remaining = info.glyphs_remaining; | |
| let msg; | |
| if (remaining === 0) { | |
| msg = | |
| "<strong class=\"banner-title\">Font glyph kapasitesi dolu</strong>" + | |
| `Bu font zaten OpenType formatının izin verdiği azami 65.535 glyph'in tamamını kullanıyor ` + | |
| `(${info.num_glyphs.toLocaleString("tr-TR")}/65.535), hiç boş yer yok. Yeni çizim gerektiren ` + | |
| `${needed} karakterin hiçbiri eklenemeyecek (fontta zaten bulunanlar bundan etkilenmez).`; | |
| } else { | |
| const missing = needed - remaining; | |
| msg = | |
| "<strong class=\"banner-title\">Font glyph kapasitesi yetersiz</strong>" + | |
| `Yeni glyph için yalnızca ${remaining} yer kaldı, ama ${needed} karakter yeni çizim gerektiriyor ` + | |
| `— ${missing} tanesi sığmayacak.`; | |
| } | |
| banner.innerHTML = msg + renderSubsetToolHtml(); | |
| banner.classList.remove("hidden"); | |
| wireSubsetTool(banner); | |
| } else { | |
| banner.classList.add("hidden"); | |
| } | |
| } | |
| function renderSubsetToolHtml() { | |
| return ` | |
| <div class="subset-tool"> | |
| <p>Yer açmak için fontu subset edebilirsiniz: kullanılmayan glyph'leri kaldırır, | |
| Türkçe kompozisyon için gereken temel Latin harfler/aksanlar her zaman korunur.</p> | |
| <label class="subset-checkbox"> | |
| <input type="checkbox" id="subsetCjkPreset" checked /> | |
| Temel CJK Unified Ideographs bloğunu koru (~20.000 karakter + yaygın noktalama — çoğu kullanım için yeterli) | |
| </label> | |
| <label class="subset-label" for="subsetKeepText">Ayrıca kesin korunmasını istediğiniz metin/karakterler (opsiyonel):</label> | |
| <textarea id="subsetKeepText" class="subset-textarea" rows="2" placeholder="Örn. özel olarak kullandığınız ekstra karakterler ya da kelimeler..."></textarea> | |
| <div class="subset-actions"> | |
| <button id="subsetBtn" type="button" class="btn btn-primary btn-small">Fontu Subset Et</button> | |
| <span id="subsetStatus" class="subset-status"></span> | |
| </div> | |
| </div>`; | |
| } | |
| function wireSubsetTool(banner) { | |
| const btn = banner.querySelector("#subsetBtn"); | |
| const status = banner.querySelector("#subsetStatus"); | |
| btn.addEventListener("click", async () => { | |
| btn.disabled = true; | |
| status.textContent = "Subset ediliyor (büyük fontlarda 30-60 saniye sürebilir)..."; | |
| try { | |
| const keepText = banner.querySelector("#subsetKeepText").value; | |
| const keepCjk = banner.querySelector("#subsetCjkPreset").checked; | |
| const res = await fetch("/api/subset", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ session_id: state.sessionId, keep_text: keepText, keep_cjk_preset: keepCjk }), | |
| }); | |
| const data = await res.json(); | |
| if (!res.ok) throw new Error(data.detail || "Subset başarısız."); | |
| const { glyphs_before, glyphs_after, glyphs_freed } = data.subset_info; | |
| onUploaded(data); | |
| const success = $("#subsetSuccessBanner"); | |
| success.textContent = `Subset tamamlandı: ${glyphs_before.toLocaleString("tr-TR")} → ${glyphs_after.toLocaleString("tr-TR")} glyph (${glyphs_freed.toLocaleString("tr-TR")} glyph boşaldı).`; | |
| success.classList.remove("hidden"); | |
| } catch (err) { | |
| status.textContent = "Hata: " + err.message; | |
| btn.disabled = false; | |
| } | |
| }); | |
| } | |
| // -------------------------------------------------------------- sidebar | |
| function renderSidebarInfo() { | |
| const info = state.fontInfo; | |
| const list = $("#fontInfoList"); | |
| const rows = [ | |
| ["Aile", info.family_name], | |
| ["Stil", info.subfamily_name || "—"], | |
| ["Format", info.format], | |
| ["Units/Em", info.units_per_em], | |
| ["Glyph Sayısı", info.num_glyphs], | |
| ]; | |
| list.innerHTML = rows.map(([k, v]) => `<div><dt>${escapeHtml(k)}</dt><dd>${escapeHtml(String(v))}</dd></div>`).join(""); | |
| renderStatusSummary(); | |
| } | |
| function renderStatusSummary() { | |
| const counts = {}; | |
| Object.values(state.chars).forEach((c) => { | |
| counts[c.status] = (counts[c.status] || 0) + 1; | |
| }); | |
| const ul = $("#statusSummary"); | |
| ul.innerHTML = Object.entries(STATUS_LABEL) | |
| .filter(([k]) => counts[k]) | |
| .map( | |
| ([k, label]) => | |
| `<li><span class="status-dot" style="background:${STATUS_DOT_COLOR[k]}"></span>${label}: ${counts[k]}</li>` | |
| ) | |
| .join(""); | |
| } | |
| // ------------------------------------------------------------- char list | |
| const template = $("#charRowTemplate"); | |
| const charListEl = $("#charList"); | |
| function renderCharList() { | |
| charListEl.innerHTML = ""; | |
| Object.values(state.chars).forEach((cs) => { | |
| const row = template.content.firstElementChild.cloneNode(true); | |
| row.dataset.char = cs.char || ""; | |
| buildRow(row, cs); | |
| charListEl.appendChild(row); | |
| }); | |
| } | |
| function buildRow(row, cs) { | |
| const char = charFor(cs); | |
| row.dataset.char = char; | |
| row.querySelector(".char-title").textContent = `${char} (U+${cs.codepoint.toString(16).toUpperCase().padStart(4, "0")})`; | |
| row.querySelector(".char-label").textContent = cs.label; | |
| const pill = row.querySelector(".char-status-pill"); | |
| pill.textContent = STATUS_LABEL[cs.status] || cs.status; | |
| pill.classList.add(`status-${cs.status}`); | |
| const includeBox = row.querySelector(".include-checkbox"); | |
| if (cs.status === "present") { | |
| row.querySelector(".char-include").classList.add("hidden"); | |
| row.querySelector(".chevron").classList.add("hidden"); | |
| } else { | |
| includeBox.checked = cs.include; | |
| includeBox.addEventListener("change", () => { | |
| cs.include = includeBox.checked; | |
| }); | |
| } | |
| const summaryBtn = row.querySelector(".char-row-summary"); | |
| if (cs.status === "present") { | |
| row.querySelector(".mini-preview").outerHTML = '<span style="font-size:20px;color:#2f7d4f;">✓</span>'; | |
| } else { | |
| summaryBtn.addEventListener("click", (e) => { | |
| if (e.target.closest(".char-include")) return; | |
| toggleRow(row, cs); | |
| }); | |
| } | |
| const editor = row.querySelector(".char-editor"); | |
| const fieldsEl = row.querySelector(".editor-fields"); | |
| renderFields(fieldsEl, cs, row); | |
| } | |
| function charFor(cs) { | |
| return Object.keys(state.chars).find((k) => state.chars[k] === cs); | |
| } | |
| function toggleRow(row, cs) { | |
| cs.open = !cs.open; | |
| row.classList.toggle("open", cs.open); | |
| row.querySelector(".char-editor").classList.toggle("hidden", !cs.open); | |
| if (cs.open) fetchPreview(charFor(cs), true); | |
| } | |
| // ------------------------------------------------------------ field UI | |
| function renderFields(container, cs, row) { | |
| container.innerHTML = ""; | |
| const mode = cs.recipe.mode; | |
| if (cs.status === "existing_unmapped") { | |
| const note = document.createElement("div"); | |
| note.className = "existing-note"; | |
| note.textContent = `Font zaten '${cs.recipe.source_glyph}' adlı bir glyph içeriyor ama bu karaktere eşlenmemiş. Yeni bir çizim yapılmadan doğrudan eşlenecek.`; | |
| container.appendChild(note); | |
| return; | |
| } | |
| if (mode === "skip" && !cs.recipe.base_glyph) { | |
| const note = document.createElement("div"); | |
| note.className = "needs-attention-note"; | |
| note.textContent = "Temel harf bu fontta bulunamadığı için otomatik oluşturma yapılamıyor."; | |
| container.appendChild(note); | |
| return; | |
| } | |
| if (mode === "dotless") { | |
| container.appendChild(glyphField("Kaynak Glyph (gövde)", cs.recipe.base_glyph, (val) => { | |
| cs.recipe.base_glyph = val; | |
| scheduleAndPreview(cs); | |
| })); | |
| container.appendChild(sliderField( | |
| "Nokta Kesme Çizgisi (Y)", cs.recipe.cutoff_y, -200, state.fontInfo.units_per_em * 1.3, 1, | |
| (val) => { cs.recipe.cutoff_y = val; scheduleAndPreview(cs); } | |
| )); | |
| container.appendChild(resetButton(cs)); | |
| return; | |
| } | |
| if (mode === "compose") { | |
| container.appendChild(glyphField("Temel Glyph", cs.recipe.base_glyph, (val) => { | |
| cs.recipe.base_glyph = val; | |
| scheduleAndPreview(cs); | |
| })); | |
| const desc = document.createElement("div"); | |
| desc.className = "source-desc"; | |
| desc.textContent = describeSource(cs.recipe.accent_source); | |
| container.appendChild(desc); | |
| container.appendChild(glyphField("Aksan Glyph (elle değiştir)", "", (val) => { | |
| if (val.trim()) { | |
| cs.recipe.accent_source = val.trim(); | |
| desc.textContent = describeSource(cs.recipe.accent_source); | |
| scheduleAndPreview(cs); | |
| } | |
| }, "Farklı bir glyph adı yazın...")); | |
| const rowFields = document.createElement("div"); | |
| rowFields.className = "field-row"; | |
| rowFields.appendChild(sliderField("X Konumu", cs.recipe.dx, -state.fontInfo.units_per_em, state.fontInfo.units_per_em, 1, | |
| (val) => { cs.recipe.dx = val; scheduleAndPreview(cs); })); | |
| rowFields.appendChild(sliderField("Y Konumu", cs.recipe.dy, -state.fontInfo.units_per_em, state.fontInfo.units_per_em, 1, | |
| (val) => { cs.recipe.dy = val; scheduleAndPreview(cs); })); | |
| container.appendChild(rowFields); | |
| container.appendChild(sliderField("Ölçek", cs.recipe.scale_x, 0.2, 2.5, 0.01, | |
| (val) => { cs.recipe.scale_x = val; cs.recipe.scale_y = val; scheduleAndPreview(cs); })); | |
| container.appendChild(resetButton(cs)); | |
| } | |
| } | |
| function describeSource(source) { | |
| if (!source) return "Aksan kaynağı seçilmedi."; | |
| if (source.startsWith("__donor__:")) { | |
| const donor = source.split(":")[1]; | |
| return `Otomatik: '${donor}' karakterinden ayıklanan şekil kullanılıyor.`; | |
| } | |
| if (source.startsWith("__synthetic__")) { | |
| return "Otomatik: fontta uygun bileşen bulunamadı, yerleşik basit vektör şekli kullanılıyor."; | |
| } | |
| return `Kaynak glyph: '${source}'`; | |
| } | |
| function glyphField(labelText, value, onChange, placeholder) { | |
| const wrap = document.createElement("div"); | |
| wrap.className = "field-group field"; | |
| const label = document.createElement("label"); | |
| label.textContent = labelText; | |
| const input = document.createElement("input"); | |
| input.type = "text"; | |
| input.value = value || ""; | |
| if (placeholder) input.placeholder = placeholder; | |
| const listId = "glyphs-" + Math.random().toString(36).slice(2); | |
| input.setAttribute("list", listId); | |
| const datalist = document.createElement("datalist"); | |
| datalist.id = listId; | |
| const doSearch = debounce(async (q) => { | |
| const params = new URLSearchParams({ session_id: state.sessionId, q, limit: 40 }); | |
| const res = await fetch(`/api/glyphs?${params}`); | |
| if (!res.ok) return; | |
| const data = await res.json(); | |
| datalist.innerHTML = data.glyphs.map((g) => `<option value="${escapeHtml(g)}"></option>`).join(""); | |
| }, 150); | |
| input.addEventListener("input", () => doSearch(input.value)); | |
| input.addEventListener("focus", () => doSearch(input.value)); | |
| input.addEventListener("change", () => onChange(input.value)); | |
| wrap.appendChild(label); | |
| wrap.appendChild(input); | |
| wrap.appendChild(datalist); | |
| return wrap; | |
| } | |
| function sliderField(labelText, value, min, max, step, onChange) { | |
| const wrap = document.createElement("div"); | |
| wrap.className = "field-group field"; | |
| const label = document.createElement("label"); | |
| label.textContent = labelText; | |
| const row = document.createElement("div"); | |
| row.className = "field-slider"; | |
| const range = document.createElement("input"); | |
| range.type = "range"; | |
| range.min = min; range.max = max; range.step = step; range.value = value; | |
| const num = document.createElement("input"); | |
| num.type = "number"; | |
| num.min = min; num.max = max; num.step = step; num.value = round2(value); | |
| const sync = (v) => { | |
| range.value = v; num.value = round2(v); | |
| onChange(parseFloat(v)); | |
| }; | |
| range.addEventListener("input", () => sync(range.value)); | |
| num.addEventListener("change", () => sync(num.value)); | |
| row.appendChild(range); | |
| row.appendChild(num); | |
| wrap.appendChild(label); | |
| wrap.appendChild(row); | |
| return wrap; | |
| } | |
| function resetButton(cs) { | |
| const wrap = document.createElement("div"); | |
| wrap.className = "field-actions"; | |
| const btn = document.createElement("button"); | |
| btn.type = "button"; | |
| btn.className = "btn btn-ghost btn-small"; | |
| btn.textContent = "Otomatik Ayarlara Sıfırla"; | |
| btn.addEventListener("click", () => { | |
| cs.recipe = JSON.parse(JSON.stringify(cs.originalRecipe)); | |
| const row = charListEl.querySelector(`.char-row[data-char="${cssEscape(charFor(cs))}"]`); | |
| const wasOpen = cs.open; | |
| renderFields(row.querySelector(".editor-fields"), cs, row); | |
| if (wasOpen) fetchPreview(charFor(cs), true); | |
| }); | |
| wrap.appendChild(btn); | |
| return wrap; | |
| } | |
| function round2(v) { | |
| return Math.round(parseFloat(v) * 100) / 100; | |
| } | |
| // ------------------------------------------------------------- preview | |
| const previewDebounced = debounce((char) => fetchPreview(char, false), 200); | |
| function scheduleAndPreview(cs) { | |
| previewDebounced(charFor(cs)); | |
| } | |
| async function fetchPreview(char, updateBig) { | |
| const cs = state.chars[char]; | |
| if (!cs || cs.status === "present") return; | |
| let recipe = cs.recipe; | |
| if (cs.status === "existing_unmapped") { | |
| recipe = { mode: "existing", source_glyph: cs.recipe.source_glyph }; | |
| } | |
| try { | |
| const res = await fetch("/api/preview", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ session_id: state.sessionId, char, recipe }), | |
| }); | |
| const data = await res.json(); | |
| if (!res.ok) throw new Error(data.detail || "Önizleme hatası"); | |
| const row = charListEl.querySelector(`.char-row[data-char="${cssEscape(char)}"]`); | |
| if (!row) return; | |
| drawGlyphSVG(row.querySelector(".mini-preview"), data, { tight: true }); | |
| if (updateBig || cs.open) { | |
| drawGlyphSVG(row.querySelector(".big-preview"), data, { tight: false }); | |
| } | |
| const warnEl = row.querySelector(".editor-warnings"); | |
| const allWarnings = [...(cs.warnings || []), ...(data.warnings || [])]; | |
| warnEl.innerHTML = [...new Set(allWarnings)] | |
| .map((w) => `<div class="warn-line">${escapeHtml(w)}</div>`) | |
| .join(""); | |
| } catch (err) { | |
| // sessizce yut - kullanıcı yazarken geçici hatalı ara durumlar olabilir | |
| } | |
| } | |
| function prefetchAllPreviews() { | |
| Object.keys(state.chars).forEach((char) => { | |
| if (state.chars[char].status !== "present") fetchPreview(char, false); | |
| }); | |
| } | |
| function drawGlyphSVG(svgEl, data, { tight }) { | |
| while (svgEl.firstChild) svgEl.removeChild(svgEl.firstChild); | |
| const ns = "http://www.w3.org/2000/svg"; | |
| const upm = state.fontInfo.units_per_em; | |
| const asc = state.fontInfo.ascender; | |
| const desc = state.fontInfo.descender; | |
| let viewW, viewH, tx, ty; | |
| if (tight && data.bounds) { | |
| const [x0, y0, x1, y1] = data.bounds; | |
| const w = Math.max(x1 - x0, 1), h = Math.max(y1 - y0, 1); | |
| const margin = Math.max(w, h) * 0.15; | |
| viewW = w + margin * 2; viewH = h + margin * 2; | |
| tx = margin - x0; ty = y1 + margin; | |
| } else { | |
| const margin = upm * 0.12; | |
| viewW = Math.max(data.advance_width, 1) + margin * 2; | |
| viewH = (asc - desc) + margin * 2; | |
| tx = margin; ty = asc + margin; | |
| } | |
| svgEl.setAttribute("viewBox", `0 0 ${viewW} ${viewH}`); | |
| if (!tight) { | |
| const baselineY = ty; // font y=0 | |
| const guide = (y, dash) => { | |
| const line = document.createElementNS(ns, "line"); | |
| line.setAttribute("x1", 0); line.setAttribute("x2", viewW); | |
| line.setAttribute("y1", y); line.setAttribute("y2", y); | |
| line.setAttribute("stroke", "#d8d2c4"); | |
| line.setAttribute("stroke-width", "1"); | |
| if (dash) line.setAttribute("stroke-dasharray", "4 3"); | |
| svgEl.appendChild(line); | |
| }; | |
| guide(baselineY, false); | |
| guide(ty - asc, true); | |
| guide(ty - desc, true); | |
| } | |
| const g = document.createElementNS(ns, "g"); | |
| g.setAttribute("transform", `translate(${tx} ${ty}) scale(1,-1)`); | |
| if (data.path) { | |
| const path = document.createElementNS(ns, "path"); | |
| path.setAttribute("d", data.path); | |
| path.setAttribute("fill", "#201d18"); | |
| path.setAttribute("fill-rule", "nonzero"); | |
| g.appendChild(path); | |
| } | |
| svgEl.appendChild(g); | |
| } | |
| // --------------------------------------------------------------- build | |
| $("#buildBtn").addEventListener("click", async () => { | |
| const btn = $("#buildBtn"); | |
| btn.disabled = true; | |
| btn.textContent = "Oluşturuluyor..."; | |
| const reportEl = $("#buildReport"); | |
| reportEl.classList.add("hidden"); | |
| try { | |
| const recipes = {}; | |
| Object.entries(state.chars).forEach(([char, cs]) => { | |
| if (cs.status === "present") return; | |
| if (!cs.include) { recipes[char] = { mode: "skip" }; return; } | |
| if (cs.status === "existing_unmapped") { | |
| recipes[char] = { mode: "existing", source_glyph: cs.recipe.source_glyph }; | |
| } else { | |
| recipes[char] = cs.recipe; | |
| } | |
| }); | |
| const res = await fetch("/api/build", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ session_id: state.sessionId, recipes }), | |
| }); | |
| const data = await res.json(); | |
| if (!res.ok) throw new Error(data.detail || "Oluşturma başarısız."); | |
| showBuildReport(data); | |
| } catch (err) { | |
| reportEl.classList.remove("hidden"); | |
| reportEl.innerHTML = `<div class="error-box">${escapeHtml(err.message)}</div>`; | |
| } finally { | |
| btn.disabled = false; | |
| btn.textContent = "Fontu Oluştur ve İndir"; | |
| } | |
| }); | |
| function showBuildReport(data) { | |
| const reportEl = $("#buildReport"); | |
| reportEl.classList.remove("hidden"); | |
| const lines = []; | |
| Object.entries(data.report || {}).forEach(([char, msgs]) => { | |
| msgs.forEach((m) => lines.push(`<li><strong>${escapeHtml(char)}</strong>: ${escapeHtml(m)}</li>`)); | |
| }); | |
| const byteChars = atob(data.font_base64); | |
| const bytes = new Uint8Array(byteChars.length); | |
| for (let i = 0; i < byteChars.length; i++) bytes[i] = byteChars.charCodeAt(i); | |
| const blob = new Blob([bytes], { type: "font/ttf" }); | |
| const url = URL.createObjectURL(blob); | |
| reportEl.innerHTML = ` | |
| <h3>Sonuç</h3> | |
| <ul>${lines.join("") || "<li>Değişiklik yapılmadı.</li>"}</ul> | |
| <a class="download-link" href="${url}" download="${escapeHtml(data.filename)}">⬇ ${escapeHtml(data.filename)} indir</a> | |
| `; | |
| } | |
| // --------------------------------------------------------------- utils | |
| function escapeHtml(str) { | |
| return String(str).replace(/[&<>"']/g, (c) => ({ | |
| "&": "&", "<": "<", ">": ">", '"': """, "'": "'", | |
| }[c])); | |
| } | |
| function cssEscape(str) { | |
| return String(str).replace(/["\\]/g, "\\$&"); | |
| } | |
| })(); | |