/*!
 * karakterlap-pdf.js
 * -------------------
 * Önálló, importálható JS modul, ami kiváltja a pdf4.py-t:
 *   - KarakterlapPDF.generate(data)  -> Uint8Array (PDF bájtok)
 *       A karakterlap1.png / karakterlap2.png sablonokra pixelre pontosan
 *       ráírja a mezőket, majd PDF-fé fűzi a lapokat.
 *   - KarakterlapPDF.reveal(pdfBytesOrFile) -> Object (a visszaolvasott adat)
 *       Kiolvassa a PDF-be mentett karakteradatokat.
 *
 * Mindez teljesen a böngészőben fut, backend nélkül.
 *
 * FONTOS: a karakteradat NEM a képbe van "belerejtve" LSB-szteganográfiával.
 * Egy korábbi verzió ezt csinálta (a JSON-t belerejtette az 1. oldal képének
 * legalsó biteibe, majd import-kor a PDF-oldalt pdf.js-szel újra
 * kirajzolta/rasterizálta egy canvas-ra, hogy onnan olvassa vissza a
 * biteket). Ez a módszer megbízhatatlan: amint a kép PDF-be ágyazódik, majd
 * egy PDF-renderelő (itt: pdf.js) újrarajzolja egy canvasra, a böngésző
 * gyakorlatilag soha nem garantálja a pixel-pontos, interpoláció nélküli
 * átvitelt (szín-profil kezelés, subpixel-illesztés stb. mind módosíthatja
 * a legkisebb helyértékű biteket) -- ezért importáláskor gyakorlatilag
 * mindig hibás/olvashatatlan adatot kaptunk vissza, akár ez a modul, akár a
 * pdf4.py generálta az eredeti PDF-et.
 *
 * Az ÚJ módszer emiatt a teljes JSON-t egyszerűen a PDF-be CSATOLT FÁJLKÉNT
 * (attachment) menti a pdf-lib segítségével -- ez egy sima, FlateDecode-dal
 * tömörített PDF-stream, amit a pdf-lib bájt-pontosan ír és olvas vissza,
 * semmiféle képrenderelés/rasterizálás nincs a betöltésnél.
 *
 * (Egy köztes verzió a PDF "Subject" metaadat-mezőjébe mentette a JSON-t
 * Base64-ben. Ez írásnál működött, de beolvasásnál egy nagyobb -- kb.
 * 90-100 KB feletti, tehát egy részletesebben kitöltött karakterlapnál
 * könnyen előálló -- JSON esetén a pdf-lib 1.17.1 belső
 * PDFHexString.decodeText függvénye "Maximum call stack size exceeded"
 * hibával elszállt. Ez okozta azt a tünetet, hogy "a mentés megy, a
 * beolvasás nem". A jelen verzió emiatt attachment-et használ, ami nincs
 * ennek a korlátnak kitéve, és a reveal() a régi Subject-es PDF-eket is
 * továbbra is tudja olvasni kisebb adatméret esetén, visszafelé
 * kompatibilitásként.)
 *
 * FÜGGŐSÉGEK (CDN-ről, a HTML-be szúrva, a karakterlap-pdf.js ELŐTT):
 *   <script src="https://cdnjs.cloudflare.com/ajax/libs/pdf-lib/1.17.1/pdf-lib.min.js"></script>
 *   (a modul window.PDFLib-et keresi; a pdf.js-re a reveal()-hez már nincs
 *   szükség, de ha a HTML betölti print/előnézet célra, az nem zavar)
 *
 * HASZNÁLAT:
 *   await KarakterlapPDF.init({
 *     page1Url: 'imgs/karakterlap1.png',
 *     page2Url: 'imgs/karakterlap2.png',
 *     fontRegularUrl: 'fonts/DejaVuSans.ttf',
 *     fontBoldUrl: 'fonts/DejaVuSans-Bold.ttf'
 *   });
 *
 *   const pdfBytes = await KarakterlapPDF.generate(data); // data: ld. lentebb
 *   // letöltés:
 *   const blob = new Blob([pdfBytes], {type:'application/pdf'});
 *   ...
 *
 *   const data = await KarakterlapPDF.reveal(pdfFileOrArrayBuffer);
 *
 * ADATFORMÁTUM (data):
 *   Vagy egyetlen karakter mezőobjektuma (alap, stat, szazalek, ... kulcsokkal),
 *   vagy több karakteres forma: { "karakter1": {...}, "karakter2": {...} }.
 *   A generate() automatikusan becsomagolja, ha egyetlen karaktert kap.
 *
 * PDF4.PY-KOMPATIBILITÁS (reveal csak -- generate mindig az attachment-et írja):
 *   A pdf4.py a `stegano.lsb` Python könyvtárral rejti a JSON-t (UTF-8,
 *   json.dumps alapértelmezés szerint ensure_ascii=True, tehát a string
 *   mindig tiszta ASCII, az ékezetes karakterek \uXXXX escape-ként vannak
 *   benne) a karakterlap1.png képbe. A `stegano.lsb` a valóságban NEM egy
 *   "#####" záró jelsort használ (egy korábbi verzió tévesen ezt
 *   feltételezte, ezért nem tudta beolvasni a pdf4.py által készített
 *   fájlokat) -- a tényleges formátum (ld. a `stegano/tools.py` Hider
 *   osztályát) egy hossz-prefix: a ténylegesen elrejtett szöveg
 *   `"<hossz>:" + eredeti_szöveg` alakban van eltárolva, ahol <hossz> az
 *   eredeti (prefix nélküli) szöveg karakterhossza tízes számrendszerben.
 *   Minden karakter 8 biten (MSB elöl) van kódolva, a bitfolyam a kép
 *   pixelein pixelenként 3 bitet (R, G, B csatorna legkisebb helyértékű
 *   bitje) felhasználva, sorfolytonos (raster) sorrendben halad végig --
 *   az alfa-csatornát a `stegano.lsb` sosem használja. Ezt a reportlab
 *   (ami a pdf4.py-ban a képeket a PDF-be rajzolja) VESZTESÉGMENTESEN,
 *   újra-kódolás nélkül ágyazza be (sima FlateDecode/ASCII85Decode
 *   PDF-stream, nincs PNG Predictor, nincs JPEG-tömörítés) -- ezt
 *   konkrétan leellenőriztük. A reveal() ezért itt NEM egy pdf.js-szel
 *   renderelt canvas-ról olvas vissza (az valóban megbízhatatlan lenne,
 *   ld. a fájl elején lévő korábbi magyarázatot), hanem közvetlenül a
 *   PDF-be ágyazott kép XObject nyers, dekódolt pixel-bájtjaiból -- így
 *   nincs semmiféle újra-rasterizálás/szín-profil-kezelés, ami
 *   módosíthatná a legkisebb helyértékű biteket.
 *   Ha pdf4.py egy jövőbeli verziója más hossz-prefix formátumot vagy más
 *   bitsorrendet használna, ez a beolvasás nem fog illeszkedni -- ilyenkor
 *   a HTML JSON export/import gombjaival, vagy a pdf4.py saját
 *   `stegano.lsb.reveal()`-jével kinyert JSON importálásával lehet
 *   áthidalni a problémát.
 */


(function (global) {
  'use strict';

  // A rajzolási koordináták az eredeti 1225×1732-es sablon logikai
  // koordinátarendszerében maradnak. A kimeneti sablon 2× felbontású, ezért
  // a teljes rajzolást egységesen skálázzuk; így a pozíciók mellett a
  // betűméretek, vonalvastagságok, körök és finom pixel-eltolások is pontosan
  // ugyanabban az arányban nőnek.
  var DESIGN_W = 1225;
  var DESIGN_H = 1732;
  var PAGE_W = 2450;
  var PAGE_H = 3464;
  var SCALE_X = PAGE_W / DESIGN_W;
  var SCALE_Y = PAGE_H / DESIGN_H;
  var FONT_FAMILY = 'KLDejaVuSans';

  var assets = null; // { img1, img2 }
  var fontsReady = false;

  // ---------------------------------------------------------------
  // Asset loading
  // ---------------------------------------------------------------

  function loadImage(url) {
    return new Promise(function (resolve, reject) {
      var img = new Image();
      // A crossOrigin='anonymous'-ra csak akkor van szükség (és csak akkor
      // működik megbízhatóan), ha a kép egy távoli http(s) szerverről jön.
      // file:// alól megnyitott HTML esetén ez a beállítás sok böngészőben
      // (pl. Chrome) meghiúsítja a helyi kép betöltését, még ha a fájl
      // ténylegesen létezik is -- ezért helyi/relatív útvonalaknál kihagyjuk.
      if (/^https?:\/\//i.test(url)) {
        img.crossOrigin = 'anonymous';
      }
      img.onload = function () { resolve(img); };
      img.onerror = function () { reject(new Error('Nem sikerült betölteni a képet: ' + url)); };
      img.src = url;
    });
  }

  function loadFont(url, weight) {
    var face = new FontFace(FONT_FAMILY, 'url(' + url + ')', { weight: weight });
    return face.load().then(function (loaded) {
      document.fonts.add(loaded);
      return loaded;
    });
  }

  function imageToCanvas(img, width, height) {
    var c = document.createElement('canvas');
    c.width = width || img.naturalWidth || img.width;
    c.height = height || img.naturalHeight || img.height;
    var ctx = c.getContext('2d');
    ctx.imageSmoothingEnabled = true;
    ctx.imageSmoothingQuality = 'high';
    ctx.drawImage(img, 0, 0, c.width, c.height);
    return c;
  }

  function setCanvasSize(canvas, width, height) {
    if (canvas.width !== width) canvas.width = width;
    if (canvas.height !== height) canvas.height = height;
  }

  function yieldToBrowser() {
    return new Promise(function (resolve) {
      // A setTimeout háttérlapon és headless böngészőben is megbízhatóbban
      // fut, mint a requestAnimationFrame.
      setTimeout(resolve, 0);
    });
  }

  function sharpenGrayCanvas(canvas, amount, threshold) {
    var ctx = canvas.getContext('2d', { willReadFrequently: true });
    var image = ctx.getImageData(0, 0, canvas.width, canvas.height);
    var data = image.data;
    var source = new Uint8ClampedArray(data);
    var width = canvas.width;
    var height = canvas.height;
    amount = amount == null ? 0.28 : amount;
    threshold = threshold == null ? 3 : threshold;

    // 3×3 Gauss-elmosásból számított, gyenge unsharp mask. A lap szürkeárnyalatos,
    // ezért elég egy csatornán számolni, majd ugyanazt írni R/G/B-be. Az alfa
    // változatlan marad, a halvány rendezvény-vízjelek pedig nem tűnnek el.
    for (var y = 1; y < height - 1; y++) {
      for (var x = 1; x < width - 1; x++) {
        var i = (y * width + x) * 4;
        var nw = source[i - width * 4 - 4];
        var n  = source[i - width * 4];
        var ne = source[i - width * 4 + 4];
        var w  = source[i - 4];
        var c  = source[i];
        var e  = source[i + 4];
        var sw = source[i + width * 4 - 4];
        var so = source[i + width * 4];
        var se = source[i + width * 4 + 4];
        var blurred = (nw + ne + sw + se + 2 * (n + w + e + so) + 4 * c) / 16;
        var delta = c - blurred;
        var value = Math.abs(delta) < threshold ? c : c + amount * delta;
        value = Math.max(0, Math.min(255, Math.round(value)));
        data[i] = value;
        data[i + 1] = value;
        data[i + 2] = value;
      }
    }
    ctx.putImageData(image, 0, 0);
  }

  /**
   * Teljesen böngészőoldali, csempézett supersampling.
   *
   * Nem hoz létre egyetlen 14700×20784-es (6×) vásznat, mert az több mint
   * 1 GB memóriát kérne. Ehelyett átfedő csempékkel dolgozik: minden csempét
   * 6×-ra nagyít, 2× köztes méretre, majd 2450×3464-es végső rácsra kicsinyít.
   * Az átfedés miatt a csempehatárok láthatatlanok maradnak.
   */
  function frontendSupersampleImage(img, options) {
    options = options || {};
    var width = options.width || PAGE_W;
    var height = options.height || PAGE_H;
    var scale = Math.max(2, Math.min(10, Number(options.scale) || 6));
    var tileSize = Math.max(192, Math.min(768, Number(options.tileSize) || 384));
    var overlap = Math.max(4, Math.min(24, Number(options.overlap) || 8));
    var onProgress = typeof options.onProgress === 'function' ? options.onProgress : function () {};

    var source = imageToCanvas(img, width, height);
    var output = document.createElement('canvas');
    output.width = width;
    output.height = height;
    var outCtx = output.getContext('2d');

    var high = document.createElement('canvas');
    var mid = document.createElement('canvas');
    var small = document.createElement('canvas');

    var columns = Math.ceil(width / tileSize);
    var rows = Math.ceil(height / tileSize);
    var total = columns * rows;
    var completed = 0;
    var chain = Promise.resolve();

    for (var tileY = 0; tileY < height; tileY += tileSize) {
      for (var tileX = 0; tileX < width; tileX += tileSize) {
        (function (x, y) {
          chain = chain.then(function () {
            var coreW = Math.min(tileSize, width - x);
            var coreH = Math.min(tileSize, height - y);
            var sx = Math.max(0, x - overlap);
            var sy = Math.max(0, y - overlap);
            var ex = Math.min(width, x + coreW + overlap);
            var ey = Math.min(height, y + coreH + overlap);
            var sw = ex - sx;
            var sh = ey - sy;

            setCanvasSize(high, sw * scale, sh * scale);
            var highCtx = high.getContext('2d');
            highCtx.clearRect(0, 0, high.width, high.height);
            highCtx.imageSmoothingEnabled = true;
            highCtx.imageSmoothingQuality = 'high';
            highCtx.drawImage(source, sx, sy, sw, sh, 0, 0, high.width, high.height);

            setCanvasSize(mid, sw * 2, sh * 2);
            var midCtx = mid.getContext('2d');
            midCtx.clearRect(0, 0, mid.width, mid.height);
            midCtx.imageSmoothingEnabled = true;
            midCtx.imageSmoothingQuality = 'high';
            midCtx.drawImage(high, 0, 0, high.width, high.height, 0, 0, mid.width, mid.height);

            setCanvasSize(small, sw, sh);
            var smallCtx = small.getContext('2d');
            smallCtx.clearRect(0, 0, small.width, small.height);
            smallCtx.imageSmoothingEnabled = true;
            smallCtx.imageSmoothingQuality = 'high';
            // A végső élesítést a böngésző natív rasterizálója végzi. Ez sokkal
            // gyorsabb, mint 8,5 millió képpont JavaScriptes konvolúciója, és
            // nem teszi tönkre a halvány rendezvény-vízjeleket.
            smallCtx.filter = 'contrast(1.012)';
            smallCtx.drawImage(mid, 0, 0, mid.width, mid.height, 0, 0, small.width, small.height);
            smallCtx.filter = 'none';

            var localX = x - sx;
            var localY = y - sy;
            outCtx.drawImage(
              small,
              localX, localY, coreW, coreH,
              x, y, coreW, coreH
            );

            completed++;
            onProgress({
              completed: completed,
              total: total,
              percent: Math.round(completed / total * 100)
            });

            // Minden csempe után visszaadjuk a főszálat a böngészőnek,
            // hogy gépelés, görgetés és lapváltás közben se legyen megakadás.
            return yieldToBrowser();
          });
        })(tileX, tileY);
      }
    }

    return chain.then(function () {
      source.width = source.height = 1;
      high.width = high.height = 1;
      mid.width = mid.height = 1;
      small.width = small.height = 1;
      return output;
    });
  }

  /**
   * Betölti a sablonképeket és a fontokat. Egyszer kell meghívni, mielőtt
   * generate()-et vagy reveal()-t hívnál.
   */
  function init(opts) {
    opts = opts || {};
    var page1Url = opts.page1Url || 'imgs/karakterlap1.png';
    var page2Url = opts.page2Url || 'imgs/karakterlap2.png';
    var fontRegularUrl = opts.fontRegularUrl || 'fonts/DejaVuSans.ttf';
    var fontBoldUrl = opts.fontBoldUrl || 'fonts/DejaVuSans-Bold.ttf';

    if (opts.pdfWorkerUrl && global.pdfjsLib) {
      global.pdfjsLib.GlobalWorkerOptions.workerSrc = opts.pdfWorkerUrl;
    }

    return Promise.all([
      loadImage(page1Url),
      loadImage(page2Url),
      loadFont(fontRegularUrl, '400'),
      loadFont(fontBoldUrl, '700')
    ]).then(function (res) {
      var img1 = res[0];
      var img2 = res[1];
      fontsReady = true;

      if (opts.backgroundTemplateProcessing === false || opts.frontendHq === false) {
        assets = { img1: img1, img2: img2 };
        return true;
      }

      var hqScale = Math.max(2, Math.min(10, Number(opts.templateScale != null ? opts.templateScale : opts.hqScale) || 6));
      var hqTileSize = Number(opts.templateTileSize != null ? opts.templateTileSize : opts.hqTileSize) || 384;
      var hqOverlap = Number(opts.templateOverlap != null ? opts.templateOverlap : opts.hqOverlap) || 8;
      var progress = typeof opts.onHqProgress === 'function' ? opts.onHqProgress : function () {};
      var page1Canvas;

      progress({ page: 1, percent: 0, message: '1. sablon HQ feldolgozása' });
      return frontendSupersampleImage(img1, {
        scale: hqScale,
        tileSize: hqTileSize,
        overlap: hqOverlap,
        onProgress: function (state) {
          progress({ page: 1, percent: state.percent, message: '1. sablon HQ feldolgozása' });
        }
      }).then(function (canvas1) {
        page1Canvas = canvas1;
        progress({ page: 2, percent: 0, message: '2. sablon HQ feldolgozása' });
        return frontendSupersampleImage(img2, {
          scale: hqScale,
          tileSize: hqTileSize,
          overlap: hqOverlap,
          onProgress: function (state) {
            progress({ page: 2, percent: state.percent, message: '2. sablon HQ feldolgozása' });
          }
        });
      }).then(function (canvas2) {
        assets = { img1: page1Canvas, img2: canvas2 };
        progress({ page: 2, percent: 100, message: 'HQ sablonok elkészültek', done: true });
        return true;
      });
    });
  }

  function ensureReady() {
    if (!assets || !fontsReady) {
      throw new Error('KarakterlapPDF.init() még nem futott le (vagy nem sikerült).');
    }
  }

  // ---------------------------------------------------------------
  // Szöveg / kör rajzoló helperek (a pdf4.py wtext/wchtext/wcwtext/
  // wcatext/wcircle függvényeinek megfelelői)
  // ---------------------------------------------------------------

  function fontString(size, bold) {
    return (bold ? 'bold ' : 'normal ') + size + 'px ' + FONT_FAMILY;
  }

  function getSize(ctx, text, size, bold) {
    ctx.font = fontString(size, bold);
    var m = ctx.measureText(text);
    var w = m.width;
    var asc = (m.actualBoundingBoxAscent !== undefined) ? m.actualBoundingBoxAscent : size * 0.8;
    var desc = (m.actualBoundingBoxDescent !== undefined) ? m.actualBoundingBoxDescent : size * 0.2;
    return { w: w, h: asc + desc };
  }

  function wtext(ctx, x, y, text, size, bold, max) {
    size = size || 16;
    bold = !!bold;
    max = max || 0;
    if (text === null || text === undefined) text = '';
    text = String(text);
    if (text === '') return;

    ctx.fillStyle = '#000';
    ctx.textBaseline = 'top';

    if (max === 0) {
      ctx.font = fontString(size, bold);
      ctx.fillText(text, x, y - size - 1);
    } else {
      var size2 = size;
      while (true) {
        ctx.font = fontString(size2, bold);
        var w = ctx.measureText(text).width;
        if (w < max || size2 <= 1) break;
        size2--;
      }
      ctx.fillText(text, x, y - size2 - 1);
    }
  }

  function wchtext(ctx, x, y, text, size, bold) {
    size = size || 16;
    var h = getSize(ctx, String(text == null ? '' : text), size, bold).h;
    wtext(ctx, x, y + h / 2, text, size, bold);
  }

  function wcwtext(ctx, x, y, text, size, bold) {
    size = size || 16;
    var w = getSize(ctx, String(text == null ? '' : text), size, bold).w;
    wtext(ctx, x - w / 2, y, text, size, bold);
  }

  function wcatext(ctx, x, y, text, size, bold) {
    size = size || 16;
    var s = getSize(ctx, String(text == null ? '' : text), size, bold);
    wtext(ctx, x - s.w / 2, y + s.h / 2, text, size, bold);
  }

  function wcircle(ctx, x, y) {
    ctx.save();
    ctx.strokeStyle = '#000';
    ctx.lineWidth = 3;
    ctx.beginPath();
    ctx.arc(x, y, 11, 0, Math.PI * 2);
    ctx.stroke();
    ctx.restore();
  }

  // Vizuálisan, a tényleges betű-kontúr alapján középre igazított cellaszöveg.
  // A régi wtext/wcatext koordináták a korábbi PIL-es rajzoláshoz voltak
  // hangolva; a hosszú táblákban ezért néhány pixelnyi függőleges eltérés
  // maradt. Ezeknél a mezőknél a sablon vonalaiból mért cellaközéppontot
  // használjuk.
  function wcelltext(ctx, x, centerY, text, size, bold, maxWidth, align) {
    size = size || 16;
    bold = !!bold;
    maxWidth = maxWidth || 0;
    align = align || 'left';
    if (text === null || text === undefined) text = '';
    text = String(text);
    if (text === '') return;

    var fitted = size;
    ctx.save();
    ctx.fillStyle = '#000';
    // A TextMetrics ascent/descent értéke a beállított baseline-tól függ,
    // ezért még a measureText() ELŐTT alphabetic baseline-ra váltunk.
    ctx.textBaseline = 'alphabetic';
    ctx.textAlign = align;
    while (true) {
      ctx.font = fontString(fitted, bold);
      if (!maxWidth || ctx.measureText(text).width <= maxWidth || fitted <= 1) break;
      fitted--;
    }
    var metrics = ctx.measureText(text);
    var ascent = metrics.actualBoundingBoxAscent !== undefined ? metrics.actualBoundingBoxAscent : fitted * 0.8;
    var descent = metrics.actualBoundingBoxDescent !== undefined ? metrics.actualBoundingBoxDescent : fitted * 0.2;
    ctx.fillText(text, x, centerY + (ascent - descent) / 2);
    ctx.restore();
  }

  function wcellcenter(ctx, x, centerY, text, size, bold, maxWidth) {
    wcelltext(ctx, x, centerY, text, size, bold, maxWidth, 'center');
  }

  // Tintakontúr szerint középre igazított szöveg. A canvas alapértelmezett
  // `textAlign = center` beállítása az előretolási szélességet központosítja,
  // nem a ténylegesen látható fekete képpontokat. Ez főleg az egy- és
  // kétjegyű százalékértékeknél volt feltűnő a körökben.
  function wopticalcenter(ctx, x, centerY, text, size, bold, maxWidth) {
    size = size || 16;
    bold = !!bold;
    maxWidth = maxWidth || 0;
    if (text === null || text === undefined || String(text) === '') return;
    text = String(text);

    var fitted = size;
    ctx.save();
    ctx.fillStyle = '#000';
    ctx.textBaseline = 'alphabetic';
    ctx.textAlign = 'left';
    while (true) {
      ctx.font = fontString(fitted, bold);
      if (!maxWidth || ctx.measureText(text).width <= maxWidth || fitted <= 1) break;
      fitted--;
    }
    var m = ctx.measureText(text);
    var ascent = m.actualBoundingBoxAscent !== undefined ? m.actualBoundingBoxAscent : fitted * 0.8;
    var descent = m.actualBoundingBoxDescent !== undefined ? m.actualBoundingBoxDescent : fitted * 0.2;
    var inkLeft = m.actualBoundingBoxLeft !== undefined ? m.actualBoundingBoxLeft : 0;
    var inkRight = m.actualBoundingBoxRight !== undefined ? m.actualBoundingBoxRight : m.width;
    var drawX = x - (inkRight - inkLeft) / 2;
    ctx.fillText(text, drawX, centerY + (ascent - descent) / 2);
    ctx.restore();
  }

  // A vonalas táblák balra igazított adatainak egységes belső margója.
  // 5 logikai pixel = 10 pixel a végső, 2450 px széles lapon.
  var TABLE_LINE_PADDING = 5;


  // Pixelrácshoz kötött mezőrajzolás. A koordináták a 1225×1732-es
  // logikai sablon tényleges cellahatárai; a drawScaled() pontosan 2×-re
  // viszi őket a 2450×3464-es kimeneti képre.
  function wboxtext(ctx, left, top, right, bottom, text, size, bold, align, padding) {
    padding = padding == null ? 3 : padding;
    align = align || 'left';
    var x = align === 'center' ? (left + right) / 2 : (align === 'right' ? right - padding : left + padding);
    var maxWidth = Math.max(1, right - left - padding * 2);
    wcelltext(ctx, x, (top + bottom) / 2, text, size, bold, maxWidth, align);
  }

  // Egy sor alsó vonalához igazított szöveg. A betű tényleges alsó
  // kontúrja pontosan `gap` távolságra marad a vonaltól; a vízszintes
  // igazítás lehet balra, középre vagy jobbra. Ezt használjuk azokon a
  // táblákon, amelyek nem klasszikus, középre zárt cellák, hanem kézzel
  // kitöltendő vonalas sorok.
  function wlinecell(ctx, left, lineY, right, text, size, bold, align, padding, gap) {
    padding = padding == null ? 2 : padding;
    gap = gap == null ? 2 : gap;
    align = align || 'left';
    if (text === null || text === undefined || String(text) === '') return;
    text = String(text);
    var fitted = size || 16;
    var maxWidth = Math.max(1, right - left - padding * 2);
    var x = align === 'center' ? (left + right) / 2 : (align === 'right' ? right - padding : left + padding);
    ctx.save();
    ctx.fillStyle = '#000';
    ctx.textBaseline = 'alphabetic';
    ctx.textAlign = align;
    while (true) {
      ctx.font = fontString(fitted, !!bold);
      if (ctx.measureText(text).width <= maxWidth || fitted <= 1) break;
      fitted--;
    }
    var m = ctx.measureText(text);
    var descent = m.actualBoundingBoxDescent !== undefined ? m.actualBoundingBoxDescent : fitted * 0.2;
    ctx.fillText(text, x, lineY - gap - descent);
    ctx.restore();
  }

  function wlinefield(ctx, left, lineY, right, text, size, bold, padding, gap) {
    wlinecell(ctx, left, lineY, right, text, size, bold, 'left', padding == null ? 3 : padding, gap == null ? 4 : gap);
  }

  function wboxmark(ctx, left, top, right, bottom, inset) {
    inset = inset == null ? 2.5 : inset;
    var half = Math.max(2.5, Math.min((right - left) / 2, (bottom - top) / 2) - inset);
    wxmark(ctx, (left + right) / 2, (top + bottom) / 2, half);
  }

  // A checkboxokba nem betűként írunk „x”-et, hanem két, pontosan a
  // négyzet közepére húzott vonallal rajzoljuk. Így a betű metrikája és a
  // böngészők közti baseline-különbség nem tudja eltolni a jelölést.
  function wxmark(ctx, centerX, centerY, halfSize) {
    halfSize = halfSize || 4.5;
    ctx.save();
    ctx.strokeStyle = '#000';
    ctx.lineWidth = 1.35;
    ctx.lineCap = 'round';
    ctx.beginPath();
    ctx.moveTo(centerX - halfSize, centerY - halfSize);
    ctx.lineTo(centerX + halfSize, centerY + halfSize);
    ctx.moveTo(centerX + halfSize, centerY - halfSize);
    ctx.lineTo(centerX - halfSize, centerY + halfSize);
    ctx.stroke();
    ctx.restore();
  }

  function drawScaled(ctx, drawFn) {
    ctx.save();
    ctx.scale(SCALE_X, SCALE_Y);
    try {
      drawFn();
    } finally {
      ctx.restore();
    }
  }

  // ---------------------------------------------------------------
  // Mezőrajzoló függvények — 1:1 megfelelői a pdf4.py genpdf metódusainak
  // ---------------------------------------------------------------

  function draw_alap(ctx, text) {
    text = text || [];
    if (text.length === 0) return;

    // Név: a díszkeret belső, üres része.
    wboxtext(ctx, 218, 57, 757, 108, text[0], 25, true, 'left', 5);

    // Bal oldali aláhúzott mezők: Kor, Kaszt, Faj, Vallás,
    // Származás, Csillagjegy.
    var leftFields = [
      [129, 141, 416], [152, 179, 419], [123, 219, 421],
      [154, 257, 421], [215, 296, 420], [202, 334, 418]
    ];
    for (var i = 0; i < 6; i++) {
      wlinefield(ctx, leftFields[i][0], leftFields[i][1], leftFields[i][2], text[i + 1], 16, false, 2, 4);
    }

    // Jobb oldali aláhúzott mezők: Rend/iskola, Rang, Csapat,
    // Falka, Jelmondat, a régi sémában fenntartott 13. alapmező.
    var rightFields = [
      [582, 139, 804], [514, 177, 802], [534, 216, 800],
      [517, 254, 804], [571, 293, 796], [437, 334, 796]
    ];
    for (var r = 0; r < 6; r++) {
      wlinefield(ctx, rightFields[r][0], rightFields[r][1], rightFields[r][2], text[r + 7], 16, false, 2, 4);
    }

    // Szint: kizárólag a felirat alatti, ténylegesen üres pajzsrész
    // optikai közepét használjuk; a pajzs csúcsának súlyát nem vesszük bele,
    // ezért a szám nem csúszik le a keskenyedő alsó rész felé.
    wopticalcenter(ctx, 113.0, 447.5, text[13], 30, true, 100);

    // Jellem: balra igazítva, a fejléc alatti írható részben. A kezdőpont
    // kellően a díszes cím alatt marad, a két sor pedig szoros, de jól
    // elkülönülő sortávot használ.
    var jellemLeft = 733.0;
    var jellemFirstCenterY = 420.0;
    var jellemLineGap = 26.0;
    wcelltext(ctx, jellemLeft, jellemFirstCenterY, text[14], 21, false, 94, 'left');
    if (text[15]) {
      wcelltext(ctx, jellemLeft, jellemFirstCenterY + jellemLineGap, text[15], 21, false, 94, 'left');
    }
  }

  function draw_elony(ctx, text) {
    text = text || [];
    var lines = [1329, 1373, 1418, 1461, 1504];
    for (var i = 0; i < Math.min(text.length, 5); i++) {
      wlinefield(ctx, 102, lines[i], 423, text[i], 16, false, TABLE_LINE_PADDING, 4);
    }
  }

  function draw_hatrany(ctx, text) {
    text = text || [];
    var lines = [1329, 1373, 1418, 1461, 1504];
    for (var i = 0; i < Math.min(text.length, 5); i++) {
      wlinefield(ctx, 453, lines[i], 773, text[i], 16, false, TABLE_LINE_PADDING, 4);
    }
  }

  function draw_fegyver(ctx, index, text) {
    text = text || ['', '', '', '', '', '', '', false, false];
    var layout = {
      1: { name:[969,154,1171,194], cols:[861,969,1065.5,1171], values:[221,253], combat:[280,312], shield:[339,371.5], checks:[[872,320,887,336],[872,346,887,362]] },
      2: { name:[969,388.5,1171,429], cols:[861,969,1065.5,1171], values:[453.5,486.5], combat:[513.5,545], shield:[572,604], checks:[[872,554,887,570],[872,579,887,595]] },
      3: { name:[969,620.5,1171,661.5], cols:[861.5,969,1066,1171], values:[687.5,720], combat:[747,779], shield:[806,838], checks:[[872,788,887,804],[872,813,887,829]] },
      4: { name:[165.5,1459.5,368,1499.5], cols:[58.5,165.5,263,368], values:[1525.5,1558.5], combat:[1584.5,1617.5], shield:[1644.5,1676.5], checks:[[69,1627,84,1643],[69,1651,84,1667]] },
      5: { name:[488,1459.5,688.5,1499.5], cols:[380.5,488,584.5,688.5], values:[1525.5,1558.5], combat:[1584.5,1617.5], shield:[1644.5,1676.5], checks:[[391,1627,406,1643],[391,1652,406,1668]] }
    }[index];
    if (!layout) return;

    wboxtext(ctx, layout.name[0], layout.name[1], layout.name[2], layout.name[3], text[0], 18, false, 'left', 3);
    for (var i = 0; i < 3; i++) {
      wboxtext(ctx, layout.cols[i], layout.values[0], layout.cols[i + 1], layout.values[1], text[i + 1], 16, false, 'center', 2);
    }
    wboxtext(ctx, layout.cols[0], layout.combat[0], layout.cols[1], layout.combat[1], text[4], 16, false, 'center', 2);
    wboxtext(ctx, layout.cols[1], layout.combat[0], layout.cols[3], layout.combat[1], text[5], 16, false, 'center', 2);
    wboxtext(ctx, layout.cols[1], layout.shield[0], layout.cols[3], layout.shield[1], text[6], 16, false, 'center', 2);
    if (text[7]) wboxmark(ctx, layout.checks[0][0], layout.checks[0][1], layout.checks[0][2], layout.checks[0][3], 3.0);
    if (text[8]) wboxmark(ctx, layout.checks[1][0], layout.checks[1][1], layout.checks[1][2], layout.checks[1][3], 3.0);
  }

  function draw_varazstargyak(ctx, text) {
    text = text || [];
    // A jobb szélső dB-oszlop tényleges külső vonala 833.5-nél van;
    // a korábbi 820-as határ a teljes oszlopot balra tolta.
    var xs = [43, 415.5, 721.5, 780, 833.5];
    var ys = [1556.5, 1589, 1622, 1655, 1688];
    for (var i = 0; i < Math.min(text.length, 4); i++) {
      var row = text[i] || ['', '', '', ''];
      wlinecell(ctx, xs[0], ys[i + 1], xs[1], row[0], 16, false, 'left', TABLE_LINE_PADDING, 2);
      wlinecell(ctx, xs[1], ys[i + 1], xs[2], row[1], 16, false, 'left', TABLE_LINE_PADDING, 2);
      wlinecell(ctx, xs[2], ys[i + 1], xs[3], row[2], 16, false, 'center', 1, 2);
      wlinecell(ctx, xs[3], ys[i + 1], xs[4], row[3], 16, false, 'center', 1, 2);
    }
  }

  function draw_fegyver_nelkuli(ctx, text) {
    text = text || [];
    if (text.length === 0) return;
    var xs = [876, 949, 1022, 1095.5, 1171];
    for (var i = 0; i < 4; i++) {
      // A harmadik (VÉ) érték optikailag magasabbnak látszott a zárójeles
      // módosító miatt, ezért csak ezt a mezőt visszük 1.5 logikai pixellel lejjebb.
      var dy = i === 2 ? 1.5 : 0;
      wboxtext(ctx, xs[i], 86.5 + dy, xs[i + 1], 132.5 + dy, text[i], 20, false, 'center', 2);
    }
  }

  function draw_szazalek(ctx, text) {
    text = text || [];
    if (text.length === 0) return;

    // A körök tényleges, a 2450×3464-es sablon pixelkontúrjából visszamért
    // geometriai középpontjai. Nem egy közös Y-értéket használunk, mert a
    // nyolc alsó kör kontúrja soronként 1-2 kimeneti pixellel eltér.
    var circles = [
      [87.75, 1211.75], [190.75, 1212.75], [292.25, 1211.75], [394.50, 1211.75],
      [497.00, 1212.75], [598.75, 1212.75], [701.25, 1211.75], [803.00, 1212.25]
    ];
    for (var i = 0; i < 8; i++) {
      wopticalcenter(ctx, circles[i][0], circles[i][1], text[i], 20, false, 52);
    }

    // Titkosajtó-keresés és csapdafelderítés felső körei.
    wopticalcenter(ctx, 131.25, 1109.25, text[8], 20, false, 52);
    wopticalcenter(ctx, 759.25, 1109.25, text[9], 20, false, 52);
  }

  function draw_pszi(ctx, text) {
    text = text || [];
    if (text.length === 0) return;
    // A Pszi-pont száma 4 logikai pixellel lejjebb kerül az ovális mezőben.
    wboxtext(ctx, 1068, 958, 1151, 1031, text[0], 20, false, 'center', 2);
    var rowBounds = [1075, 1106, 1137, 1169, 1200];
    for (var i = 0; i < 4; i++) {
      wboxtext(ctx, 990, rowBounds[i], 1080, rowBounds[i + 1], text[i + 1], 17, false, 'center', 2);
      wboxtext(ctx, 1080, rowBounds[i], 1171, rowBounds[i + 1], text[i + 5], 17, false, 'center', 2);
    }
    if (text[9])  wboxmark(ctx, 1033, 920.5, 1048, 935.5, 2.2);
    if (text[10]) wboxmark(ctx, 1094, 920.5, 1109, 935.5, 2.2);
    if (text[11]) wboxmark(ctx, 857, 964.5, 872, 979.5, 2.2);
    if (text[12]) wboxmark(ctx, 971, 964.5, 986, 979.5, 2.2);
    if (text[13]) wboxmark(ctx, 857, 1004.5, 872, 1019.5, 2.2);
    if (text[14]) wboxmark(ctx, 974, 1004.5, 989, 1019.5, 2.2);
  }

  function draw_varazsero(ctx, text) {
    text = text || [];
    if (text.length === 0) return;
    wcellcenter(ctx, 949.5, 1328.0, text[0], 20, false, 92);
    wcellcenter(ctx, 1098.5, 1336.0, text[1], 18, false, 80);
    wcellcenter(ctx, 1098.5, 1427.5, text[2], 18, false, 80);
    // A négy manajelölő külön-külön, a sablon tényleges négyzetközepére kerül.
    // A kisebb X nem ér hozzá a nyomtatott kerethez.
    if (text[3]) wxmark(ctx, 907.25, 1361.75, 4.5);
    if (text[4]) wxmark(ctx, 906.25, 1384.75, 4.5);
    if (text[5]) wxmark(ctx, 906.25, 1407.75, 4.5);
    if (text[6]) wxmark(ctx, 906.25, 1429.75, 4.5);
  }

  function draw_vert(ctx, text) {
    text = text || [];
    if (text.length === 0) return;
    wlinefield(ctx, 965, 1548, 1168, text[0], 17, false, 0, 4);
    wcellcenter(ctx, 905.0, 1570.0, text[1], 23, false, 70);
    // Az FP értéke a hatszög optikai közepéhez igazítva 6 logikai pixellel feljebb.
    wcellcenter(ctx, 1140.0, 1637.0, text[2], 23, false, 72);
    // Az SFÉ/MGT értékek a felirat alatti terület optikai közepére kerülnek;
    // nem követik a teljes szürke doboz alsó irányba húzó geometriai közepét.
    wopticalcenter(ctx, 949.0, 1653.0, text[3], 23, false, 76);
    wopticalcenter(ctx, 1036.0, 1653.0, text[4], 23, false, 76);
    if (text[5]) wboxmark(ctx, 965, 1562, 980, 1577, 2.2);
    if (text[6]) wboxmark(ctx, 1020, 1562, 1035, 1577, 2.2);
  }

  // A 10 alapérték 10-18 közötti számainak közvetlen, 1225×1732-es
  // logikai koordinátái. A középpontokat a 2450×3464-es sablon
  // tényleges fekete számkontúrjainak befoglaló téglalapjából mértük,
  // ezért nincs köztes 724×1024-es átskálázás és kerekítési eltérés.
  var STAT_CIRCLE_CENTERS = [
    // ero
    { 18: [351.5, 497], 17: [358.75, 524.5], 16: [368.25, 552.5], 15: [376.75, 580.75], 14: [385.25, 608.25], 13: [393.75, 636.5], 12: [401.75, 664.5], 11: [410.25, 692], 10: [417.75, 720.5] },
    // gyorsasag
    { 18: [534.5, 497.75], 17: [525.75, 525], 16: [516.25, 552.75], 15: [507, 580.75], 14: [498, 608.5], 13: [489, 636], 12: [480.5, 664.25], 11: [470.75, 691.25], 10: [462, 719.25] },
    // ugyesseg
    { 18: [684.75, 609.5], 17: [661, 625.25], 16: [637.75, 643.5], 15: [614.25, 660.75], 14: [590.75, 677.25], 13: [567, 694.25], 12: [543.5, 711.75], 11: [519.25, 729], 10: [495.75, 745.5] },
    // allokepesseg
    { 18: [742.25, 791.25], 17: [714.25, 791.25], 16: [684.25, 791.25], 15: [655.75, 791.25], 14: [626.25, 791.25], 13: [598.25, 791.25], 12: [568.75, 791.25], 11: [540.25, 791.25], 10: [510.25, 791.25] },
    // egeszseg
    { 18: [683.25, 973.5], 17: [660, 956.75], 16: [636.75, 939], 15: [613.5, 921.25], 14: [590, 904.25], 13: [567, 887.25], 12: [543, 869.5], 11: [519.5, 851.75], 10: [496.75, 835.25] },
    // szepseg
    { 18: [530.5, 1080.75], 17: [522.5, 1053.25], 16: [512.5, 1025.5], 15: [503.5, 997.25], 14: [494.5, 970], 13: [485.75, 942], 12: [477, 914], 11: [468, 886.75], 10: [459.25, 858.5] },
    // intelligencia
    { 18: [345.25, 1081.25], 17: [353.75, 1054], 16: [363, 1026.25], 15: [372, 998.25], 14: [381.25, 970.75], 13: [390, 942.75], 12: [399, 914.5], 11: [408.25, 887.25], 10: [416.25, 859.25] },
    // akaratero
    { 18: [190.25, 967.75], 17: [214, 951.75], 16: [237.25, 932.75], 15: [260.5, 915.5], 14: [284.25, 898.25], 13: [308, 881.25], 12: [331.25, 863.25], 11: [354.75, 846], 10: [378.75, 829.25] },
    // asztral
    { 18: [133.25, 786.75], 17: [161.75, 786.75], 16: [191.25, 786.75], 15: [220.25, 786.75], 14: [249.75, 786.75], 13: [278.25, 786.75], 12: [308.25, 786.75], 11: [336.25, 786.75], 10: [366.25, 786.75] },
    // eszleles
    { 18: [194.25, 605.75], 17: [217.25, 622.5], 16: [241.25, 640], 15: [264.5, 657.25], 14: [288.5, 674.75], 13: [312.25, 691.25], 12: [335.75, 709], 11: [359.5, 726.75], 10: [383.25, 743.5] }
  ];

  // A 10 alatti és 18 feletti értékek helye ugyanazon a sugáron van,
  // mint a nyomtatott 10–18 számsor. A pozíciót a két szélső, közvetlenül
  // visszamért körközépből extrapoláljuk, így nincs régi koordinátatérből
  // származó átskálázási vagy kerekítési hiba.
  function statOutsideCenter(index, above18) {
    var centers = STAT_CIRCLE_CENTERS[index];
    var edge = above18 ? centers[18] : centers[10];
    var neighbor = above18 ? centers[17] : centers[11];
    return [2 * edge[0] - neighbor[0], 2 * edge[1] - neighbor[1]];
  }

  function draw_stat(ctx, stat) {
    stat = stat || [];
    if (stat.length === 0) return;
    for (var i = 0; i < 10; i++) {
      var v = stat[i];
      if (typeof v !== 'number') continue;
      if (v > 9 && v < 19) {
        var c = STAT_CIRCLE_CENTERS[i][v];
        if (c) wcircle(ctx, c[0], c[1]);
      }
    }
    for (var x = 0; x < 10; x++) {
      var val = stat[x];
      if (typeof val !== 'number') continue;
      if (val !== 0 && val < 10) {
        var low = statOutsideCenter(x, false);
        wopticalcenter(ctx, low[0], low[1], String(val), 17, true, 28);
      } else if (val > 18) {
        var high = statOutsideCenter(x, true);
        wopticalcenter(ctx, high[0], high[1], String(val), 17, true, 28);
      }
    }
  }

  function draw_felszereles(ctx, text) {
    text = text || [];
    var ys = [337,366,394,423,451,480,509,537,566,595,623.5,652.5,680.5,709.5,738.5,766.5];
    var xs = [[58,284,329.5],[329.5,562,590]];
    for (var c = 0; c < Math.min(text.length, 2); c++) {
      var col = text[c] || [];
      for (var r = 0; r < Math.min(col.length, 15); r++) {
        var row = col[r] || ['', ''];
        wlinecell(ctx, xs[c][0], ys[r + 1], xs[c][1], row[0], 16, false, 'left', TABLE_LINE_PADDING, 2);
        wlinecell(ctx, xs[c][1], ys[r + 1], xs[c][2], row[1], 16, false, 'center', 1, 2);
      }
    }
  }

  function draw_kepzettseg(ctx, text) {
    text = text || [];
    var ys = [337,366,394,423,451,480,509,537,566,595,623.5,652.5,680.5,709.5,738.5,766.5,795,824,852,881,909,939];
    var cols = [[627,838,906],[906,1116,1186]];

    // A képzettség-jelölőnégyzetek tényleges, sablonpixelből visszamért
    // középpontjai. A sor cellaközepe nem mindenhol egyezik a négyzet
    // közepével, ezért az X-ek külön Y-koordinátalistát használnak.
    var checkY = [
      350.75,378.75,407.75,436.75,465.75,494.75,522.75,
      551.75,580.75,608.75,637.75,666.75,694.75,723.75,
      751.75,780.75,809.75,837.75,866.75,895.75,923.75
    ];
    var checkX = [[861.25,887.25],[1139.25,1164.25]];

    for (var c = 0; c < Math.min(text.length, 2); c++) {
      var col = text[c] || [];
      for (var r = 0; r < Math.min(col.length, 21); r++) {
        var row = col[r] || ['', false, false];
        wlinecell(ctx, cols[c][0], ys[r + 1], cols[c][1], row[0], 16, false, 'left', TABLE_LINE_PADDING, 2);
        if (row[1]) wxmark(ctx, checkX[c][0], checkY[r], 4.5);
        if (row[2]) wxmark(ctx, checkX[c][1], checkY[r], 4.5);
      }
    }
  }

  function draw_penz(ctx, text) {
    text = text || [];
    var centers = [138.5, 182.5, 226.5];
    var lines = [161, 204.5, 248];
    for (var r = 0; r < Math.min(text.length, 3); r++) {
      var row = text[r] || ['', '', '', ''];
      wcellcenter(ctx, 498, centers[r], row[0], 20, true, 60);
      wcellcenter(ctx, 571, centers[r], row[1], 20, true, 60);
      wcellcenter(ctx, 643, centers[r], row[2], 20, true, 60);
      wlinefield(ctx, 671, lines[r], 789, row[3], 18, false, 3, 4);
    }
  }

  function draw_nyelv(ctx, text) {
    text = text || [];
    var ys = [88,116.5,145.5,175,204.5,233.5];
    var cols = [[847.5,934.5,1006],[1023,1110,1172]];
    for (var c = 0; c < Math.min(text.length, 2); c++) {
      var col = text[c] || [];
      for (var r = 0; r < Math.min(col.length, 5); r++) {
        var row = col[r] || ['', ''];
        // A nyelv neve közvetlenül a vonal kezdetén indul. Ennél a táblánál
        // nincs meg az általános, 5 pixeles táblabelső behúzás.
        wlinecell(ctx, cols[c][0], ys[r + 1], cols[c][1], row[0], 16, false, 'left', 0, 2);
        wlinecell(ctx, cols[c][1], ys[r + 1], cols[c][2], row[1], 16, false, 'center', 1, 2);
      }
    }
  }

  function draw_talalkozok(ctx, text) {
    text = text || [];
    var xs = [43,271.5,500,728.5,957,1185.5];
    var ys = [1183.5,1215,1247.5,1279.5,1311,1343,1375];
    for (var r = 0; r < Math.min(text.length, 6); r++) {
      var row = text[r] || ['', '', '', '', '', ''];
      var values = [row[0], row[1], row[2], row[3], row.length > 5 ? row[5] : row[4]];
      for (var c = 0; c < 5; c++) {
        wlinecell(ctx, xs[c], ys[r + 1], xs[c + 1], values[c], 16, false, 'left', TABLE_LINE_PADDING, 2);
      }
    }
  }

  function draw_tarsak(ctx, text) {
    text = text || [];
    var xs = [269,377,489,538,587,635,684,733,826,920,1013,1185];
    var ys = [994,1027.5,1063.5];
    for (var r = 0; r < Math.min(text.length, 2); r++) {
      var row = text[r] || [];
      for (var c = 0; c < 11; c++) {
        var align = (c === 0 || c === 1 || c === 10) ? 'left' : 'center';
        wlinecell(ctx, xs[c], ys[r + 1], xs[c + 1], row[c], 16, false, align, align === 'left' ? TABLE_LINE_PADDING : 1, 2);
      }
    }
  }

  function draw_mergek(ctx, text) {
    text = text || [];
    var xs = [43,284,329,474,590];
    var ys = [825,853,882,910,940];
    for (var r = 0; r < Math.min(text.length, 4); r++) {
      var row = text[r] || ['', '', '', ''];
      wlinecell(ctx, r < 2 ? 84 : 47, ys[r + 1], xs[1], row[0], 16, false, 'left', TABLE_LINE_PADDING, 2);
      wlinecell(ctx, xs[1], ys[r + 1], xs[2], row[1], 16, false, 'center', 1, 2);
      wlinecell(ctx, xs[2], ys[r + 1], xs[3], row[2], 16, false, 'left', TABLE_LINE_PADDING, 2);
      wlinecell(ctx, xs[3], ys[r + 1], xs[4], row[3], 16, false, 'left', TABLE_LINE_PADDING, 2);
    }
  }

  function draw_zsakmany(ctx, text) {
    text = text || [];
    // A hét kitölthető sor alsó vonalai. Az első adat már az első,
    // 1475-ös vonal fölé kerül; a 1678 körüli ív a pergamen alsó díszítése,
    // nem egy további kitölthető sor.
    var ys = [1475,1505,1533.5,1563,1592,1620.5,1651];
    for (var r = 0; r < Math.min(text.length, 7); r++) {
      var row = text[r] || ['', ''];
      wlinecell(ctx, 745, ys[r], 1095.5, row[0], 16, false, 'left', 0, 2);
      // A dB mező a tényleges, 2194-2307 px közötti vonalszakasz
      // geometriai közepére kerül, nem a dekoratív külső kontúrhoz jobbra.
      wlinecell(ctx, 1097, ys[r], 1154, row[1], 14, false, 'center', 1, 2);
    }
  }

  function draw_tp(ctx, text) {
    // A tapasztalati pontok ovális mezőjének tényleges kontúrközepe:
    // 1054..1340 × 2747..2890 px a 2450×3464-es sablonon.
    // Optikai középre igazítást használunk, így a látható számjegyek
    // vízszintesen és függőlegesen is középen maradnak.
    wopticalcenter(ctx, 598.5, 1409.25, text, 20, false, 138);
  }

  function draw_levonasok(ctx, text) {
    text = text || [];
    if (text.length === 0) return;
    var left = text[0] || ['', '', '', ''];
    var right = text[1] || ['', '', '', ''];
    var ys = [73,116,160,205,248];
    for (var r = 0; r < 4; r++) {
      wboxtext(ctx, 116, ys[r], 350, ys[r + 1], left[r], 16, false, 'left', 4);
      wboxtext(ctx, 365, ys[r], 424, ys[r + 1], right[r], 16, false, 'center', 2);
    }
  }

  // ---------------------------------------------------------------
  // Adat <-> Base64 (UTF-8 biztos) - a PDF "Subject" metaadat-mezőjébe megy
  // ---------------------------------------------------------------

  function utf8ToBase64(str) {
    return btoa(unescape(encodeURIComponent(str)));
  }

  function base64ToUtf8(b64) {
    return decodeURIComponent(escape(atob(b64)));
  }

  // ---------------------------------------------------------------
  // Oldal-összeállítás
  // ---------------------------------------------------------------

  function drawPage1Fields(ctx, rec) {
    draw_alap(ctx, rec.alap);
    draw_stat(ctx, rec.stat);
    draw_szazalek(ctx, rec.szazalek);
    draw_elony(ctx, rec.elony);
    draw_hatrany(ctx, rec.hatrany);
    draw_varazstargyak(ctx, rec.varazstargyak);
    draw_fegyver_nelkuli(ctx, rec.fegyver_nelkuli);

    var fegyverek = rec.fegyver || [];
    var n1 = Math.min(fegyverek.length, 3);
    for (var z = 0; z < n1; z++) draw_fegyver(ctx, z + 1, fegyverek[z]);

    draw_pszi(ctx, rec.pszi);
    draw_varazsero(ctx, rec.varazsero);
    draw_vert(ctx, rec.vert);
  }

  function drawPage2Fields(ctx, rec) {
    draw_levonasok(ctx, rec.levonasok);
    draw_penz(ctx, rec.penz);
    draw_nyelv(ctx, rec.nyelv);
    draw_felszereles(ctx, rec.felszereles);
    draw_kepzettseg(ctx, rec.kepzettseg);
    draw_mergek(ctx, rec.mergek);
    draw_tarsak(ctx, rec.tarsak);
    draw_talalkozok(ctx, rec.talalkozok);
    draw_tp(ctx, rec.tp !== undefined && rec.tp !== null ? String(rec.tp) : '');

    var fegyverek = rec.fegyver || [];
    if (fegyverek.length > 3) {
      if (fegyverek.length >= 4) draw_fegyver(ctx, 4, fegyverek[3]);
      if (fegyverek.length >= 5) draw_fegyver(ctx, 5, fegyverek[4]);
    }
    draw_zsakmany(ctx, rec.zsakmany);
  }

  function cloneCanvasFromImage(img) {
    var c = document.createElement('canvas');
    c.width = PAGE_W;
    c.height = PAGE_H;
    var ctx = c.getContext('2d');
    ctx.drawImage(img, 0, 0, PAGE_W, PAGE_H);
    return c;
  }

  function canvasToPngBytes(canvas) {
    return new Promise(function (resolve, reject) {
      canvas.toBlob(function (blob) {
        if (!blob) { reject(new Error('canvas.toBlob sikertelen')); return; }
        blob.arrayBuffer().then(function (buf) {
          resolve(new Uint8Array(buf));
        }, reject);
      }, 'image/png');
    });
  }


  function getTemplatePng(pageNumber) {
    ensureReady();
    var template = Number(pageNumber) === 2 ? assets.img2 : assets.img1;
    var canvas = cloneCanvasFromImage(template);
    return new Promise(function (resolve, reject) {
      canvas.toBlob(function (blob) {
        canvas.width = canvas.height = 1;
        if (!blob) {
          reject(new Error('A PNG előállítása sikertelen.'));
          return;
        }
        resolve(blob);
      }, 'image/png');
    });
  }

  function isCharacterRecord(obj) {
    // egyszerű heurisztika: ha van benne 'alap' vagy 'stat' kulcs, egy karakter mezőobjektuma
    return obj && typeof obj === 'object' && ('alap' in obj || 'stat' in obj);
  }

  var ATTACHMENT_NAME = 'karakterlap-adat.json';

  // ---------------------------------------------------------------
  // Karakteradat tárolása a PDF-ben: CSATOLT FÁJLKÉNT (attachment), NEM a
  // "Subject" metaadat-mezőben.
  //
  // KORÁBBI HIBA ("mentés megy, beolvasás nem"): a régi verzió a teljes
  // JSON-t Base64-be csomagolva a PDF "Subject" mezőjébe írta. Az ÍRÁS
  // (pdfDoc.setSubject + save()) ezzel sosem volt gond, mert az írási
  // útvonal egyszerű string-konkatenációval dolgozik. Az OLVASÁS
  // (pdfDoc.getSubject()) viszont a pdf-lib 1.17.1-es PDFHexString.decodeText
  // függvényén megy át, ami belül String.fromCharCode(...tömb) "spread"
  // szintaxist használ -- ez egy nagyobb (kb. 90-100 KB feletti, tehát egy
  // valamivel részletesebben kitöltött karakterlapnál, pl. hosszabb "Meta"
  // szöveggel, könnyen elért) karakterlánc esetén "Maximum call stack size
  // exceeded" hibával elszáll. Ezért látszott úgy, hogy "az export
  // működik, az import nem": az adat valójában rendben elmentődött a
  // fájlba, csak a visszaolvasó pdf-lib-függvény omlott össze nagyobb
  // méretnél.
  //
  // Az attachment (csatolt fájl) ezt elkerüli, mert azt egy normál,
  // FlateDecode-dal tömörített PDF-stream-ként tárolja/olvassa a pdf-lib,
  // ott nincs ilyen string-hossz-korlátos "spread" kód. Ehhez a pdf-lib
  // saját (a böngészős buildben is elérhető) decodePDFRawStream()
  // segédfüggvényét használjuk.
  // ---------------------------------------------------------------

  function findEmbeddedFileStream(pdfDoc, PDFName) {
    var catalog = pdfDoc.catalog;
    var namesDict = catalog.lookup(PDFName.of('Names'));
    if (!namesDict) return null;
    var embeddedFilesDict = namesDict.lookup(PDFName.of('EmbeddedFiles'));
    if (!embeddedFilesDict) return null;

    // A Names-fa lehet lapos ("Names" tömb) vagy elágazó ("Kids" tömb).
    // Nekünk mindig csak egyetlen csatolt fájlunk van, de a biztonság
    // kedvéért mindkét esetet kezeljük.
    var namesArrays = [];
    function collect(dict) {
      if (!dict) return;
      var namesArr = dict.lookup(PDFName.of('Names'));
      if (namesArr) namesArrays.push(namesArr);
      var kids = dict.lookup(PDFName.of('Kids'));
      if (kids) {
        for (var i = 0; i < kids.size(); i++) {
          collect(pdfDoc.context.lookup(kids.get(i)));
        }
      }
    }
    collect(embeddedFilesDict);

    for (var a = 0; a < namesArrays.length; a++) {
      var arr = namesArrays[a];
      for (var i = 0; i + 1 < arr.size(); i += 2) {
        var fileSpec = pdfDoc.context.lookup(arr.get(i + 1));
        if (!fileSpec) continue;
        var efDict = fileSpec.lookup(PDFName.of('EF'));
        if (!efDict) continue;
        var streamRef = efDict.get(PDFName.of('F'));
        if (!streamRef) continue;
        var stream = pdfDoc.context.lookup(streamRef);
        if (stream) return stream;
      }
    }
    return null;
  }

  function utf8EncodeBytes(str) {
    if (typeof TextEncoder !== 'undefined') return new TextEncoder().encode(str);
    // nagyon régi böngészőkhöz (nincs TextEncoder): kézi UTF-8 kódolás
    var b64 = utf8ToBase64(str);
    var bin = atob(b64);
    var out = new Uint8Array(bin.length);
    for (var i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
    return out;
  }

  function utf8DecodeBytes(bytes) {
    if (typeof TextDecoder !== 'undefined') return new TextDecoder('utf-8').decode(bytes);
    var bin = '';
    for (var i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
    return base64ToUtf8(btoa(bin));
  }

  /**
   * Legenerálja a PDF-et a megadott adatból.
   * @param {Object} data - egy karakter mezőobjektuma, vagy { "nev1": {...}, "nev2": {...} }
   * @returns {Promise<Uint8Array>}
   */
  function generate(data) {
    ensureReady();
    if (!global.PDFLib) {
      return Promise.reject(new Error('A pdf-lib nincs betöltve (window.PDFLib hiányzik).'));
    }
    var wrapped = isCharacterRecord(data) ? { '': data } : data;
    var jsonStr = JSON.stringify(wrapped);

    var pageCanvases = [];
    Object.keys(wrapped).forEach(function (key) {
      var rec = wrapped[key] || {};

      var c1 = cloneCanvasFromImage(assets.img1);
      var ctx1 = c1.getContext('2d');
      drawScaled(ctx1, function () { drawPage1Fields(ctx1, rec); });

      var c2 = cloneCanvasFromImage(assets.img2);
      var ctx2 = c2.getContext('2d');
      drawScaled(ctx2, function () { drawPage2Fields(ctx2, rec); });

      pageCanvases.push(c1, c2);
    });

    return Promise.all(pageCanvases.map(canvasToPngBytes)).then(function (pngList) {
      return global.PDFLib.PDFDocument.create().then(function (pdfDoc) {
        var chain = Promise.resolve();
        pngList.forEach(function (pngBytes) {
          chain = chain.then(function () {
            return pdfDoc.embedPng(pngBytes).then(function (pngImage) {
              var page = pdfDoc.addPage([PAGE_W, PAGE_H]);
              page.drawImage(pngImage, { x: 0, y: 0, width: PAGE_W, height: PAGE_H });
            });
          });
        });
        return chain.then(function () {
          // A teljes karakteradatot csatolt fájlként (attachment) mentjük a
          // PDF-be, ld. a findEmbeddedFileStream() feletti megjegyzést arról,
          // miért NEM a "Subject" metaadat-mezőt használjuk erre.
          var jsonBytes = utf8EncodeBytes(jsonStr);
          return pdfDoc.attach(jsonBytes, ATTACHMENT_NAME, {
            mimeType: 'application/json',
            description: 'Karakterlap adat (JSON)'
          });
        }).then(function () {
          return pdfDoc.save();
        });
      });
    });
  }

  // ---------------------------------------------------------------
  // pdf4.py-kompatibilitás: LSB-szteganográfiával az 1. oldal képébe
  // rejtett JSON kiolvasása közvetlenül a PDF-be ágyazott kép XObject
  // nyers pixel-bájtjaiból (ld. a fájl tetején lévő "PDF4.PY-KOMPATIBILITÁS"
  // megjegyzést arról, miért megbízható ez -- szemben a canvas-rerenderelős
  // korábbi próbálkozással).
  // ---------------------------------------------------------------

  // A stegano.lsb valódi drótformátuma (ld. a fájl tetején lévő
  // "PDF4.PY-KOMPATIBILITÁS" megjegyzést): nincs "#####" záró jelsor --
  // ehelyett egy hossz-prefix van a rejtett szöveg elején:
  //   "<eredeti_szöveg_hossza>:" + eredeti_szöveg
  // Minden karakter 8 biten (MSB elöl) van kódolva, pixelenként 3 bit
  // (R,G,B LSB-je) raster sorrendben. Beolvasáskor először a ":" jelig
  // tartó számjegyeket gyűjtjük össze (ez adja a hosszt), utána pontosan
  // ennyi további karaktert olvasunk -- az lesz a tényleges üzenet (itt:
  // a karakteradat JSON-je).
  function decodeLsbAsciiFromBytes(bytes, usableByteCount) {
    var buf = '';
    var chars = [];
    var limit = null;
    var limitStr = '';
    var prefixLen = 0;
    for (var i = 0; i < usableByteCount; i++) {
      buf += (bytes[i] & 1);
      if (buf.length === 8) {
        var ch = String.fromCharCode(parseInt(buf, 2));
        buf = '';
        chars.push(ch);
        if (limit === null) {
          if (ch === ':') {
            if (!/^[0-9]+$/.test(limitStr)) return null; // nem érvényes hossz-prefix
            limit = parseInt(limitStr, 10);
            prefixLen = limitStr.length + 1;
          } else if (/^[0-9]$/.test(ch)) {
            limitStr += ch;
            if (limitStr.length > 10) return null; // irreálisan hosszú prefix -- nincs itt rejtett adat
          } else {
            return null; // nem számjegy és nem ":" a prefixben -- nincs itt rejtett adat
          }
        } else if (chars.length - prefixLen === limit) {
          return chars.slice(prefixLen).join('');
        }
      }
    }
    return null; // nem sikerült a teljes (hossz-prefixben jelzett méretű) üzenetet beolvasni
  }

  function findLargestImageXObject(pdfDoc, pageIndex, PDFName) {
    var page;
    try {
      page = pdfDoc.getPage(pageIndex);
    } catch (e) {
      return null;
    }
    if (!page) return null;
    var resources = page.node.Resources();
    if (!resources) return null;
    var xobjectDict = resources.lookup(PDFName.of('XObject'));
    if (!xobjectDict) return null;

    var best = null, bestArea = 0, bestW = 0, bestH = 0;
    var entries = xobjectDict.entries();
    for (var i = 0; i < entries.length; i++) {
      var ref = entries[i][1];
      var xobj = pdfDoc.context.lookup(ref);
      if (!xobj || !xobj.dict) continue;
      var subtype = xobj.dict.lookup(PDFName.of('Subtype'));
      if (!subtype || subtype.encodedName !== '/Image') continue;
      var wNum = xobj.dict.lookup(PDFName.of('Width'));
      var hNum = xobj.dict.lookup(PDFName.of('Height'));
      var w = wNum ? wNum.asNumber() : 0;
      var h = hNum ? hNum.asNumber() : 0;
      var area = w * h;
      if (area > bestArea) { bestArea = area; best = xobj; bestW = w; bestH = h; }
    }
    if (!best) return null;
    return { stream: best, width: bestW, height: bestH };
  }

  function revealFromPdf4LsbImage(pdfDoc) {
    var PDFLib = global.PDFLib;
    var PDFName = PDFLib.PDFName;

    // pdf4.py csak az 1. oldal (index 0) képébe rejt adatot.
    var found = findLargestImageXObject(pdfDoc, 0, PDFName);
    if (!found || !found.width || !found.height) return null;

    var bpc = found.stream.dict.lookup(PDFName.of('BitsPerComponent'));
    if (bpc && bpc.asNumber && bpc.asNumber() !== 8) return null;

    var cs = found.stream.dict.lookup(PDFName.of('ColorSpace'));
    var isRgb = cs && cs.encodedName === '/DeviceRGB';
    if (!isRgb) return null; // pdf4.py mindig RGB-re konvertál (image.convert("RGB"))

    var decoded;
    try {
      decoded = PDFLib.decodePDFRawStream(found.stream).decode();
    } catch (e) {
      return null;
    }

    var need = found.width * found.height * 3;
    if (!decoded || decoded.length < need) return null;

    var msg = decodeLsbAsciiFromBytes(decoded, need);
    if (msg == null) return null;

    try {
      return JSON.parse(msg);
    } catch (e) {
      return null;
    }
  }

  /**
   * Visszaolvassa a PDF-be mentett karakteradatot. Sorban kipróbálja:
   *   1) az új formátumot (csatolt fájl / attachment -- a jelen modul
   *      generate()-je ezt írja),
   *   2) a régi formátumot (Base64 JSON a "Subject" metaadat-mezőben --
   *      egy korábbi karakterlap-pdf.js verzió exportjaihoz),
   *   3) a pdf4.py formátumát (LSB-szteganográfia az 1. oldal képében).
   * @param {ArrayBuffer|Uint8Array|Blob|File} pdfInput
   * @returns {Promise<Object>}
   */
  function reveal(pdfInput) {
    if (!global.PDFLib) {
      return Promise.reject(new Error('A pdf-lib nincs betöltve (window.PDFLib hiányzik).'));
    }

    function toUint8Array(input) {
      if (input instanceof Uint8Array) return Promise.resolve(input);
      if (input instanceof ArrayBuffer) return Promise.resolve(new Uint8Array(input));
      if (input && typeof input.arrayBuffer === 'function') {
        return input.arrayBuffer().then(function (buf) { return new Uint8Array(buf); }); // Blob/File
      }
      return Promise.reject(new Error('Ismeretlen bemeneti típus a reveal()-hez.'));
    }

    return toUint8Array(pdfInput).then(function (bytes) {
      return global.PDFLib.PDFDocument.load(bytes, { updateMetadata: false });
    }).then(function (pdfDoc) {
      var PDFLib = global.PDFLib;

      // 1) Új formátum: csatolt fájl (attachment) -- a jelen modul saját exportja.
      var stream = findEmbeddedFileStream(pdfDoc, PDFLib.PDFName);
      if (stream) {
        var decodedBytes = PDFLib.decodePDFRawStream(stream).decode();
        var jsonStr = utf8DecodeBytes(decodedBytes);
        return JSON.parse(jsonStr);
      }

      // 2) Régi formátum (visszafelé kompatibilitás korábbi, kisebb
      // karakterlapokhoz): a JSON a "Subject" metaadat-mezőbe volt mentve.
      // Nagyobb (kb. 90-100 KB feletti) JSON-nál a pdf-lib 1.17.1
      // getSubject()-je "Maximum call stack size exceeded" hibával elszáll
      // -- ezt itt elkapjuk és egyszerűen továbblépünk a 3) próbálkozásra,
      // ahelyett hogy azonnal feladnánk.
      try {
        var subject = pdfDoc.getSubject();
        if (subject) {
          try {
            return JSON.parse(base64ToUtf8(subject));
          } catch (e) {
            // a Subject mező nem érvényes JSON volt -- lépjünk tovább a 3) próbálkozásra
          }
        }
      } catch (e) {
        // getSubject() elszállt (pl. túl nagy string) -- lépjünk tovább
      }

      // 3) pdf4.py formátuma: LSB-szteganográfiával az 1. oldal képébe
      // rejtett JSON (ld. a fenti "PDF4.PY-KOMPATIBILITÁS" megjegyzést).
      var lsbData = revealFromPdf4LsbImage(pdfDoc);
      if (lsbData) return lsbData;

      throw new Error('Ez a PDF nem ezzel az eszközzel és nem a pdf4.py szkripttel készült, vagy nincs benne felismerhető, mentett karakteradat.');
    });
  }

  global.KarakterlapPDF = {
    init: init,
    generate: generate,
    reveal: reveal,
    getTemplatePng: getTemplatePng,
    // haladó/teszt célra elérhetővé tett belső darabok:
    _internal: {
      utf8ToBase64: utf8ToBase64,
      base64ToUtf8: base64ToUtf8,
      utf8EncodeBytes: utf8EncodeBytes,
      utf8DecodeBytes: utf8DecodeBytes,
      findEmbeddedFileStream: findEmbeddedFileStream,
      ATTACHMENT_NAME: ATTACHMENT_NAME,
      PAGE_W: PAGE_W,
      PAGE_H: PAGE_H,
      frontendSupersampleImage: frontendSupersampleImage
    }
  };

})(typeof window !== 'undefined' ? window : globalThis);
