PageSourceSearch

https://productype.com/wp-content/themes/pdt-theme/9option/g9-tabs-final.js

js productype.com collected 2026-10-04 17:41:18 UTC 30,274 bytes, 742 lines download raw bytes

1/* ============================================================
2   G9 TABS FINAL v3 — Text Tester, Font Showcase, Glyph
3   + Multi-family selector (fontname0..N via data-fid)
4   + Variable font axis sliders (baca tabel `fvar` via opentype.js)
5
6   Perubahan dari v2:
7   - v2 cuma baca #fontname0 -> font family >1 (mis. 18 family)
8     gagal ke-load karena script memang belum pernah "melihat"
9     elemen #fontname1..N.
10   - v3 mengumpulkan SEMUA elemen `.fontname` (0..N), bikin dropdown
11     Family kalau jumlahnya >1, dan fetch on-demand per family lewat
12     AJAX yang sama (tfa_initprevtxtcmap) — endpoint & nonce dipinjam
13     dari #fontname0 karena cuma elemen itu yang punya atributnya.
14   - Variable font: cmap/glyph set memang identik di semua bobot
15     (itu bukan bug, karena variable font cuma interpolasi outline,
16     bukan glyph terpisah per bobot). Yang ditambahkan di sini adalah
17     slider per-axis (dibaca dari tabel `fvar`) yang mengubah
18     `font-variation-settings` secara live pada teks preview.
19     Catatan jujur: perubahan axis ini berlaku untuk semua teks biasa
20     (Tester, Showcase, dan karakter di Glyph yang dirender sebagai
21     teks span), TAPI tidak berlaku pada glyph tanpa kode Unicode yang
22     digambar manual lewat SVG path (opentype.js versi ini tidak
23     menghitung interpolasi outline per-axis, cuma baca outline default).
24   ============================================================ */
25(function () {
26  'use strict';
27
28  function ready(fn) {
29    if (document.readyState !== 'loading') fn();
30    else document.addEventListener('DOMContentLoaded', fn);
31  }
32
33  ready(init);
34
35  function init() {
36    if (!document.body.classList.contains('single-product')) return;
37
38    var fontProbe = document.getElementById('fontname0');
39    var testerIdEl = document.querySelector('.fontesterid');
40    var fontGroup = document.querySelector('.fontprevgroup');
41
42    if (!fontProbe || !testerIdEl || !fontGroup) {
43      console.warn('[g9] Block Type Tester (.fontprevgroup) tidak ditemukan di halaman ini.');
44      return;
45    }
46
47    var productId = testerIdEl.dataset.id;
48    var endpoint = fontProbe.dataset.endpoint;
49    var nonce = fontProbe.dataset.nonce;
50
51    if (!endpoint || !nonce || !productId) {
52      console.warn('[g9] Data endpoint/nonce/productId tidak lengkap.');
53      return;
54    }
55
56    // ── 0. Kumpulkan SEMUA family: #fontname0, #fontname1, ... #fontname{N} ──
57    var families = collectFamilies(fontGroup);
58    if (!families.length) {
59      console.warn('[g9] Tidak ada elemen .fontname yang ditemukan.');
60      return;
61    }
62
63    // ── 1. Bangun struktur wrapper + controls bar + tab nav ──
64    var wrapper = document.createElement('div');
65    wrapper.className = 'g9-tabs-wrapper';
66
67    var controlsBar = document.createElement('div');
68    controlsBar.className = 'g9-controls-bar';
69    wrapper.appendChild(controlsBar);
70
71    var familySelectWrap = null;
72    var familySelect = null;
73    if (families.length > 1) {
74      familySelectWrap = document.createElement('div');
75      familySelectWrap.className = 'g9-family-select';
76      var famLabel = document.createElement('label');
77      famLabel.setAttribute('for', 'g9-family-picker');
78      famLabel.textContent = 'Font Style';
79      familySelect = document.createElement('select');
80      familySelect.id = 'g9-family-picker';
81      families.forEach(function (fam, idx) {
82        var opt = document.createElement('option');
83        opt.value = String(idx);
84        opt.textContent = fam.displayName;
85        familySelect.appendChild(opt);
86      });
87      familySelectWrap.appendChild(famLabel);
88      familySelectWrap.appendChild(familySelect);
89      controlsBar.appendChild(familySelectWrap);
90    }
91
92    var axisPanel = document.createElement('div');
93    axisPanel.className = 'g9-axis-panel';
94    axisPanel.style.display = 'none';
95    controlsBar.appendChild(axisPanel);
96
97    var nav = document.createElement('nav');
98    nav.className = 'g9-tabs-nav';
99    nav.setAttribute('role', 'tablist');
100
101    var tabs = [
102      { id: 'tester', label: 'Text Tester' },
103      { id: 'showcase', label: 'Font Showcase' },
104      { id: 'glyph', label: 'Glyph' },
105      { id: 'info', label: 'Product Info' }
106    ];
107
108    var panels = {};
109    tabs.forEach(function (tab, idx) {
110      var btn = document.createElement('button');
111      btn.type = 'button';
112      btn.dataset.tab = tab.id;
113      btn.setAttribute('role', 'tab');
114      if (idx === 0) btn.classList.add('is-active');
115      btn.textContent = tab.label;
116      nav.appendChild(btn);
117
118      var panel = document.createElement('div');
119      panel.className = 'g9-tab-panel';
120      panel.id = 'g9tab-' + tab.id;
121      if (idx === 0) panel.classList.add('is-active');
122      wrapper.appendChild(panel);
123      panels[tab.id] = panel;
124    });
125    wrapper.appendChild(nav);
126    // urutan visual: controlsBar sudah appended pertama, nav kedua — betulkan urutan DOM
127    wrapper.insertBefore(nav, panels.tester);
128    wrapper.insertBefore(controlsBar, nav);
129
130    fontGroup.insertAdjacentElement('afterend', wrapper);
131    // Sembunyikan, jangan dihapus — data (#fontname0..N dataset, dll) tetap bisa dibaca script tema lain
132    fontGroup.style.display = 'none';
133
134    // ── Pindahkan blok "File Size / License options" (.g9productpack) yang
135    // tadinya duduk di baris bawah galeri foto ke tab "Product Info" ini.
136    // Ini DOM MOVE (appendChild memindah node aslinya, bukan menyalin/
137    // menduplikasi), jadi kontennya, harga, checkbox, dan semua event
138    // handler WooCommerce-nya tetap sama persis — cuma lokasinya berubah,
139    // otomatis membuat baris itu tidak lagi nangkring di sebelah foto utama. ──
140    var productPack = document.querySelector('.g9productpack');
141    if (productPack) {
142      panels.info.appendChild(productPack);
143    }
144
145    // ── Pindahkan juga "Product Detail" (judul + isi deskripsi produk dari
146    // the_content()) ke tab "Product Info" ini, di bawah .g9productpack di
147    // atas — DOM MOVE juga, jadi kontennya (termasuk HTML/gambar apapun di
148    // dalam deskripsi) tetap persis sama, cuma pindah lokasi ke dalam tab. ──
149    var productDesc = document.querySelector('.g9productdesc');
150    if (productDesc) {
151      panels.info.appendChild(productDesc);
152    }
153
154    if (!productPack && !productDesc) {
155      panels.info.innerHTML = '<div class="g9-loading-status">Info produk tidak ditemukan di halaman ini.</div>';
156    }
157
158    var buttons = nav.querySelectorAll('button');
159    buttons.forEach(function (btn) {
160      btn.addEventListener('click', function () {
161        buttons.forEach(function (b) { b.classList.remove('is-active'); });
162        this.classList.add('is-active');
163        wrapper.querySelectorAll('.g9-tab-panel').forEach(function (p) { p.classList.remove('is-active'); });
164        panels[this.dataset.tab].classList.add('is-active');
165      });
166    });
167
168    // ── 2. Toast kecil untuk copy-to-clipboard ──
169    var toast = document.createElement('div');
170    toast.className = 'g9-toast';
171    document.body.appendChild(toast);
172    function showToast(msg) {
173      toast.textContent = msg;
174      toast.classList.add('show');
175      clearTimeout(toast._t);
176      toast._t = setTimeout(function () { toast.classList.remove('show'); }, 1400);
177    }
178    function copyText(str) {
179      if (navigator.clipboard && navigator.clipboard.writeText) {
180        navigator.clipboard.writeText(str).then(function () { showToast('Copied: "' + str + '"'); });
181      } else {
182        var ta = document.createElement('textarea');
183        ta.value = str;
184        document.body.appendChild(ta);
185        ta.select();
186        try { document.execCommand('copy'); showToast('Copied: "' + str + '"'); } catch (e) {}
187        document.body.removeChild(ta);
188      }
189    }
190
191    // ── 3. State per-family cache (biar ganti-ganti dropdown tidak fetch ulang) ──
192    var cache = {}; // idx -> { family, format, urlfont, font(opentype)|null, axes: [] }
193    var activeIdx = 0;
194    var appliedFamilyCss = 'g9customfont-' + productId; // dipakai ulang, ganti isi @font-face lewat FontFace API per idx
195
196    // Batas waktu tunggu proses baca glyph/axis lewat opentype.js. Beberapa
197    // format/varian font (mis. WOFF dengan kompresi tertentu, atau OTF
198    // variable berbasis CFF2) bisa membuat opentype.js diam tanpa pernah
199    // memanggil callback-nya (bukan error biasa, tapi benar-benar tidak
200    // pernah selesai). Tanpa batas waktu ini, ketiga tab akan macet
201    // selamanya di "Memuat data font asli...". Dengan timeout, Text Tester
202    // & Font Showcase tetap tampil (mereka tidak butuh opentype.js sama
203    // sekali — cukup FontFace API bawaan browser) dan hanya tab Glyph +
204    // panel weight/axis yang menampilkan pesan "tidak tersedia" bila
205    // memang gagal/lambat.
206    var OPENTYPE_TIMEOUT_MS = 15000;
207
208    function familyCssName(idx) { return 'g9customfont-' + productId + '-' + idx; }
209
210    function setLoadingState(msg) {
211      panels.tester.innerHTML = '<div class="g9-loading-status">' + escapeHtml(msg) + '</div>';
212      panels.showcase.innerHTML = '<div class="g9-loading-status">' + escapeHtml(msg) + '</div>';
213      panels.glyph.innerHTML = '<div class="g9-loading-status">' + escapeHtml(msg) + '</div>';
214    }
215
216    function loadFamily(idx) {
217      activeIdx = idx;
218      var fam = families[idx];
219      axisPanel.style.display = 'none';
220      axisPanel.innerHTML = '';
221
222      if (cache[idx]) {
223        renderCore(cache[idx], fam);
224        renderGlyphAndAxis(cache[idx], fam);
225        return;
226      }
227
228      setLoadingState('Memuat data font asli\u2026');
229
230      fetchRealFont({
231        endpoint: endpoint,
232        nonce: nonce,
233        posaidi: productId,
234        fontsource: fam.fid
235      }).then(function (data) {
236        var family = familyCssName(idx);
237        var face = new FontFace(family, 'url(' + data.urlfont + ')');
238        document.fonts.add(face);
239
240        var entry = { family: family, format: data.format, urlfont: data.urlfont, font: null, axes: [] };
241        cache[idx] = entry;
242
243        face.load().then(function () {
244          if (activeIdx === idx) applyFontFamily(wrapper, family);
245        }).catch(function (err) {
246          console.warn('[g9] FontFace gagal dimuat untuk "' + fam.displayName + '":', err);
247        });
248
249        // ── Tester & Showcase cuma butuh FontFace, TIDAK butuh opentype.js.
250        // Tampilkan segera supaya format apapun (otf/ttf/woff/woff2) tidak
251        // pernah macet di dua tab ini, apapun yang terjadi pada opentype.js. ──
252        if (activeIdx === idx) renderCore(entry, fam);
253
254        if (data.format === 'woff2') {
255          entry.woff2 = true;
256          renderGlyphAndAxis(entry, fam);
257          cleartrack(endpoint, nonce, data.fontpath);
258          return;
259        }
260
261        panels.glyph.innerHTML = '<div class="g9-loading-status">Memuat data glyph\u2026</div>';
262
263        withTimeout(loadFontForGlyphData(data.urlfont), OPENTYPE_TIMEOUT_MS)
264          .then(function (font) {
265            entry.font = font;
266            entry.axes = extractVariableAxes(font);
267          })
268          .catch(function (err) {
269            console.warn('[g9] Data glyph/axis tidak tersedia untuk "' + fam.displayName + '" (' + data.format + '):', err);
270            entry.glyphError = true;
271          })
272          .then(function () {
273            cleartrack(endpoint, nonce, data.fontpath); // file temp server sudah tidak diperlukan lagi
274            if (activeIdx === idx) renderGlyphAndAxis(entry, fam);
275          });
276      }).catch(function (err) {
277        console.error('[g9] Gagal mengambil font "' + fam.displayName + '":', err);
278        panels.tester.innerHTML = '<div class="g9-loading-status">Gagal memuat font ini (' + escapeHtml(err.message) + ').</div>';
279        panels.showcase.innerHTML = '';
280        panels.glyph.innerHTML = '';
281      });
282    }
283
284    // Bungkus promise apapun dengan batas waktu, supaya proses yang macet
285    // (bukan cuma yang eksplisit reject) tetap dianggap gagal setelah N ms.
286    function withTimeout(promise, ms) {
287      return new Promise(function (resolve, reject) {
288        var done = false;
289        var timer = setTimeout(function () {
290          if (done) return;
291          done = true;
292          reject(new Error('Timeout setelah ' + ms + 'ms'));
293        }, ms);
294        promise.then(function (val) {
295          if (done) return;
296          done = true;
297          clearTimeout(timer);
298          resolve(val);
299        }, function (err) {
300          if (done) return;
301          done = true;
302          clearTimeout(timer);
303          reject(err);
304        });
305      });
306    }
307
308    // Muat opentype.js (kalau belum ada) lalu parse font untuk data glyph/axis.
309    function loadFontForGlyphData(urlfont) {
310      return new Promise(function (resolve, reject) {
311        loadOpentype(function (err) {
312          if (err) { reject(err); return; }
313          try {
314            window.opentype.load(urlfont, function (loadErr, font) {
315              if (loadErr) { reject(loadErr); return; }
316              resolve(font);
317            });
318          } catch (syncErr) {
319            // Sebagian versi opentype.js bisa throw langsung (bukan
320            // lewat callback) untuk file yang tidak didukung penuh
321            // (mis. OTF variable/CFF2). Tangkap di sini supaya tidak
322            // jadi uncaught error yang membekukan proses.
323            reject(syncErr);
324          }
325        });
326      });
327    }
328
329    // Render Text Tester + Font Showcase. Independen dari opentype.js.
330    function renderCore(entry, fam) {
331      renderTester(panels.tester, fam.displayName);
332      renderShowcase(panels.showcase, fam.displayName);
333      if (document.fonts.check('1em "' + entry.family + '"')) {
334        applyFontFamily(wrapper, entry.family);
335      }
336    }
337
338    // Render tab Glyph + panel slider weight/axis. Bergantung pada opentype.js,
339    // jadi bisa gagal per-format tanpa memengaruhi Tester/Showcase di atas.
340    function renderGlyphAndAxis(entry, fam) {
341      if (entry.woff2) {
342        renderGlyphWoff2Notice(panels.glyph);
343      } else if (entry.font) {
344        try {
345          renderGlyphTab(panels.glyph, entry.font, copyText);
346        } catch (renderErr) {
347          console.error('[g9] Gagal render Glyph tab:', renderErr);
348          panels.glyph.innerHTML = '<div class="g9-glyph-status">Terjadi error saat menampilkan glyph (' + escapeHtml(renderErr.message) + ').</div>';
349        }
350      } else if (entry.glyphError) {
351        panels.glyph.innerHTML = '<div class="g9-glyph-status">Preview glyph &amp; weight variable tidak didukung browser ini untuk file ' + escapeHtml((entry.format || '').toString()) + ' ini. Text Tester dan Font Showcase di atas tetap memakai font aslinya.</div>';
352      } else {
353        panels.glyph.innerHTML = '<div class="g9-glyph-status">Data glyph tidak tersedia untuk font ini.</div>';
354      }
355
356      renderAxisSliders(entry, wrapper, axisPanel);
357
358      if (document.fonts.check('1em "' + entry.family + '"')) {
359        applyFontFamily(wrapper, entry.family);
360      }
361    }
362
363    if (familySelect) {
364      familySelect.addEventListener('change', function () {
365        loadFamily(parseInt(this.value, 10));
366      });
367    }
368
369    loadFamily(0);
370
371    console.log('[g9] Tabs v3 siap —', families.length, 'family terdeteksi.');
372  }
373
374  // ── Kumpulkan semua elemen .fontname (fontname0..N) + nama tampilan + meta key ──
375  function collectFamilies(fontGroup) {
376    var nodes = fontGroup.querySelectorAll('.fontname');
377    var out = [];
378    nodes.forEach(function (node, i) {
379      var fid = node.dataset.fid;
380      if (!fid) return;
381      var displayName = 'Style ' + (i + 1);
382      var next = node.nextElementSibling;
383      if (next && next.classList.contains('fttlfrm')) {
384        var ttl = next.querySelector('.fttl');
385        if (ttl && ttl.textContent.trim()) displayName = ttl.textContent.trim();
386      }
387      out.push({ fid: fid, displayName: displayName });
388    });
389    return out;
390  }
391
392  // ── Helper: AJAX request identik dengan yang dipakai tema (tfa_initprevtxtcmap) ──
393  function fetchRealFont(opts) {
394    return new Promise(function (resolve, reject) {
395      var fd = new FormData();
396      fd.append('action', 'tfa_initprevtxtcmap');
397      fd.append('posaidi', opts.posaidi);
398      fd.append('fontsource', opts.fontsource);
399
400      var xhr = new XMLHttpRequest();
401      xhr.open('POST', opts.endpoint, true);
402      xhr.setRequestHeader('X-WP-Nonce', opts.nonce);
403      xhr.onload = function () {
404        try {
405          var json = JSON.parse(xhr.responseText);
406          if (!json || !json.urlfont) { reject(new Error('Response tidak berisi urlfont')); return; }
407          resolve(json);
408        } catch (e) { reject(e); }
409      };
410      xhr.onerror = function () { reject(new Error('Network error saat mengambil font')); };
411      xhr.send(fd);
412    });
413  }
414
415  // ── Helper: bersihkan file temp di server (samain dengan cleartrack tema) ──
416  function cleartrack(endpoint, nonce, fontpath) {
417    if (!fontpath) return;
418    var fd = new FormData();
419    fd.append('action', 'tfa_cleartrack');
420    fd.append('fontpath', fontpath);
421    var xhr = new XMLHttpRequest();
422    xhr.open('POST', endpoint, true);
423    xhr.setRequestHeader('X-WP-Nonce', nonce);
424    xhr.send(fd);
425  }
426
427  // ── Helper: load opentype.js sekali saja ──
428  // Sumber utama: build resmi jsdelivr yang sudah terbukti cepat & stabil.
429  // Sumber cadangan: build terbaru dari opentype.js.org (langsung dari
430  // branch master), dicoba HANYA kalau sumber utama gagal — supaya kasus
431  // normal (mayoritas font TTF/OTF/WOFF) tetap secepat sebelumnya dan
432  // tidak kena timeout gara-gara menunggu sumber cadangan yang lebih lambat.
433  var OPENTYPE_CDN_SOURCES = [
434    'https://cdn.jsdelivr.net/npm/[email protected]/dist/opentype.min.js',
435    'https://opentype.js.org/dist/opentype.min.js'
436  ];
437  var _otPromise = null;
438  function loadScriptFrom(sources, i) {
439    return new Promise(function (resolve, reject) {
440      if (i >= sources.length) { reject(new Error('Semua sumber opentype.js gagal dimuat.')); return; }
441      var s = document.createElement('script');
442      s.src = sources[i];
443      s.async = true;
444      s.onload = function () {
445        if (window.opentype && typeof window.opentype.load === 'function') {
446          resolve();
447        } else {
448          resolve(loadScriptFrom(sources, i + 1));
449        }
450      };
451      s.onerror = function () { resolve(loadScriptFrom(sources, i + 1)); };
452      document.head.appendChild(s);
453    });
454  }
455  function loadOpentype(cb) {
456    if (window.opentype && typeof window.opentype.load === 'function') { cb(null); return; }
457    if (!_otPromise) { _otPromise = loadScriptFrom(OPENTYPE_CDN_SOURCES, 0); }
458    _otPromise.then(function () {
459      if (window.opentype && typeof window.opentype.load === 'function') { cb(null); }
460      else { cb(new Error('opentype.js tidak tersedia setelah semua sumber dicoba.')); }
461    }).catch(function (err) { cb(err); });
462  }
463
464  function applyFontFamily(wrapper, family) {
465    wrapper.querySelectorAll('.font-preview, .preview, .g9-showcase-style-title, .g9-glyph-item .char').forEach(function (el) {
466      el.style.fontFamily = '"' + family + '"';
467    });
468    wrapper.dataset.fontFamily = family;
469  }
470
471  function escapeHtml(str) {
472    var div = document.createElement('div');
473    div.textContent = str;
474    return div.innerHTML;
475  }
476
477  // ── Variable font: baca tabel fvar via opentype.js ──
478  function extractVariableAxes(font) {
479    if (!font.tables || !font.tables.fvar || !font.tables.fvar.axes) return [];
480    return font.tables.fvar.axes.map(function (axis) {
481      var label = axis.tag;
482      if (axis.name && axis.name.en) label = axis.name.en;
483      else if (typeof axis.name === 'string') label = axis.name;
484      return {
485        tag: axis.tag,
486        label: label,
487        min: axis.minValue,
488        max: axis.maxValue,
489        def: axis.defaultValue
490      };
491    });
492  }
493
494  function renderAxisSliders(entry, wrapper, axisPanel) {
495    axisPanel.innerHTML = '';
496    if (!entry.axes || !entry.axes.length) {
497      axisPanel.style.display = 'none';
498      return;
499    }
500    axisPanel.style.display = 'flex';
501
502    var currentValues = {};
503    entry.axes.forEach(function (axis) { currentValues[axis.tag] = axis.def; });
504
505    function applyVariationSettings() {
506      var css = Object.keys(currentValues).map(function (tag) {
507        return '"' + tag + '" ' + currentValues[tag];
508      }).join(', ');
509      wrapper.querySelectorAll('.font-preview, .preview, .g9-showcase-style-title, .g9-glyph-item .char').forEach(function (el) {
510        el.style.fontVariationSettings = css;
511      });
512    }
513
514    entry.axes.forEach(function (axis) {
515      var row = document.createElement('div');
516      row.className = 'g9-axis-row';
517
518      var labelRow = document.createElement('div');
519      labelRow.className = 'g9-axis-label-row';
520      var label = document.createElement('label');
521      label.textContent = axis.label + ' (' + axis.tag + ')';
522      var valueTag = document.createElement('span');
523      valueTag.className = 'g9-axis-value';
524      valueTag.textContent = axis.def;
525      labelRow.appendChild(label);
526      labelRow.appendChild(valueTag);
527
528      var input = document.createElement('input');
529      input.type = 'range';
530      input.min = axis.min;
531      input.max = axis.max;
532      input.step = (axis.max - axis.min > 20) ? 1 : 0.01;
533      input.value = axis.def;
534      input.addEventListener('input', function () {
535        currentValues[axis.tag] = this.value;
536        valueTag.textContent = this.value;
537        applyVariationSettings();
538      });
539
540      row.appendChild(labelRow);
541      row.appendChild(input);
542      axisPanel.appendChild(row);
543    });
544
545    var note = document.createElement('div');
546    note.className = 'g9-axis-note';
547    note.textContent = 'Adjust the font weight to see live changes in the text preview. Image glyphs without Unicode are not affected.';
548    axisPanel.appendChild(note);
549
550    applyVariationSettings();
551  }
552
553  // ── Render Text Tester ──
554  var G9_DEFAULT_TESTER_TEXT = 'The quick brown fox jumps over the lazy dog';
555  function renderTester(panel, fontDisplayName) {
556    panel.innerHTML =
557      '<div class="g9-font-tester">' +
558      '<label for="g9-tester-input">Type Tester</label>' +
559      '<input type="text" id="g9-tester-input" class="tester-input" placeholder="Type your text here...">' +
560      '<div class="preview" id="g9-tester-preview">' + escapeHtml(G9_DEFAULT_TESTER_TEXT) + '</div>' +
561      '</div>';
562    var testerInput = panel.querySelector('#g9-tester-input');
563    var testerPreview = panel.querySelector('#g9-tester-preview');
564    testerInput.addEventListener('input', function () {
565      testerPreview.textContent = this.value || G9_DEFAULT_TESTER_TEXT;
566    });
567  }
568
569  // ── Render Font Showcase — konten kustom tema "penjaga suar" (bukan tema kompetitor) ──
570  var SHOWCASE_BLOCKS = [
571    { title: 'UPPERCASE', text: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' },
572    { title: 'LOWERCASE', text: 'abcdefghijklmnopqrstuvwxyz' },
573    { title: 'NUMBERS', text: '0123456789' },
574    { title: 'SYMBOLS', text: '!@#$%^&*()_+-=[]{}|;:\',.<>?/~`' },
575    { title: 'FULL NAME', text: 'Prof.Nathaniel Ashworth-Cole, Ph.D' },
576    { title: 'TITLE', text: 'Keeper of the Nightbeam Light' },
577    { title: 'ADDRESS', text: '14 Harbor Light Lane, Port Solitude, ME 04654' },
578    { title: 'PHONE', text: '+1 (207) 555-0148' },
579    { title: 'EMAIL', text: '[email protected]' },
580    { title: 'URL', text: 'www.portsolitudelighthouse.com' },
581    { title: 'SHORT SENTENCE', text: 'The beacon burned steady through the fog.' },
582    { title: 'MEDIUM SENTENCE', text: 'Every night for forty years, Elias climbed the spiral stairs to light the lamp before the tide turned.' },
583    {
584      title: 'LONG SENTENCE',
585      text: 'Long before the coastal road existed, ships found their way home by a single flame Elias tended alone, ' +
586            'and even now, retired and grey, he still walks up at dusk just to be sure the light is lit.'
587    }
588  ];
589
590  function renderShowcase(panel, fontDisplayName) {
591    panel.innerHTML = '';
592    var showcaseDiv = document.createElement('div');
593    showcaseDiv.className = 'g9-showcase';
594
595    var styleHeader = document.createElement('div');
596    styleHeader.className = 'g9-showcase-style-title';
597    styleHeader.textContent = fontDisplayName;
598    showcaseDiv.appendChild(styleHeader);
599
600    SHOWCASE_BLOCKS.forEach(function (block) {
601      var el = document.createElement('div');
602      el.className = 'example-block';
603      el.innerHTML = '<h3><span class="label-chip">' + escapeHtml(block.title) + '</span></h3><div class="font-preview">' + escapeHtml(block.text) + '</div>';
604      showcaseDiv.appendChild(el);
605    });
606    panel.appendChild(showcaseDiv);
607  }
608
609  function renderGlyphWoff2Notice(panel) {
610    panel.innerHTML =
611      '<div class="g9-glyph-status">Preview karakter tidak didukung untuk format WOFF2 ' +
612      '(pembatasan yang sama seperti Character Map bawaan tema).</div>';
613  }
614
615  // ── Kategorisasi Unicode nyata (dibaca dari glyph.unicodes / glyph.name via opentype.js) ──
616  var CATEGORY_LABELS = {
617    basic: 'Basic Latin',
618    numeric: 'Numeric',
619    punctuation: 'Punctuation',
620    extended: 'Extended Latin',
621    math: 'Math Symbols',
622    currency: 'Currency',
623    othersymbols: 'Other Symbols',
624    other: 'Other',
625    remaining: 'Remaining Glyphs'
626  };
627  var CATEGORY_ORDER = ['basic', 'numeric', 'punctuation', 'extended', 'math', 'currency', 'othersymbols', 'other', 'remaining'];
628
629  function categorizeCodepoint(cp) {
630    var ch = String.fromCodePoint(cp);
631    var asciiPunct = '!"#%&\'()*,-./:;?@[\\]_{}';
632    if (cp === 0x24 || cp === 0xA2 || cp === 0xA3 || cp === 0xA4 || cp === 0xA5 || (cp >= 0x20A0 && cp <= 0x20CF)) return 'currency';
633    if (cp === 0x2B || cp === 0x3C || cp === 0x3D || cp === 0x3E || cp === 0xB1 || cp === 0xD7 || cp === 0xF7 || (cp >= 0x2200 && cp <= 0x22FF)) return 'math';
634    if (cp >= 0x30 && cp <= 0x39) return 'numeric';
635    if (asciiPunct.indexOf(ch) !== -1) return 'punctuation';
636    if (cp >= 0x2000 && cp <= 0x206F) return 'punctuation';
637    if (cp === 0x20 || (cp >= 0x41 && cp <= 0x5A) || (cp >= 0x61 && cp <= 0x7A) || cp === 0x5E || cp === 0x60 || cp === 0x7E) return 'basic';
638    if ((cp >= 0xC0 && cp <= 0xFF) || (cp >= 0x100 && cp <= 0x17F) || (cp >= 0x180 && cp <= 0x24F)) return 'extended';
639    if ([0xA1, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB, 0xAC, 0xAE, 0xAF, 0xB0, 0xB4, 0xB6, 0xB7, 0xB8, 0xBA, 0xBB, 0xBC, 0xBD, 0xBE, 0xBF, 0x2122].indexOf(cp) !== -1) return 'othersymbols';
640    if (cp >= 0x2190 && cp <= 0x21FF) return 'othersymbols';
641    if (cp >= 0x2600 && cp <= 0x27BF) return 'othersymbols';
642    return 'other';
643  }
644
645  function renderGlyphTab(panel, font, copyText) {
646    var buckets = {};
647    CATEGORY_ORDER.forEach(function (k) { buckets[k] = []; });
648
649    var total = font.glyphs.length;
650    for (var i = 0; i < total; i++) {
651      var glyph = font.glyphs.get(i);
652      var gname = glyph.name || '';
653      if (gname === '.notdef' || gname === '.null' || gname === 'nonmarkingreturn') continue;
654      var unicodes = glyph.unicodes || [];
655      if (!unicodes.length) {
656        buckets.remaining.push({ glyph: glyph, name: glyph.name || ('glyph' + i) });
657        continue;
658      }
659      unicodes.forEach(function (cp) {
660        var cat = categorizeCodepoint(cp);
661        buckets[cat].push({ cp: cp, name: glyph.name || ('U+' + cp.toString(16)), glyph: glyph });
662      });
663    }
664
665    panel.innerHTML = '';
666    var unitsPerEm = font.unitsPerEm || 1000;
667
668    CATEGORY_ORDER.forEach(function (catKey) {
669      var items = buckets[catKey];
670      if (!items.length) return;
671
672      var section = document.createElement('div');
673      section.className = 'g9-glyph-section';
674      var h = document.createElement('h3');
675      h.textContent = CATEGORY_LABELS[catKey] + ' (' + items.length + ')';
676      section.appendChild(h);
677
678      var grid = document.createElement('div');
679      grid.className = 'g9-glyph-grid';
680
681      items.forEach(function (item) {
682        var cell = document.createElement('div');
683        cell.className = 'g9-glyph-item';
684
685        if (catKey === 'remaining') {
686          cell.appendChild(glyphToSvg(item.glyph, unitsPerEm));
687          cell.title = item.name;
688          cell.addEventListener('click', function () { copyText(item.name); });
689        } else {
690          var charSpan = document.createElement('span');
691          charSpan.className = 'char';
692          charSpan.textContent = String.fromCodePoint(item.cp);
693          cell.appendChild(charSpan);
694          cell.title = item.name;
695          cell.addEventListener('click', function () { copyText(String.fromCodePoint(item.cp)); });
696        }
697
698        var nameSpan = document.createElement('span');
699        nameSpan.className = 'gname';
700        nameSpan.textContent = item.name;
701        cell.appendChild(nameSpan);
702
703        if (item.cp) {
704          var uni = document.createElement('span');
705          uni.className = 'unicode';
706          uni.textContent = 'U+' + item.cp.toString(16).toUpperCase().padStart(4, '0');
707          cell.appendChild(uni);
708        }
709
710        var badge = document.createElement('span');
711        badge.className = 'copy-badge';
712        badge.textContent = '\ud83d\udccb copy';
713        cell.appendChild(badge);
714
715        grid.appendChild(cell);
716      });
717
718      section.appendChild(grid);
719      panel.appendChild(section);
720    });
721
722    if (!panel.children.length) {
723      panel.innerHTML = '<div class="g9-glyph-status">Tidak ada data glyph yang bisa dibaca dari font ini.</div>';
724    }
725  }
726
727  // Gambar outline glyph langsung dari path font (untuk glyph tanpa kode Unicode / ligature / alternate)
728  function glyphToSvg(glyph, unitsPerEm) {
729    var path = glyph.getPath(0, 0, unitsPerEm);
730    var d = path.toPathData(2);
731    var ns = 'http://www.w3.org/2000/svg';
732    var svg = document.createElementNS(ns, 'svg');
733    svg.setAttribute('viewBox', '0 -' + (unitsPerEm * 0.8) + ' ' + unitsPerEm + ' ' + unitsPerEm);
734    svg.setAttribute('class', 'g9-glyph-svg');
735    var p = document.createElementNS(ns, 'path');
736    p.setAttribute('d', d);
737    p.setAttribute('fill', 'currentColor');
738    p.setAttribute('transform', 'scale(1,-1)');
739    svg.appendChild(p);
740    return svg;
741  }
742})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.