{"id":10733,"date":"2026-03-28T10:57:53","date_gmt":"2026-03-28T09:57:53","guid":{"rendered":"https:\/\/app.reklamaindygo.pl\/?page_id=10733"},"modified":"2026-03-28T17:38:32","modified_gmt":"2026-03-28T16:38:32","slug":"weryfikator-projektow","status":"publish","type":"page","link":"https:\/\/app.reklamaindygo.pl\/en\/weryfikator-projektow\/","title":{"rendered":"Design verifier"},"content":{"rendered":"\t\t<div data-elementor-type=\"wp-page\" data-elementor-id=\"10733\" class=\"elementor elementor-10733\" data-elementor-post-type=\"page\">\n\t\t\t\t<div class=\"elementor-element elementor-element-7a311f0 e-flex e-con-boxed nocturne-dark-mode e-con e-parent\" data-id=\"7a311f0\" data-element_type=\"container\" data-e-type=\"container\">\n\t\t\t\t\t<div class=\"e-con-inner\">\n\t\t<div class=\"elementor-element elementor-element-bf94b0b animated-fast e-con-full e-flex nocturne-dark-mode elementor-invisible e-con e-child\" data-id=\"bf94b0b\" data-element_type=\"container\" data-e-type=\"container\" data-settings=\"{&quot;animation&quot;:&quot;fadeInUp&quot;}\">\n\t\t<div class=\"elementor-element elementor-element-6e5cb20 e-con-full animated-fast e-flex e-con e-child\" data-id=\"6e5cb20\" data-element_type=\"container\" data-e-type=\"container\">\n\t\t<a class=\"elementor-element elementor-element-775a319 e-con-full e-flex e-con e-child\" data-id=\"775a319\" data-element_type=\"container\" data-e-type=\"container\" href=\"https:\/\/app.reklamaindygo.pl\/\">\n\t\t\t\t<div class=\"elementor-element elementor-element-4bfa15e elementor-view-default elementor-widget elementor-widget-icon\" data-id=\"4bfa15e\" data-element_type=\"widget\" data-e-type=\"widget\" data-widget_type=\"icon.default\">\n\t\t\t\t<div class=\"elementor-widget-container\">\n\t\t\t\t\t\t\t<div class=\"elementor-icon-wrapper\">\n\t\t\t<div class=\"elementor-icon\">\n\t\t\t<svg aria-hidden=\"true\" class=\"e-font-icon-svg e-fas-angle-left\" viewBox=\"0 0 256 512\" xmlns=\"http:\/\/www.w3.org\/2000\/svg\"><path d=\"M31.7 239l136-136c9.4-9.4 24.6-9.4 33.9 0l22.6 22.6c9.4 9.4 9.4 24.6 0 33.9L127.9 256l96.4 96.4c9.4 9.4 9.4 24.6 0 33.9L201.7 409c-9.4 9.4-24.6 9.4-33.9 0l-136-136c-9.5-9.4-9.5-24.6-.1-34z\"><\/path><\/svg>\t\t\t<\/div>\n\t\t<\/div>\n\t\t\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t\t\t<\/a>\n\t\t<div class=\"elementor-element elementor-element-01c1842 e-con-full e-flex e-con e-child\" data-id=\"01c1842\" data-element_type=\"container\" data-e-type=\"container\">\n\t\t\t\t<div class=\"elementor-element elementor-element-352a04b elementor-align-start animated-fast elementor-icon-list--layout-traditional elementor-list-item-link-full_width elementor-widget elementor-widget-icon-list\" data-id=\"352a04b\" data-element_type=\"widget\" data-e-type=\"widget\" data-widget_type=\"icon-list.default\">\n\t\t\t\t<div class=\"elementor-widget-container\">\n\t\t\t\t\t\t\t<ul class=\"elementor-icon-list-items\">\n\t\t\t\t\t\t\t<li class=\"elementor-icon-list-item\">\n\t\t\t\t\t\t\t\t\t\t\t<span class=\"elementor-icon-list-icon\">\n\t\t\t\t\t\t\t<svg xmlns=\"http:\/\/www.w3.org\/2000\/svg\" viewBox=\"0 0 256 256\"><rect width=\"256\" height=\"256\" fill=\"none\"><\/rect><path d=\"M144,148a20,20,0,1,1-20-20A20,20,0,0,1,144,148Zm72-60V216a16,16,0,0,1-16,16H56a16,16,0,0,1-16-16V40A16,16,0,0,1,56,24h96a8,8,0,0,1,5.66,2.34l56,56A8,8,0,0,1,216,88Zm-50.34,90.34-11.2-11.19a36.05,36.05,0,1,0-11.31,11.31l11.19,11.2a8,8,0,0,0,11.32-11.32ZM196,88,152,44V88Z\"><\/path><\/svg>\t\t\t\t\t\t<\/span>\n\t\t\t\t\t\t\t\t\t\t<span class=\"elementor-icon-list-text\">Weryfikator projekt\u00f3w<\/span>\n\t\t\t\t\t\t\t\t\t<\/li>\n\t\t\t\t\t\t<\/ul>\n\t\t\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t\t\t<div class=\"elementor-element elementor-element-ab9b20d elementor-widget elementor-widget-button\" data-id=\"ab9b20d\" data-element_type=\"widget\" data-e-type=\"widget\" data-widget_type=\"button.default\">\n\t\t\t\t<div class=\"elementor-widget-container\">\n\t\t\t\t\t\t\t\t\t<div class=\"elementor-button-wrapper\">\n\t\t\t\t\t<a class=\"elementor-button elementor-size-sm\" role=\"button\">\n\t\t\t\t\t\t<span class=\"elementor-button-content-wrapper\">\n\t\t\t\t\t\t\t\t\t<span class=\"elementor-button-text\">BETA<\/span>\n\t\t\t\t\t<\/span>\n\t\t\t\t\t<\/a>\n\t\t\t\t<\/div>\n\t\t\t\t\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t<a class=\"elementor-element elementor-element-f1c1858 e-con-full e-flex e-con e-child\" data-id=\"f1c1858\" data-element_type=\"container\" data-e-type=\"container\" href=\"https:\/\/app.reklamaindygo.pl\/\">\n\t\t\t\t<div class=\"elementor-element elementor-element-ea3f945 elementor-view-default elementor-widget elementor-widget-icon\" data-id=\"ea3f945\" data-element_type=\"widget\" data-e-type=\"widget\" data-widget_type=\"icon.default\">\n\t\t\t\t<div class=\"elementor-widget-container\">\n\t\t\t\t\t\t\t<div class=\"elementor-icon-wrapper\">\n\t\t\t<div class=\"elementor-icon\">\n\t\t\t<svg xmlns=\"http:\/\/www.w3.org\/2000\/svg\" width=\"640\" height=\"120\"><\/svg>\t\t\t<\/div>\n\t\t<\/div>\n\t\t\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t\t\t<\/a>\n\t\t\t\t<\/div>\n\t\t<div class=\"elementor-element elementor-element-ccdca43 e-con-full animated-fast e-flex e-con e-child\" data-id=\"ccdca43\" data-element_type=\"container\" data-e-type=\"container\">\n\t\t\t\t<div class=\"elementor-element elementor-element-952b6cc elementor-widget__width-inherit animated-fast elementor-widget elementor-widget-text-editor\" data-id=\"952b6cc\" data-element_type=\"widget\" data-e-type=\"widget\" data-settings=\"{&quot;_animation_delay&quot;:10}\" data-widget_type=\"text-editor.default\">\n\t\t\t\t<div class=\"elementor-widget-container\">\n\t\t\t\t\t\t\t\t\t<p>W tej zak\u0142adce sprawdzisz, czy Tw\u00f3j projekt jest odpowiednio przygotowany do druku.<\/p>\t\t\t\t\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t\t\t<div class=\"elementor-element elementor-element-285478d elementor-widget elementor-widget-html\" data-id=\"285478d\" data-element_type=\"widget\" data-e-type=\"widget\" data-widget_type=\"html.default\">\n\t\t\t\t<div class=\"elementor-widget-container\">\n\t\t\t\t\t<a href=\"indygo:\/\/weryfikator-projektow\" \n   onclick=\"window.location.href='indygo:\/\/weryfikator-projektow'; return false;\"\n   style=\"\n     display: inline-flex;\n     align-items: center;\n     gap: 8px;\n     background-color: #FEC100;\n     color: #000000;\n     text-decoration: none;\n     padding: 12px 20px;\n     border-radius: 50px;\n     font-size: 14px;\n     font-weight: 600;\n     font-family: 'Poppins', sans-serif;\n     cursor: pointer;\n     line-height: 1.2;\n   \">\n  <svg xmlns=\"http:\/\/www.w3.org\/2000\/svg\" viewBox=\"0 0 256 256\" width=\"16\" height=\"16\" fill=\"currentColor\">\n    <rect width=\"256\" height=\"256\" fill=\"none\"\/>\n    <path d=\"M176,16H80A24,24,0,0,0,56,40V216a24,24,0,0,0,24,24h96a24,24,0,0,0,24-24V40A24,24,0,0,0,176,16ZM80,32h96a8,8,0,0,1,8,8v8H72V40A8,8,0,0,1,80,32Zm96,192H80a8,8,0,0,1-8-8v-8H184v8A8,8,0,0,1,176,224Z\"\/>\n  <\/svg>\n  Zweryfikuj projekt w aplikacji\n<\/a>\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t\t\t<div class=\"elementor-element elementor-element-60f00cb animated-fast elementor-invisible elementor-widget elementor-widget-shortcode\" data-id=\"60f00cb\" data-element_type=\"widget\" data-e-type=\"widget\" data-settings=\"{&quot;_animation_delay&quot;:50,&quot;_animation&quot;:&quot;fadeInUp&quot;}\" data-widget_type=\"shortcode.default\">\n\t\t\t\t<div class=\"elementor-widget-container\">\n\t\t\t\t\t\t\t<div class=\"elementor-shortcode\"><div class=\"indygo-weryfikator-wrap\">\n<style>\/* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n   WERYFIKATOR PROJEKT\u00d3W INDYGO v2\n   Design: INDYGO app tokens (Poppins, #FEC100, 20px radius)\n   Motyw: html.dark \/ body.dark (klasa motywu strony, sync przez JS)\n   J\u0119zyk: URL \/en\/ = angielski, wszystko inne = polski\n   Scope: wszystkie regu\u0142y pod #iv-wrap\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 *\/\n\n#iv-wrap *, #iv-wrap *::before, #iv-wrap *::after {\n  box-sizing: border-box; margin: 0; padding: 0;\n}\n\n\/* \u2500\u2500 Jasny motyw \u2500\u2500 *\/\n#iv-wrap {\n  font-family: 'Poppins', sans-serif;\n  font-size: 15px; line-height: 1.5;\n  -webkit-font-smoothing: antialiased;\n  background: transparent;\n  --c-bg:      #f2f2f7;\n  --c-card:    #ffffff;\n  --c-card2:   #f3f3f3;\n  --c-txt:     #000000;\n  --c-txt2:    rgba(0,0,0,.55);\n  --c-txt3:    rgba(0,0,0,.35);\n  --c-border:  rgba(0,0,0,.12);\n  --c-sep:     rgba(0,0,0,.07);\n  --c-acc:     #FEC100;\n  --c-acc-fg:  #000000;\n  --c-grn:     #27AE60;\n  --c-org:     #F2994A;\n  --c-red:     #EB5757;\n  --c-blue:    #2D9CDB;\n  --r:         20px;\n  --r-sm:      20px;\n  --r-xs:      20px;\n}\n\nhtml.dark #iv-wrap,\n#iv-wrap.iv-dark {\n  --c-bg:     #000000;\n  --c-card:   #1c1c1e;\n  --c-card2:  #2c2c2e;\n  --c-txt:    #ffffff;\n  --c-txt2:   rgba(255,255,255,.6);\n  --c-txt3:   rgba(255,255,255,.35);\n  --c-border: rgba(255,255,255,.14);\n  --c-sep:    rgba(255,255,255,.08);\n}\n\n#iv-wrap .iv-root {\n  padding: 16px;\n  display: flex; flex-direction: column; gap: 16px;\n  color: var(--c-txt);\n}\n\n#iv-wrap .iv-card {\n  background: var(--c-card);\n  border-radius: var(--r);\n  overflow: hidden;\n}\n\n#iv-wrap .iv-label {\n  font-size: 11px; font-weight: 700;\n  letter-spacing: .07em; text-transform: uppercase;\n  color: var(--c-txt3);\n  padding: 16px 18px 8px;\n}\n\n#iv-wrap .iv-sep { height: 1px; background: var(--c-sep); margin: 2px 0; }\n\n\/* \u2500\u2500 Selects \u2500\u2500 *\/\n#iv-wrap .iv-selects {\n  padding: 6px 14px 16px;\n  display: flex; flex-direction: column; gap: 10px;\n}\n#iv-wrap .iv-select-wrap { position: relative; }\n#iv-wrap .iv-select-wrap select {\n  width: 100%; appearance: none; -webkit-appearance: none;\n  padding: 14px 40px 14px 16px;\n  border: 1.5px solid var(--c-border);\n  border-radius: var(--r-sm);\n  background: var(--c-card2);\n  font-family: 'Poppins', sans-serif;\n  font-size: 13px; font-weight: 500;\n  color: var(--c-txt); cursor: pointer; outline: none;\n  transition: border-color .18s;\n}\n#iv-wrap .iv-select-wrap select:focus { border-color: var(--c-acc); }\n#iv-wrap .iv-select-wrap::after {\n  content: ''; position: absolute; right: 14px; top: 50%;\n  transform: translateY(-50%);\n  width: 0; height: 0;\n  border-left: 5px solid transparent;\n  border-right: 5px solid transparent;\n  border-top: 6px solid var(--c-txt3);\n  pointer-events: none;\n}\n#iv-wrap .iv-select-wrap select option {\n  background: var(--c-card); color: var(--c-txt); font-weight: 500;\n}\n\n\/* \u2500\u2500 Wymagania \u2500\u2500 *\/\n#iv-wrap .iv-reqs-wrap {\n  padding: 0 14px 14px;\n  display: flex; flex-direction: column; gap: 8px;\n}\n#iv-wrap .iv-size-row {\n  display: flex; align-items: center; gap: 6px;\n  padding: 11px 14px;\n  background: var(--c-card2);\n  border-radius: var(--r-xs);\n  font-size: 15px; font-weight: 600; color: var(--c-txt2);\n}\n#iv-wrap .iv-reqs-grid {\n  display: grid; grid-template-columns: 1fr 1fr; gap: 6px;\n}\n@media (max-width: 480px) { #iv-wrap .iv-reqs-grid { grid-template-columns: 1fr; } }\n#iv-wrap .iv-req-chip {\n  display: flex; align-items: flex-start; gap: 8px;\n  padding: 10px 12px;\n  background: var(--c-card2);\n  border-radius: var(--r-xs);\n  font-size: 14px; color: var(--c-txt2); line-height: 1.4;\n}\n#iv-wrap .iv-req-dot {\n  width: 6px; height: 6px; border-radius: 50%;\n  background: var(--c-acc); flex-shrink: 0; margin-top: 4px;\n}\n#iv-wrap .iv-note-chip {\n  padding: 10px 13px;\n  background: rgba(254,193,0,.09);\n  border-left: 3px solid var(--c-acc);\n  border-radius: 0 var(--r-xs) var(--r-xs) 0;\n  font-size: 12px; color: var(--c-txt2); line-height: 1.6;\n}\n\n\/* \u2500\u2500 Upload \u2500\u2500 *\/\n#iv-wrap .iv-upload-wrap {\n  padding: 0 14px 16px;\n  display: flex; flex-direction: column; gap: 10px;\n}\n#iv-wrap .iv-dropzone {\n  border: 1.5px dashed var(--c-border);\n  border-radius: var(--r-sm);\n  padding: 26px 16px;\n  text-align: center; cursor: pointer;\n  background: var(--c-card2);\n  transition: border-color .18s, background .18s;\n}\n#iv-wrap .iv-dropzone:hover,\n#iv-wrap .iv-dropzone.drag {\n  border-color: var(--c-acc);\n  background: rgba(254,193,0,.05);\n}\n#iv-wrap .iv-dz-ico   { font-size: 28px; opacity: .5; margin-bottom: 5px; }\n#iv-wrap .iv-dz-title { font-size: 13px; font-weight: 600; margin-bottom: 2px; }\n#iv-wrap .iv-dz-sub   { font-size: 11px; color: var(--c-txt2); }\n#iv-wrap .iv-dz-sub em { color: var(--c-acc); font-style: normal; font-weight: 600; cursor: pointer; }\n#iv-wrap .iv-formats { display: flex; flex-wrap: wrap; gap: 5px; justify-content: center; margin-top: 10px; }\n#iv-wrap .iv-fmt-chip {\n  padding: 2px 9px; border-radius: 20px;\n  font-size: 10px; font-weight: 700; letter-spacing: .04em;\n  background: var(--c-card2); border: 1.5px solid var(--c-border); color: var(--c-txt3);\n}\n\n\/* \u2500\u2500 Lista wgranych plik\u00f3w \u2500\u2500 *\/\n#iv-wrap .iv-files-list {\n  display: flex; flex-direction: column; gap: 6px;\n}\n#iv-wrap .iv-file-row {\n  display: flex; align-items: center; gap: 10px;\n  padding: 11px 13px;\n  background: var(--c-card2);\n  border-radius: var(--r-sm);\n}\n#iv-wrap .iv-file-slot-label {\n  font-size: 10px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase;\n  color: var(--c-txt3); white-space: nowrap; flex-shrink: 0;\n  min-width: 70px;\n}\n#iv-wrap .iv-file-ico  { font-size: 18px; flex-shrink: 0; }\n#iv-wrap .iv-file-name {\n  flex: 1; min-width: 0; font-size: 12px; font-weight: 600;\n  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;\n}\n#iv-wrap .iv-file-sz   { font-size: 10px; color: var(--c-txt2); white-space: nowrap; }\n#iv-wrap .iv-file-rm {\n  background: none; border: none; cursor: pointer;\n  color: var(--c-txt3); font-size: 16px; padding: 2px; line-height: 1;\n  transition: color .15s; flex-shrink: 0;\n}\n#iv-wrap .iv-file-rm:hover { color: var(--c-red); }\n\n\/* \u2500\u2500 Przycisk \"Dodaj stron\u0119\" \u2500\u2500 *\/\n#iv-wrap .iv-add-page-btn {\n  display: flex; align-items: center; justify-content: center; gap: 8px;\n  width: 100%; padding: 12px 16px;\n  border: 1.5px dashed var(--c-border);\n  border-radius: var(--r-sm);\n  background: none;\n  font-family: 'Poppins', sans-serif;\n  font-size: 12px; font-weight: 600; color: var(--c-txt2);\n  cursor: pointer; transition: all .15s;\n}\n#iv-wrap .iv-add-page-btn:hover {\n  border-color: var(--c-acc); color: var(--c-txt);\n  background: rgba(254,193,0,.04);\n}\n#iv-wrap .iv-add-page-btn .iv-add-icon {\n  width: 20px; height: 20px; border-radius: 50%;\n  background: var(--c-acc); color: var(--c-acc-fg);\n  font-size: 14px; font-weight: 700; line-height: 20px; text-align: center;\n  flex-shrink: 0;\n}\n\n\/* \u2500\u2500 Info o brakuj\u0105cych stronach \u2500\u2500 *\/\n#iv-wrap .iv-missing-info {\n  display: flex; align-items: flex-start; gap: 10px;\n  padding: 10px 13px;\n  background: rgba(45,156,219,.08);\n  border-left: 3px solid var(--c-blue);\n  border-radius: 0 var(--r-xs) var(--r-xs) 0;\n  font-size: 12px; color: var(--c-txt2); line-height: 1.6;\n}\n\n#iv-wrap input[type=file] { display: none; }\n\n#iv-wrap .iv-btn-verify {\n  width: 100%; padding: 16px;\n  border: none; border-radius: var(--r-sm);\n  background: var(--c-acc); color: var(--c-acc-fg);\n  font-family: 'Poppins', sans-serif;\n  font-size: 15px; font-weight: 700;\n  cursor: pointer; letter-spacing: .01em;\n  transition: opacity .18s, transform .12s;\n}\n#iv-wrap .iv-btn-verify:hover:not(:disabled) { opacity: .84; transform: scale(.98); }\n#iv-wrap .iv-btn-verify:disabled { opacity: .28; cursor: default; }\n\n\/* \u2500\u2500 Wyniki \u2500\u2500 *\/\n#iv-wrap .iv-summary {\n  display: flex; align-items: center; gap: 10px;\n  padding: 12px 16px; margin: 0 12px 8px;\n  border-radius: var(--r-sm);\n  font-size: 12px; font-weight: 600;\n}\n#iv-wrap .iv-sum-ok   { background: rgba(39,174,96,.1); }\n#iv-wrap .iv-sum-warn { background: rgba(242,153,74,.1); }\n#iv-wrap .iv-sum-err  { background: rgba(235,87,87,.1); }\n#iv-wrap .iv-sum-badge {\n  padding: 3px 10px; border-radius: 20px;\n  font-size: 10px; font-weight: 700; color: #fff; flex-shrink: 0;\n}\n\n\/* \u2500\u2500 Podgl\u0105d 2D z siatk\u0105 \u2500\u2500 *\/\n#iv-wrap .iv-preview-wrap { padding: 0 12px 8px; }\n#iv-wrap .iv-preview-tabs {\n  display: flex; gap: 5px; margin-bottom: 8px;\n}\n#iv-wrap .iv-preview-tab {\n  flex: 1; padding: 8px 4px;\n  border: 1.5px solid var(--c-border);\n  border-radius: var(--r-xs);\n  background: var(--c-card2);\n  font-family: 'Poppins', sans-serif;\n  font-size: 11px; font-weight: 600;\n  color: var(--c-txt2); cursor: pointer;\n  transition: all .14s; text-align: center;\n}\n#iv-wrap .iv-preview-tab:hover { border-color: var(--c-acc); color: var(--c-txt); }\n#iv-wrap .iv-preview-tab.active {\n  background: var(--c-acc); border-color: var(--c-acc); color: var(--c-acc-fg);\n}\n\n\/* Podgl\u0105d 2D canvas *\/\n#iv-wrap .iv-preview2d-wrap {\n  position: relative; width: 100%;\n  border-radius: var(--r-sm); overflow: hidden;\n  background: #404040;\n  display: flex; align-items: center; justify-content: center;\n  padding: 16px;\n}\n#iv-wrap #iv-canvas2d {\n  display: block;\n  max-width: 100%; max-height: 400px;\n  border-radius: 2px;\n  box-shadow: 0 2px 16px rgba(0,0,0,.4);\n}\n#iv-wrap .iv-grid-legend {\n  display: flex; align-items: center; gap: 14px;\n  margin-top: 7px; flex-wrap: wrap;\n  padding: 0 2px;\n}\n#iv-wrap .iv-legend-item {\n  display: flex; align-items: center; gap: 5px;\n  font-size: 10px; font-weight: 600; color: var(--c-txt3);\n}\n#iv-wrap .iv-legend-line {\n  width: 22px; height: 2px; border-radius: 1px; flex-shrink: 0;\n}\n\n\/* 3D canvas *\/\n#iv-wrap .iv-canvas-wrap {\n  position: relative; width: 100%;\n  aspect-ratio: 16 \/ 9;\n  border-radius: var(--r-sm); overflow: hidden;\n  background: #080808; cursor: grab;\n}\n#iv-wrap .iv-canvas-wrap:active { cursor: grabbing; }\n#iv-wrap #iv-canvas { display: block; width: 100%; height: 100%; }\n#iv-wrap .iv-page-btns { display: flex; gap: 5px; margin-top: 7px; }\n#iv-wrap .iv-page-btn {\n  flex: 1; padding: 8px 4px;\n  border: 1.5px solid var(--c-border);\n  border-radius: var(--r-xs);\n  background: var(--c-card2);\n  font-family: 'Poppins', sans-serif;\n  font-size: 11px; font-weight: 600;\n  color: var(--c-txt2); cursor: pointer;\n  transition: all .14s; text-align: center;\n}\n#iv-wrap .iv-page-btn:hover { border-color: var(--c-acc); color: var(--c-txt); }\n#iv-wrap .iv-page-btn.active {\n  background: var(--c-acc); border-color: var(--c-acc); color: var(--c-acc-fg);\n}\n#iv-wrap .iv-page-ind {\n  position: absolute; top: 10px; left: 50%; transform: translateX(-50%);\n  background: rgba(0,0,0,.72); color: #fff;\n  padding: 4px 12px; border-radius: 20px;\n  font-size: 10px; font-weight: 600;\n  pointer-events: none; opacity: 0; transition: opacity .3s; white-space: nowrap;\n}\n#iv-wrap .iv-page-ind.show { opacity: 1; }\n\n\/* \u2500\u2500 Szczeg\u00f3\u0142y \u2500\u2500 *\/\n#iv-wrap .iv-checks {\n  padding: 0 14px 16px;\n  display: flex; flex-direction: column; gap: 8px;\n}\n#iv-wrap .iv-check-item {\n  display: flex; align-items: flex-start; gap: 10px;\n  padding: 12px 15px; border-radius: var(--r-xs);\n}\n#iv-wrap .iv-ci-ok   { background: rgba(39,174,96,.08); }\n#iv-wrap .iv-ci-warn { background: rgba(242,153,74,.10); }\n#iv-wrap .iv-ci-err  { background: rgba(235,87,87,.08); }\n#iv-wrap .iv-ci-info { background: rgba(45,156,219,.08); }\n#iv-wrap .iv-ci-badge {\n  min-width: 52px; width: 52px;\n  padding: 3px 0; border-radius: 20px;\n  font-size: 10px; font-weight: 700; color: #fff; letter-spacing: .03em;\n  flex-shrink: 0; align-self: flex-start; margin-top: 2px;\n  white-space: nowrap; text-align: center;\n}\n#iv-wrap .iv-ci-badge-ok   { background: var(--c-grn); }\n#iv-wrap .iv-ci-badge-warn { background: var(--c-org); }\n#iv-wrap .iv-ci-badge-err  { background: var(--c-red); }\n#iv-wrap .iv-ci-badge-info { background: var(--c-blue); }\n#iv-wrap .iv-ci-title { font-size: 13px; font-weight: 600; line-height: 1.4; }\n#iv-wrap .iv-ci-desc {\n  font-size: 12px; color: var(--c-txt2);\n  margin-top: 3px; line-height: 1.55;\n}\n#iv-wrap .iv-ci-page-tag {\n  display: inline-block;\n  padding: 1px 7px; border-radius: 10px;\n  font-size: 10px; font-weight: 700;\n  background: var(--c-card2); color: var(--c-txt3);\n  margin-bottom: 4px;\n}\n\n#iv-wrap .iv-btn-back {\n  display: inline-flex; align-items: center; gap: 5px;\n  padding: 10px 16px; margin: 0 12px 12px;\n  border: 1.5px solid var(--c-border); border-radius: var(--r-sm);\n  background: none;\n  font-family: 'Poppins', sans-serif;\n  font-size: 12px; font-weight: 600; color: var(--c-txt2);\n  cursor: pointer; transition: all .15s;\n}\n#iv-wrap .iv-btn-back:hover { border-color: var(--c-txt); color: var(--c-txt); }\n\n\/* \u2500\u2500 Loading \u2500\u2500 *\/\n#iv-wrap .iv-canvas-loading {\n  position: absolute; inset: 0;\n  background: rgba(0,0,0,.55);\n  display: flex; flex-direction: column;\n  align-items: center; justify-content: center; gap: 8px;\n}\n#iv-wrap .iv-spinner {\n  width: 26px; height: 26px;\n  border: 2.5px solid rgba(255,255,255,.2);\n  border-top-color: #fff; border-radius: 50%;\n  animation: iv-spin .7s linear infinite;\n}\n@keyframes iv-spin { to { transform: rotate(360deg); } }\n#iv-wrap .iv-canvas-loading p { font-size: 11px; color: #fff; font-weight: 500; }\n#iv-wrap .iv-fullload {\n  position: fixed; inset: 0; z-index: 9999;\n  background: rgba(0,0,0,.58);\n  display: flex; flex-direction: column;\n  align-items: center; justify-content: center; gap: 14px;\n}\n#iv-wrap .iv-fullload .iv-spinner { width: 30px; height: 30px; }\n#iv-wrap .iv-fullload p { font-size: 13px; color: #fff; font-weight: 600; }\n\n#iv-wrap ::-webkit-scrollbar { width: 4px; }\n#iv-wrap ::-webkit-scrollbar-track { background: transparent; }\n#iv-wrap ::-webkit-scrollbar-thumb { background: var(--c-border); border-radius: 2px; }\n<\/style>\n<div id=\"iv-wrap\">\n<div class=\"iv-root\">\n\n  <!-- Wyb\u00f3r produktu -->\n  <div class=\"iv-card\">\n    <div class=\"iv-label\" id=\"iv-sel-label\">Wybierz produkt<\/div>\n    <div class=\"iv-selects\">\n      <div class=\"iv-select-wrap\">\n        <select id=\"iv-cat-sel\" onchange=\"onCatChange()\"><\/select>\n      <\/div>\n      <div class=\"iv-select-wrap\" id=\"iv-prod-wrap\">\n        <select id=\"iv-prod-sel\" onchange=\"onProdChange()\"><\/select>\n      <\/div>\n      <div class=\"iv-select-wrap\" id=\"iv-format-wrap\" style=\"display:none\">\n        <select id=\"iv-format-sel\" onchange=\"onFormatChange()\"><\/select>\n      <\/div>\n      <div class=\"iv-select-wrap\" id=\"iv-sides-wrap\" style=\"display:none\">\n        <select id=\"iv-sides-sel\" onchange=\"onSidesChange()\"><\/select>\n      <\/div>\n      <div class=\"iv-select-wrap\" id=\"iv-finish-wrap\" style=\"display:none\">\n        <select id=\"iv-finish-sel\" onchange=\"onFinishChange()\"><\/select>\n      <\/div>\n    <\/div>\n  <\/div>\n\n  <!-- Wymagania + upload -->\n  <div class=\"iv-card\" id=\"iv-detail\" style=\"display:none\">\n    <div class=\"iv-label\" id=\"iv-req-label\">Wymagania pliku<\/div>\n    <div class=\"iv-reqs-wrap\">\n      <div class=\"iv-size-row\" id=\"iv-size-row\"><\/div>\n      <div class=\"iv-reqs-grid\" id=\"iv-reqs-grid\"><\/div>\n      <div class=\"iv-note-chip\" id=\"iv-note\" style=\"display:none\"><\/div>\n    <\/div>\n\n    <div class=\"iv-sep\"><\/div>\n\n    <div class=\"iv-label\" id=\"iv-up-label\">Wgraj plik projektu<\/div>\n    <div class=\"iv-upload-wrap\">\n\n      <!-- Strefa upustu (g\u0142\u00f3wna, zawsze widoczna gdy nie ma wszystkich plik\u00f3w) -->\n      <div class=\"iv-dropzone\" id=\"iv-dropzone\">\n        <div class=\"iv-dz-ico\">\ud83d\udcc1<\/div>\n        <div class=\"iv-dz-title\" id=\"iv-dz-t\">Przeci\u0105gnij plik tutaj<\/div>\n        <div class=\"iv-dz-sub\" id=\"iv-dz-s\">lub <em id=\"iv-dz-link\">kliknij, aby wybra\u0107<\/em><\/div>\n        <div class=\"iv-formats\">\n          <span class=\"iv-fmt-chip\">PDF<\/span>\n          <span class=\"iv-fmt-chip\">JPG<\/span>\n          <span class=\"iv-fmt-chip\">PNG<\/span>\n          <span class=\"iv-fmt-chip\">TIF<\/span>\n          <span class=\"iv-fmt-chip\">TIFF<\/span>\n        <\/div>\n      <\/div>\n\n      <!-- Lista wgranych plik\u00f3w -->\n      <div class=\"iv-files-list\" id=\"iv-files-list\"><\/div>\n\n      <!-- Info o brakuj\u0105cych stronach -->\n      <div class=\"iv-missing-info\" id=\"iv-missing-info\" style=\"display:none\">\n        <span>\ud83d\udca1<\/span><span id=\"iv-missing-text\"><\/span>\n      <\/div>\n\n      <!-- Przycisk dodania kolejnej strony -->\n      <button class=\"iv-add-page-btn\" id=\"iv-add-page-btn\" style=\"display:none\" onclick=\"ivTriggerAdd()\">\n        <span class=\"iv-add-icon\">+<\/span>\n        <span id=\"iv-add-page-label\">Dodaj kolejn\u0105 stron\u0119<\/span>\n      <\/button>\n\n      <input type=\"file\" id=\"iv-file\" accept=\".pdf,.jpg,.jpeg,.png,.tif,.tiff\">\n      <button class=\"iv-btn-verify\" id=\"iv-btn-v\" disabled onclick=\"ivVerify()\">Weryfikuj projekt<\/button>\n    <\/div>\n  <\/div>\n\n  <!-- Wyniki -->\n  <div class=\"iv-card\" id=\"iv-results\" style=\"display:none\">\n    <div class=\"iv-label\" id=\"iv-res-label\">Wyniki weryfikacji<\/div>\n    <div class=\"iv-summary\" id=\"iv-summary\">\n      <span class=\"iv-sum-badge\" id=\"iv-sum-badge\">\u2014<\/span>\n      <span id=\"iv-sum-text\">\u2014<\/span>\n    <\/div>\n\n    <!-- Podgl\u0105d -->\n    <div class=\"iv-label\" id=\"iv-prev-label\">Podgl\u0105d projektu<\/div>\n    <div class=\"iv-preview-wrap\">\n      <!-- Zak\u0142adki stron podgl\u0105du 2D -->\n      <div class=\"iv-preview-tabs\" id=\"iv-preview-tabs\"><\/div>\n\n      <!-- 2D canvas z siatk\u0105 -->\n      <div class=\"iv-preview2d-wrap\" id=\"iv-preview2d-wrap\">\n        <canvas id=\"iv-canvas2d\"><\/canvas>\n      <\/div>\n      <div class=\"iv-grid-legend\" id=\"iv-grid-legend\">\n        <div class=\"iv-legend-item\">\n          <div class=\"iv-legend-line\" style=\"background:#ff3b30;\"><\/div>\n          <span id=\"iv-leg-bleed\">Linia spad\u00f3w<\/span>\n        <\/div>\n        <div class=\"iv-legend-item\">\n          <div class=\"iv-legend-line\" style=\"background:#007aff; border-top: 2px dashed #007aff; height:0;\"><\/div>\n          <span id=\"iv-leg-margin\">Margines bezpieczny<\/span>\n        <\/div>\n        <div class=\"iv-legend-item\">\n          <div class=\"iv-legend-line\" style=\"background:#fec100;\"><\/div>\n          <span id=\"iv-leg-cut\">Linia ci\u0119cia<\/span>\n        <\/div>\n      <\/div>\n\n      <!-- 3D podgl\u0105d -->\n      <div style=\"margin-top:10px;\">\n        <div class=\"iv-canvas-wrap\" id=\"iv-canvas-wrap\">\n          <canvas id=\"iv-canvas\"><\/canvas>\n          <div class=\"iv-canvas-loading\" id=\"iv-cload\" style=\"display:none\">\n            <div class=\"iv-spinner\"><\/div>\n            <p id=\"iv-cload-txt\">Renderowanie\u2026<\/p>\n          <\/div>\n          <div class=\"iv-page-ind\" id=\"iv-page-ind\"><\/div>\n        <\/div>\n        <div class=\"iv-page-btns\" id=\"iv-page-btns\"><\/div>\n      <\/div>\n    <\/div>\n\n    <!-- Szczeg\u00f3\u0142y -->\n    <div class=\"iv-label\" id=\"iv-checks-label\">Szczeg\u00f3\u0142y sprawdzenia<\/div>\n    <div class=\"iv-checks\" id=\"iv-checks\"><\/div>\n\n    <button class=\"iv-btn-back\" id=\"iv-btn-back\" onclick=\"ivReset()\">\u2190 Sprawd\u017a inny plik<\/button>\n  <\/div>\n\n<\/div><!-- \/iv-root -->\n\n<div class=\"iv-fullload\" id=\"iv-loading\" style=\"display:none\">\n  <div class=\"iv-spinner\"><\/div>\n  <p id=\"iv-loading-txt\">\u0141adowanie\u2026<\/p>\n<\/div>\n<\/div><!-- \/iv-wrap -->\n\n<script>\n'use strict';\n\/\/ Ustaw workerSrc PRZED jakimkolwiek u\u017cyciem pdf.js\n\/\/ FlyingPress nie cachuje string\u00f3w JS \u2014 tylko tagi <script src>\nwindow._PDFJS_WORKER = 'https:\/\/cdnjs.cloudflare.com\/ajax\/libs\/pdf.js\/3.11.174\/pdf.worker.min.js';\n<\/script>\n<script src=\"https:\/\/cdnjs.cloudflare.com\/ajax\/libs\/pdf.js\/3.11.174\/pdf.min.js\" data-no-optimize=\"1\" data-cfasync=\"false\" crossorigin=\"anonymous\"><\/script>\n<script src=\"https:\/\/cdnjs.cloudflare.com\/ajax\/libs\/three.js\/r128\/three.min.js\" data-no-optimize=\"1\" data-cfasync=\"false\" crossorigin=\"anonymous\"><\/script>\n<script>\n'use strict';\n\/\/ Konfiguracja workera PDF.js\n\/\/ U\u017cywamy workerSrc z CDN (nie ruszanego przez FlyingPress bo to string w JS, nie <script src>)\n\/\/ Je\u015bli worker si\u0119 nie za\u0142aduje, getDocument z\u0142apie b\u0142\u0105d i spr\u00f3buje tryb bez workera\npdfjsLib.GlobalWorkerOptions.workerSrc = window._PDFJS_WORKER;\n\n\/\/ Wrapper dla getDocument obs\u0142uguj\u0105cy brak workera (WebView, CSP)\nasync function ivGetDocument(data) {\n  try {\n    return await pdfjsLib.getDocument({data}).promise;\n  } catch(e) {\n    \/\/ Worker failed \u2014 spr\u00f3buj bez workera\n    if (pdfjsLib.GlobalWorkerOptions.workerSrc !== '') {\n      pdfjsLib.GlobalWorkerOptions.workerSrc = '';\n      try {\n        return await pdfjsLib.getDocument({data}).promise;\n      } catch(e2) {\n        throw e2;\n      }\n    }\n    throw e;\n  }\n}\n\n\/* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n   J\u0118ZYK\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 *\/\nconst PL = !\/\\\/(en)(\\\/|$)\/i.test(window.location.pathname);\n\nconst T = {\n  selLabel:   PL ? 'Wybierz produkt'       : 'Select product',\n  reqLabel:   PL ? 'Wymagania pliku'        : 'File requirements',\n  upLabel:    PL ? 'Wgraj plik projektu'    : 'Upload project file',\n  dzT:        PL ? 'Przeci\u0105gnij plik tutaj (PDF, JPG, TIFF)' : 'Drag file here (PDF, JPG, TIFF)',\n  dzS:        PL ? 'lub'                    : 'or',\n  dzLink:     PL ? 'kliknij, aby wybra\u0107'   : 'click to select',\n  verifyBtn:  PL ? 'Weryfikuj projekt'      : 'Verify design',\n  resLabel:   PL ? 'Wyniki weryfikacji'     : 'Verification results',\n  prevLabel:  PL ? 'Podgl\u0105d projektu'       : 'Project preview',\n  chkLabel:   PL ? 'Szczeg\u00f3\u0142y sprawdzenia' : 'Check details',\n  backBtn:    PL ? '\u2190 Sprawd\u017a inny plik'   : '\u2190 Check another file',\n  rendering:  PL ? 'Renderowanie\u2026'         : 'Rendering\u2026',\n  loadPDF:    PL ? '\u0141adowanie PDF\u2026'        : 'Loading PDF\u2026',\n  loadPrev:   PL ? 'Renderowanie podgl\u0105du\u2026' : 'Rendering preview\u2026',\n  errFormat:  PL ? 'Nieobs\u0142ugiwany format. Wybierz PDF, JPG lub TIFF.' : 'Unsupported format.',\n  errLoad:    PL ? 'B\u0142\u0105d wczytywania pliku' : 'File loading error',\n  sumOK:      PL ? 'Plik gotowy do druku \u2713' : 'File ready for printing \u2713',\n  sumWarn:    PL ? 'Sprawd\u017a zaznaczone punkty przed wys\u0142aniem' : 'Review flagged items before sending',\n  sumErr:     PL ? 'Plik wymaga poprawek przed drukiem' : 'File needs corrections before printing',\n  bOK:        'OK',\n  bWarn:      PL ? 'Uwaga' : 'Warning',\n  bErr:       PL ? 'B\u0142\u0105d'  : 'Error',\n  bInfo:      'INFO',\n  catPlaceholder:    PL ? 'Kategoria produktu\u2026' : 'Product category\u2026',\n  prodPlaceholder:   PL ? 'Wybierz produkt\u2026'    : 'Select product\u2026',\n  formatPlaceholder: PL ? 'Format\u2026'             : 'Format\u2026',\n  sidesPlaceholder:  PL ? 'Druk\u2026'               : 'Print sides\u2026',\n  finishPlaceholder: PL ? 'Uszlachetnienie\u2026'    : 'Finishing\u2026',\n  sides1:            PL ? 'Jednostronne'         : 'Single-sided',\n  sides2:            PL ? 'Dwustronne'           : 'Double-sided',\n  finishBrak:        PL ? 'Bez uszlachetnienia'     : 'No finishing',\n  finishGold:        PL ? 'Ze z\u0142oceniem'            : 'Gold foil',\n  finishUV:          PL ? 'Z lakierem UV'           : 'UV varnish',\n  finishFoliaM:      PL ? 'Z foli\u0105 matow\u0105'          : 'Matte laminate',\n  finishFoliaB:      PL ? 'Z foli\u0105 b\u0142yszcz\u0105c\u0105'      : 'Gloss laminate',\n  finishSoftSkin:    PL ? 'Z foli\u0105 soft skin'       : 'Soft skin laminate',\n  pages:      n  => PL ? `${n} ${n===1?'strona':'strony'}` : `${n} ${n===1?'page':'pages'}`,\n  bleedSize:  (w,h) => PL ? `Ze spadem: ${w}\u00d7${h} mm` : `With bleed: ${w}\u00d7${h} mm`,\n  customSize: PL ? '\ud83d\udcd0 Rozmiar zgodny z zam\u00f3wieniem (skala 1:1)' : '\ud83d\udcd0 Size as per order (scale 1:1)',\n  legBleed:   PL ? 'Linia spad\u00f3w (+2 mm)'        : 'Bleed line (+2 mm)',\n  legMargin:  PL ? 'Margines bezpieczny'          : 'Safe margin',\n  legCut:     PL ? 'Linia ci\u0119cia'                 : 'Cut line',\n};\n\n\/* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n   STA\u0141E\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 *\/\nconst PT  = 2.834645669; \/\/ 1 mm w punktach PDF\nconst TOL = 1.5;         \/\/ tolerancja wymiar\u00f3w [mm]\n\n\/* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n   WYMAGANIA \u2014 etykiety\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 *\/\nconst REQS = {\n  cmyk:      { pl:'Kolorystyka: CMYK (nie RGB)',             en:'Color space: CMYK (not RGB)'        },\n  curves:    { pl:'Teksty zamienione na krzywe',             en:'Texts converted to curves'          },\n  dpi300:    { pl:'Mapy bitowe: min. 300 DPI',              en:'Bitmaps: min. 300 DPI'              },\n  bleed2:    { pl:'Spady: 2 mm z ka\u017cdej strony',            en:'Bleeds: 2 mm on each side'          },\n  margin3:   { pl:'Margines wewn\u0119trzny: min. 3 mm',         en:'Safe margin: min. 3 mm'             },\n  margin5:   { pl:'Margines wewn\u0119trzny: min. 5 mm',         en:'Safe margin: min. 5 mm'             },\n  gradients: { pl:'Przej\u015bcia tonalne \u2192 mapy bitowe',        en:'Gradients \u2192 bitmaps (recommended)'  },\n  masks:     { pl:'Maski uszlachetnie\u0144: K=100%, os. strony',en:'Finishing masks: K=100%, sep. pages'},\n  scale:     { pl:'Rozmiar z zam\u00f3wienia (skala 1:1)',        en:'Size as per order (scale 1:1)'      },\n};\nconst R = k => PL ? REQS[k].pl : REQS[k].en;\n\n\/* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n   KATEGORIE I PRODUKTY\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 *\/\nconst CATS = [\n  { id:'wiz', pl:'\ud83d\udcb3 Wizyt\u00f3wki',        en:'\ud83d\udcb3 Business cards'    },\n  { id:'ulo', pl:'\ud83d\udcc4 Ulotki',           en:'\ud83d\udcc4 Flyers'            },\n  { id:'ban', pl:'\ud83c\udfaa Banery i tablice', en:'\ud83c\udfaa Banners & boards'  },\n];\n\nconst PRODS = [\n  \/\/ \u2500\u2500 Wizyt\u00f3wki 90\u00d750 \u2500\u2500\n  { id:'wiz1s', cat:'wiz', format:'90x50', sides:'1', finish:'brak',\n    pl:'Wizyt\u00f3wka 90\u00d750 mm \u2014 jednostronna', en:'Business card 90\u00d750 mm \u2014 single-sided',\n    w:90,h:50,bl:2,margin:3,pages:1, pl_pg:['Prz\u00f3d'],en_pg:['Front'], model:'card',\n    reqs:['cmyk','curves','dpi300','bleed2','margin3','gradients'] },\n  { id:'wiz1', cat:'wiz', format:'90x50', sides:'2', finish:'brak',\n    pl:'Wizyt\u00f3wka 90\u00d750 mm \u2014 dwustronna', en:'Business card 90\u00d750 mm \u2014 double-sided',\n    w:90,h:50,bl:2,margin:3,pages:2, pl_pg:['Prz\u00f3d','Ty\u0142'],en_pg:['Front','Back'], model:'card',\n    reqs:['cmyk','curves','dpi300','bleed2','margin3','gradients'] },\n  { id:'wiz1fm', cat:'wiz', format:'90x50', sides:'2', finish:'folia_mat',\n    pl:'Wizyt\u00f3wka 90\u00d750 mm \u2014 folia matowa', en:'Business card 90\u00d750 mm \u2014 matte laminate',\n    w:90,h:50,bl:2,margin:3,pages:2, pl_pg:['Prz\u00f3d','Ty\u0142'],en_pg:['Front','Back'], model:'card',\n    reqs:['cmyk','curves','dpi300','bleed2','margin3','gradients'] },\n  { id:'wiz1fb', cat:'wiz', format:'90x50', sides:'2', finish:'folia_bly',\n    pl:'Wizyt\u00f3wka 90\u00d750 mm \u2014 folia b\u0142yszcz\u0105ca', en:'Business card 90\u00d750 mm \u2014 gloss laminate',\n    w:90,h:50,bl:2,margin:3,pages:2, pl_pg:['Prz\u00f3d','Ty\u0142'],en_pg:['Front','Back'], model:'card',\n    reqs:['cmyk','curves','dpi300','bleed2','margin3','gradients'] },\n  { id:'wiz1ss', cat:'wiz', format:'90x50', sides:'2', finish:'soft_skin',\n    pl:'Wizyt\u00f3wka 90\u00d750 mm \u2014 folia soft skin', en:'Business card 90\u00d750 mm \u2014 soft skin laminate',\n    w:90,h:50,bl:2,margin:3,pages:2, pl_pg:['Prz\u00f3d','Ty\u0142'],en_pg:['Front','Back'], model:'card',\n    reqs:['cmyk','curves','dpi300','bleed2','margin3','gradients'] },\n  { id:'wiz3', cat:'wiz', format:'90x50', sides:'2', finish:'gold',\n    pl:'Wizyt\u00f3wka 90\u00d750 mm \u2014 z\u0142ocenie', en:'Business card 90\u00d750 mm \u2014 gold foil',\n    w:90,h:50,bl:2,margin:3,pages:4,\n    pl_pg:['Prz\u00f3d','Ty\u0142','Maska z\u0142ocenia \u2014 prz\u00f3d','Maska z\u0142ocenia \u2014 ty\u0142'],\n    en_pg:['Front','Back','Gold mask \u2014 front','Gold mask \u2014 back'], model:'card',\n    reqs:['cmyk','curves','dpi300','bleed2','margin3','gradients','masks'],\n    note:{ pl:'Strony 3\u20134: maski z\u0142ocenia \u2014 wy\u0142\u0105cznie K=100%. Czarny = obszar z\u0142ocenia.',\n           en:'Pages 3\u20134: gold foil masks \u2014 K=100% only. Black = gold area.' } },\n  { id:'wiz5', cat:'wiz', format:'90x50', sides:'2', finish:'uv',\n    pl:'Wizyt\u00f3wka 90\u00d750 mm \u2014 lakier UV', en:'Business card 90\u00d750 mm \u2014 UV varnish',\n    w:90,h:50,bl:2,margin:3,pages:4,\n    pl_pg:['Prz\u00f3d','Ty\u0142','Maska lakieru \u2014 prz\u00f3d','Maska lakieru \u2014 ty\u0142'],\n    en_pg:['Front','Back','Varnish mask \u2014 front','Varnish mask \u2014 back'], model:'card',\n    reqs:['cmyk','curves','dpi300','bleed2','margin3','gradients','masks'],\n    note:{ pl:'Strony 3\u20134: maski lakieru UV \u2014 wy\u0142\u0105cznie K=100%. Czarny = obszar lakierowania.',\n           en:'Pages 3\u20134: UV varnish masks \u2014 K=100% only. Black = varnish area.' } },\n\n  \/\/ \u2500\u2500 Wizyt\u00f3wki 85\u00d754 \u2500\u2500\n  { id:'wiz2s', cat:'wiz', format:'85x54', sides:'1', finish:'brak',\n    pl:'Wizyt\u00f3wka 85\u00d754 mm \u2014 jednostronna', en:'Business card 85\u00d754 mm \u2014 single-sided',\n    w:85,h:54,bl:2,margin:3,pages:1, pl_pg:['Prz\u00f3d'],en_pg:['Front'], model:'card',\n    reqs:['cmyk','curves','dpi300','bleed2','margin3','gradients'] },\n  { id:'wiz2', cat:'wiz', format:'85x54', sides:'2', finish:'brak',\n    pl:'Wizyt\u00f3wka 85\u00d754 mm \u2014 dwustronna', en:'Business card 85\u00d754 mm \u2014 double-sided',\n    w:85,h:54,bl:2,margin:3,pages:2, pl_pg:['Prz\u00f3d','Ty\u0142'],en_pg:['Front','Back'], model:'card',\n    reqs:['cmyk','curves','dpi300','bleed2','margin3','gradients'] },\n  { id:'wiz2fm', cat:'wiz', format:'85x54', sides:'2', finish:'folia_mat',\n    pl:'Wizyt\u00f3wka 85\u00d754 mm \u2014 folia matowa', en:'Business card 85\u00d754 mm \u2014 matte laminate',\n    w:85,h:54,bl:2,margin:3,pages:2, pl_pg:['Prz\u00f3d','Ty\u0142'],en_pg:['Front','Back'], model:'card',\n    reqs:['cmyk','curves','dpi300','bleed2','margin3','gradients'] },\n  { id:'wiz2fb', cat:'wiz', format:'85x54', sides:'2', finish:'folia_bly',\n    pl:'Wizyt\u00f3wka 85\u00d754 mm \u2014 folia b\u0142yszcz\u0105ca', en:'Business card 85\u00d754 mm \u2014 gloss laminate',\n    w:85,h:54,bl:2,margin:3,pages:2, pl_pg:['Prz\u00f3d','Ty\u0142'],en_pg:['Front','Back'], model:'card',\n    reqs:['cmyk','curves','dpi300','bleed2','margin3','gradients'] },\n  { id:'wiz2ss', cat:'wiz', format:'85x54', sides:'2', finish:'soft_skin',\n    pl:'Wizyt\u00f3wka 85\u00d754 mm \u2014 folia soft skin', en:'Business card 85\u00d754 mm \u2014 soft skin laminate',\n    w:85,h:54,bl:2,margin:3,pages:2, pl_pg:['Prz\u00f3d','Ty\u0142'],en_pg:['Front','Back'], model:'card',\n    reqs:['cmyk','curves','dpi300','bleed2','margin3','gradients'] },\n  { id:'wiz4', cat:'wiz', format:'85x54', sides:'2', finish:'gold',\n    pl:'Wizyt\u00f3wka 85\u00d754 mm \u2014 z\u0142ocenie', en:'Business card 85\u00d754 mm \u2014 gold foil',\n    w:85,h:54,bl:2,margin:3,pages:4,\n    pl_pg:['Prz\u00f3d','Ty\u0142','Maska z\u0142ocenia \u2014 prz\u00f3d','Maska z\u0142ocenia \u2014 ty\u0142'],\n    en_pg:['Front','Back','Gold mask \u2014 front','Gold mask \u2014 back'], model:'card',\n    reqs:['cmyk','curves','dpi300','bleed2','margin3','gradients','masks'],\n    note:{ pl:'Strony 3\u20134: maski z\u0142ocenia \u2014 wy\u0142\u0105cznie K=100%.', en:'Pages 3\u20134: gold foil masks \u2014 K=100% only.' } },\n  { id:'wiz6', cat:'wiz', format:'85x54', sides:'2', finish:'uv',\n    pl:'Wizyt\u00f3wka 85\u00d754 mm \u2014 lakier UV', en:'Business card 85\u00d754 mm \u2014 UV varnish',\n    w:85,h:54,bl:2,margin:3,pages:4,\n    pl_pg:['Prz\u00f3d','Ty\u0142','Maska lakieru \u2014 prz\u00f3d','Maska lakieru \u2014 ty\u0142'],\n    en_pg:['Front','Back','Varnish mask \u2014 front','Varnish mask \u2014 back'], model:'card',\n    reqs:['cmyk','curves','dpi300','bleed2','margin3','gradients','masks'],\n    note:{ pl:'Strony 3\u20134: maski lakieru UV \u2014 wy\u0142\u0105cznie K=100%.', en:'Pages 3\u20134: UV varnish masks \u2014 K=100% only.' } },\n\n  \/\/ \u2500\u2500 Ulotki \u2500\u2500\n  { id:'ulo1s', cat:'ulo', format:'A6', sides:'1',\n    pl:'Ulotka A6 \u2014 105\u00d7148 mm \u2014 jednostronna', en:'Flyer A6 \u2014 105\u00d7148 mm \u2014 single-sided',\n    w:105,h:148,bl:2,margin:3,pages:1, pl_pg:['Prz\u00f3d'],en_pg:['Front'], model:'flyer',\n    reqs:['cmyk','curves','dpi300','bleed2','margin3','gradients'] },\n  { id:'ulo1', cat:'ulo', format:'A6', sides:'2',\n    pl:'Ulotka A6 \u2014 105\u00d7148 mm \u2014 dwustronna', en:'Flyer A6 \u2014 105\u00d7148 mm \u2014 double-sided',\n    w:105,h:148,bl:2,margin:3,pages:2, pl_pg:['Prz\u00f3d','Ty\u0142'],en_pg:['Front','Back'], model:'flyer',\n    reqs:['cmyk','curves','dpi300','bleed2','margin3','gradients'] },\n  { id:'ulo2s', cat:'ulo', format:'A5', sides:'1',\n    pl:'Ulotka A5 \u2014 148\u00d7210 mm \u2014 jednostronna', en:'Flyer A5 \u2014 148\u00d7210 mm \u2014 single-sided',\n    w:148,h:210,bl:2,margin:3,pages:1, pl_pg:['Prz\u00f3d'],en_pg:['Front'], model:'flyer',\n    reqs:['cmyk','curves','dpi300','bleed2','margin3','gradients'] },\n  { id:'ulo2', cat:'ulo', format:'A5', sides:'2',\n    pl:'Ulotka A5 \u2014 148\u00d7210 mm \u2014 dwustronna', en:'Flyer A5 \u2014 148\u00d7210 mm \u2014 double-sided',\n    w:148,h:210,bl:2,margin:3,pages:2, pl_pg:['Prz\u00f3d','Ty\u0142'],en_pg:['Front','Back'], model:'flyer',\n    reqs:['cmyk','curves','dpi300','bleed2','margin3','gradients'] },\n  { id:'ulo3s', cat:'ulo', format:'A4', sides:'1',\n    pl:'Ulotka A4 \u2014 210\u00d7297 mm \u2014 jednostronna', en:'Flyer A4 \u2014 210\u00d7297 mm \u2014 single-sided',\n    w:210,h:297,bl:2,margin:3,pages:1, pl_pg:['Prz\u00f3d'],en_pg:['Front'], model:'flyer',\n    reqs:['cmyk','curves','dpi300','bleed2','margin3','gradients'] },\n  { id:'ulo3', cat:'ulo', format:'A4', sides:'2',\n    pl:'Ulotka A4 \u2014 210\u00d7297 mm \u2014 dwustronna', en:'Flyer A4 \u2014 210\u00d7297 mm \u2014 double-sided',\n    w:210,h:297,bl:2,margin:3,pages:2, pl_pg:['Prz\u00f3d','Ty\u0142'],en_pg:['Front','Back'], model:'flyer',\n    reqs:['cmyk','curves','dpi300','bleed2','margin3','gradients'] },\n  { id:'ulo4s', cat:'ulo', format:'DL', sides:'1',\n    pl:'Ulotka DL \u2014 99\u00d7210 mm \u2014 jednostronna', en:'Flyer DL \u2014 99\u00d7210 mm \u2014 single-sided',\n    w:99,h:210,bl:2,margin:3,pages:1, pl_pg:['Prz\u00f3d'],en_pg:['Front'], model:'flyer',\n    reqs:['cmyk','curves','dpi300','bleed2','margin3','gradients'] },\n  { id:'ulo4', cat:'ulo', format:'DL', sides:'2',\n    pl:'Ulotka DL \u2014 99\u00d7210 mm \u2014 dwustronna', en:'Flyer DL \u2014 99\u00d7210 mm \u2014 double-sided',\n    w:99,h:210,bl:2,margin:3,pages:2, pl_pg:['Prz\u00f3d','Ty\u0142'],en_pg:['Front','Back'], model:'flyer',\n    reqs:['cmyk','curves','dpi300','bleed2','margin3','gradients'] },\n\n  \/\/ \u2500\u2500 Banery i tablice \u2500\u2500\n  { id:'ban1', cat:'ban', pl:'Baner z oczkowaniem', en:'Banner with eyelets',\n    custom:true, pages:1, pl_pg:['Projekt'],en_pg:['Design'], model:'banner',\n    bl:0,margin:5,\n    reqs:['cmyk','curves','dpi300','margin5','gradients','scale'] },\n  { id:'ban2', cat:'ban', pl:'Tablica PCV', en:'PVC board',\n    custom:true, pages:1, pl_pg:['Projekt'],en_pg:['Design'], model:'banner',\n    bl:0,margin:5,\n    reqs:['cmyk','curves','dpi300','margin5','gradients','scale'] },\n  { id:'ban3', cat:'ban', pl:'Tablica Dibond', en:'Dibond board',\n    custom:true, pages:1, pl_pg:['Projekt'],en_pg:['Design'], model:'banner',\n    bl:0,margin:5,\n    reqs:['cmyk','curves','dpi300','margin5','gradients','scale'] },\n];\n\n\/* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n   STATE\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 *\/\nconst S = {\n  cat: null, prod: null, format: null, sides: null, finish: null,\n  \/\/ slots: Array of {file, fileType, buf, pageCount} \u2014 jeden slot = jedna strona lub ca\u0142y PDF\n  slots: [],\n  rendered: [],   \/\/ canvas[] \u2014 pe\u0142na strona ze spadami (dla podgl\u0105du 2D)\n  rendered3d: [], \/\/ canvas[] \u2014 przyci\u0119te do TrimBox (dla podgl\u0105du 3D)\n  renderer: null, mesh: null, camera: null, af: null,\n  drag: false, prev: {x:0,y:0}, autoRot: true, autoTimer: null, targetY: 0,\n  zoomCleanup: null,\n  preview2dPage: 0, \/\/ aktywna strona w podgl\u0105dzie 2D\n};\n\n\/* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n   INICJALIZACJA\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 *\/\nfunction init() {\n  document.getElementById('iv-sel-label').textContent      = T.selLabel;\n  document.getElementById('iv-req-label').textContent      = T.reqLabel;\n  document.getElementById('iv-up-label').textContent       = T.upLabel;\n  document.getElementById('iv-dz-t').textContent           = T.dzT;\n  document.getElementById('iv-dz-link').textContent        = T.dzLink;\n  document.getElementById('iv-dz-s').childNodes[0].nodeValue = T.dzS + ' ';\n  document.getElementById('iv-btn-v').textContent          = T.verifyBtn;\n  document.getElementById('iv-res-label').textContent      = T.resLabel;\n  document.getElementById('iv-prev-label').textContent     = T.prevLabel;\n  document.getElementById('iv-checks-label').textContent   = T.chkLabel;\n  document.getElementById('iv-btn-back').textContent       = T.backBtn;\n  document.getElementById('iv-cload-txt').textContent      = T.rendering;\n  document.getElementById('iv-leg-bleed').textContent      = T.legBleed;\n  document.getElementById('iv-leg-margin').textContent     = T.legMargin;\n  document.getElementById('iv-leg-cut').textContent        = T.legCut;\n  buildCatSelect();\n}\n\n\/* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n   SELECTS \u2014 identyczne jak orygina\u0142\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 *\/\nfunction buildCatSelect() {\n  const sel = document.getElementById('iv-cat-sel');\n  sel.innerHTML = `<option value=\"\" disabled selected>${T.catPlaceholder}<\/option>`;\n  CATS.forEach(c => {\n    const o = document.createElement('option');\n    o.value = c.id; o.textContent = PL ? c.pl : c.en;\n    sel.appendChild(o);\n  });\n  buildProdSelect(null);\n}\n\nfunction onCatChange() {\n  const catId = document.getElementById('iv-cat-sel').value;\n  S.cat = catId; S.prod = null; S.format = null; S.sides = null; S.finish = null;\n  const useCascade = (catId === 'wiz' || catId === 'ulo');\n  document.getElementById('iv-prod-wrap').style.display   = useCascade ? 'none' : '';\n  document.getElementById('iv-format-wrap').style.display = useCascade ? ''     : 'none';\n  document.getElementById('iv-sides-wrap').style.display  = 'none';\n  document.getElementById('iv-finish-wrap').style.display = 'none';\n  if (useCascade) buildFormatSelect(catId);\n  else            buildProdSelect(catId);\n  document.getElementById('iv-detail').style.display  = 'none';\n  document.getElementById('iv-results').style.display = 'none';\n}\n\nfunction buildProdSelect(catId) {\n  const sel = document.getElementById('iv-prod-sel');\n  sel.innerHTML = `<option value=\"\" disabled selected>${T.prodPlaceholder}<\/option>`;\n  if (!catId) return;\n  PRODS.filter(p => p.cat === catId).forEach(p => {\n    const o = document.createElement('option');\n    o.value = p.id; o.textContent = PL ? p.pl : p.en;\n    sel.appendChild(o);\n  });\n}\n\nfunction onProdChange() {\n  const pid = document.getElementById('iv-prod-sel').value;\n  if (!pid) return;\n  S.prod = pid;\n  showProductDetail(PRODS.find(x => x.id === pid));\n}\n\nfunction buildFormatSelect(catId) {\n  const sel = document.getElementById('iv-format-sel');\n  sel.innerHTML = `<option value=\"\" disabled selected>${T.formatPlaceholder}<\/option>`;\n  const seen = new Set();\n  PRODS.filter(p => p.cat === catId).forEach(p => {\n    if (p.format && !seen.has(p.format)) {\n      seen.add(p.format);\n      const o = document.createElement('option');\n      o.value = p.format;\n      o.textContent = p.format === 'A6'  ? 'A6 \u2014 105\u00d7148 mm'\n                    : p.format === 'A5'  ? 'A5 \u2014 148\u00d7210 mm'\n                    : p.format === 'A4'  ? 'A4 \u2014 210\u00d7297 mm'\n                    : p.format === 'DL'  ? 'DL \u2014 99\u00d7210 mm'\n                    : p.format === '90x50' ? '90\u00d750 mm'\n                    : p.format === '85x54' ? '85\u00d754 mm'\n                    : p.format;\n      sel.appendChild(o);\n    }\n  });\n}\n\nfunction onFormatChange() {\n  S.format = document.getElementById('iv-format-sel').value;\n  S.sides = null; S.finish = null; S.prod = null;\n  document.getElementById('iv-sides-wrap').style.display  = '';\n  document.getElementById('iv-finish-wrap').style.display = 'none';\n  buildSidesSelect();\n  document.getElementById('iv-detail').style.display  = 'none';\n  document.getElementById('iv-results').style.display = 'none';\n}\n\nfunction buildSidesSelect() {\n  const sel = document.getElementById('iv-sides-sel');\n  sel.innerHTML = `<option value=\"\" disabled selected>${T.sidesPlaceholder}<\/option>`;\n  ['1','2'].forEach(v => {\n    const o = document.createElement('option');\n    o.value = v; o.textContent = v === '1' ? T.sides1 : T.sides2;\n    sel.appendChild(o);\n  });\n}\n\nfunction onSidesChange() {\n  S.sides = document.getElementById('iv-sides-sel').value;\n  S.finish = null; S.prod = null;\n  if (S.cat === 'wiz' && S.sides === '2') {\n    document.getElementById('iv-finish-wrap').style.display = '';\n    buildFinishSelect();\n    document.getElementById('iv-detail').style.display  = 'none';\n    document.getElementById('iv-results').style.display = 'none';\n  } else {\n    document.getElementById('iv-finish-wrap').style.display = 'none';\n    S.finish = 'brak';\n    resolveAndShow();\n  }\n}\n\nfunction buildFinishSelect() {\n  const sel = document.getElementById('iv-finish-sel');\n  sel.innerHTML = `<option value=\"\" disabled selected>${T.finishPlaceholder}<\/option>`;\n  [\n    {v:'brak',      t: T.finishBrak},\n    {v:'folia_mat', t: T.finishFoliaM},\n    {v:'folia_bly', t: T.finishFoliaB},\n    {v:'soft_skin', t: T.finishSoftSkin},\n    {v:'gold',      t: T.finishGold},\n    {v:'uv',        t: T.finishUV},\n  ].forEach(({v,t}) => {\n    const o = document.createElement('option'); o.value = v; o.textContent = t; sel.appendChild(o);\n  });\n}\n\nfunction onFinishChange() {\n  S.finish = document.getElementById('iv-finish-sel').value;\n  resolveAndShow();\n}\n\nfunction resolveAndShow() {\n  const p = PRODS.find(x =>\n    x.cat    === S.cat &&\n    x.format === S.format &&\n    x.sides  === S.sides &&\n    (S.cat !== 'wiz' || x.finish === S.finish)\n  );\n  if (!p) return;\n  S.prod = p.id;\n  showProductDetail(p);\n}\n\n\/* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n   PANEL WYMAGA\u0143\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 *\/\nfunction showProductDetail(p) {\n  const sr = document.getElementById('iv-size-row');\n  if (p.custom) {\n    sr.textContent = T.customSize;\n  } else {\n    const bw = p.w + p.bl * 2, bh = p.h + p.bl * 2;\n    sr.innerHTML = `\ud83d\udcd0 ${p.w}\u00d7${p.h} mm &nbsp;\u00b7&nbsp; ${T.bleedSize(bw, bh)} &nbsp;\u00b7&nbsp; ${T.pages(p.pages)} PDF`;\n  }\n  const rg = document.getElementById('iv-reqs-grid');\n  rg.innerHTML = '';\n  p.reqs.forEach(k => {\n    const chip = document.createElement('div');\n    chip.className = 'iv-req-chip';\n    chip.innerHTML = `<span class=\"iv-req-dot\"><\/span><span>${R(k)}<\/span>`;\n    rg.appendChild(chip);\n  });\n  const nb = document.getElementById('iv-note');\n  const note = p.note ? (PL ? p.note.pl : p.note.en) : null;\n  if (note) { nb.innerHTML = '\ud83d\udca1 ' + note; nb.style.display = 'block'; }\n  else nb.style.display = 'none';\n\n  document.getElementById('iv-detail').style.display  = 'block';\n  document.getElementById('iv-results').style.display = 'none';\n  ivResetFiles();\n  document.getElementById('iv-detail').scrollIntoView({ behavior:'smooth', block:'nearest' });\n}\n\n\/* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n   MULTI-UPLOAD \u2014 zarz\u0105dzanie slotami\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 *\/\n\n\/\/ Ile stron ju\u017c mamy \u0142\u0105cznie z wgranych slot\u00f3w\nfunction ivCountLoadedPages() {\n  const p = PRODS.find(x => x.id === S.prod);\n  if (!p) return 0;\n  let total = 0;\n  for (const slot of S.slots) {\n    total += slot.pageCount || 1;\n  }\n  return total;\n}\n\nfunction ivUpdateUploadUI() {\n  const p = PRODS.find(x => x.id === S.prod);\n  if (!p) return;\n  const required = p.pages;\n  const loaded   = ivCountLoadedPages();\n  const labels   = PL ? p.pl_pg : p.en_pg;\n\n  \/\/ Przerenderuj list\u0119 plik\u00f3w\n  const list = document.getElementById('iv-files-list');\n  list.innerHTML = '';\n  let pageIdx = 0;\n  S.slots.forEach((slot, si) => {\n    const row = document.createElement('div');\n    row.className = 'iv-file-row';\n    \/\/ Etykieta strony\n    const pagesInSlot = slot.pageCount || 1;\n    let slotLabel = '';\n    if (p.pages > 1) {\n      if (pagesInSlot > 1) {\n        slotLabel = labels.slice(pageIdx, pageIdx + pagesInSlot).join(' + ') || `str. ${pageIdx+1}\u2013${pageIdx+pagesInSlot}`;\n      } else {\n        slotLabel = labels[pageIdx] || `str. ${pageIdx+1}`;\n      }\n    }\n    pageIdx += pagesInSlot;\n\n    row.innerHTML = `\n      ${slotLabel ? `<span class=\"iv-file-slot-label\">${slotLabel}<\/span>` : ''}\n      <span class=\"iv-file-ico\">${slot.fileType === 'pdf' ? '\ud83d\udcc4' : '\ud83d\uddbc\ufe0f'}<\/span>\n      <div style=\"flex:1;min-width:0\">\n        <div class=\"iv-file-name\">${escHtml(slot.file.name)}<\/div>\n        <div class=\"iv-file-sz\">${(slot.file.size\/1024).toFixed(0)} KB${slot.pageCount > 1 ? ` \u00b7 ${slot.pageCount} str.` : ''}<\/div>\n      <\/div>\n      <button class=\"iv-file-rm\" onclick=\"ivRemoveSlot(${si})\">\u2715<\/button>`;\n    list.appendChild(row);\n  });\n\n  \/\/ Info o brakuj\u0105cych stronach\n  const missing    = required - loaded;\n  const missingEl  = document.getElementById('iv-missing-info');\n  const missingTxt = document.getElementById('iv-missing-text');\n  const addBtn     = document.getElementById('iv-add-page-btn');\n  const addLbl     = document.getElementById('iv-add-page-label');\n\n  if (loaded === 0) {\n    missingEl.style.display = 'none';\n    addBtn.style.display    = 'none';\n  } else if (missing > 0) {\n    const nextPageLabel = labels[loaded] || (PL ? `Strona ${loaded+1}` : `Page ${loaded+1}`);\n    missingTxt.textContent = PL\n      ? `Wgrano ${loaded} z ${required} stron. Brakuje: ${labels.slice(loaded).join(', ')}.`\n      : `Uploaded ${loaded} of ${required} pages. Missing: ${labels.slice(loaded).join(', ')}.`;\n    missingEl.style.display = 'flex';\n    addLbl.textContent = PL\n      ? `Dodaj: ${nextPageLabel}`\n      : `Add: ${nextPageLabel}`;\n    addBtn.style.display = 'flex';\n  } else {\n    missingEl.style.display = 'none';\n    addBtn.style.display    = 'none';\n  }\n\n  \/\/ G\u0142\u00f3wna dropzone \u2014 ukryj gdy ju\u017c mamy co najmniej jedn\u0105 stron\u0119\n  document.getElementById('iv-dropzone').style.display = loaded === 0 ? 'block' : 'none';\n\n  \/\/ Przycisk Weryfikuj \u2014 aktywny gdy mamy wystarczaj\u0105co stron\n  document.getElementById('iv-btn-v').disabled = loaded < required;\n}\n\nfunction ivRemoveSlot(idx) {\n  S.slots.splice(idx, 1);\n  ivUpdateUploadUI();\n}\n\nfunction ivResetFiles() {\n  S.slots = []; S.rendered = []; S.rendered3d = [];\n  if (S.zoomCleanup) { S.zoomCleanup(); S.zoomCleanup = null; }\n  if (S.af)       { cancelAnimationFrame(S.af); S.af = null; }\n  if (S.renderer) { S.renderer.dispose(); S.renderer = null; }\n  document.getElementById('iv-file').value = '';\n  document.getElementById('iv-files-list').innerHTML = '';\n  document.getElementById('iv-missing-info').style.display = 'none';\n  document.getElementById('iv-add-page-btn').style.display = 'none';\n  document.getElementById('iv-dropzone').style.display     = 'block';\n  document.getElementById('iv-btn-v').disabled = true;\n}\n\nfunction ivTriggerAdd() {\n  document.getElementById('iv-file').click();\n}\n\nasync function ivHandleFile(file) {\n  if (!file) return;\n  const p = PRODS.find(x => x.id === S.prod);\n  if (!p) return;\n\n  const n = file.name.toLowerCase();\n  const isPDF  = n.endsWith('.pdf')  || file.type === 'application\/pdf';\n  const isJPEG = n.endsWith('.jpg')  || n.endsWith('.jpeg');\n  const isTIFF = n.endsWith('.tif')  || n.endsWith('.tiff');\n  const isPNG  = n.endsWith('.png');\n  if (!isPDF && !isJPEG && !isTIFF && !isPNG) { alert(T.errFormat); return; }\n\n  const fileType = isPDF ? 'pdf' : (isJPEG || isPNG) ? 'jpeg' : 'tiff';\n  const buf = await file.arrayBuffer();\n\n  let pageCount = 1;\n  if (isPDF) {\n    try {\n      const doc = await ivGetDocument(buf.slice(0));\n      pageCount = doc.numPages;\n    } catch(e) { pageCount = 1; }\n  }\n\n  const required = p.pages;\n  const loaded   = ivCountLoadedPages();\n  const remaining = required - loaded;\n\n  \/\/ Je\u015bli PDF ma za du\u017co stron \u2014 przyjmij tylko tyle ile brakuje (poinformuj)\n  const actualPages = Math.min(pageCount, remaining > 0 ? remaining : required);\n\n  S.slots.push({ file, fileType, buf, pageCount: actualPages, _rawPages: pageCount });\n  ivUpdateUploadUI();\n}\n\n\/* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n   UPLOAD EVENTS\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 *\/\nconst dz = document.getElementById('iv-dropzone');\ndz.addEventListener('click', () => document.getElementById('iv-file').click());\ndocument.getElementById('iv-dz-link').addEventListener('click', e => {\n  e.stopPropagation(); document.getElementById('iv-file').click();\n});\ndocument.getElementById('iv-file').addEventListener('change', e => {\n  if (e.target.files[0]) { ivHandleFile(e.target.files[0]); e.target.value = ''; }\n});\ndz.addEventListener('dragover', e => { e.preventDefault(); dz.classList.add('drag'); });\ndz.addEventListener('dragleave', () => dz.classList.remove('drag'));\ndz.addEventListener('drop', e => {\n  e.preventDefault(); dz.classList.remove('drag');\n  if (e.dataTransfer.files[0]) ivHandleFile(e.dataTransfer.files[0]);\n});\n\n\/* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n   LOADING\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 *\/\nfunction ivLoad(v, t='') {\n  document.getElementById('iv-loading').style.display = v ? 'flex' : 'none';\n  if (t) document.getElementById('iv-loading-txt').textContent = t;\n}\n\n\/* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n   WERYFIKACJA G\u0141\u00d3WNA\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 *\/\nasync function ivVerify() {\n  const p = PRODS.find(x => x.id === S.prod);\n  if (!p || S.slots.length === 0) return;\n  S.rendered = []; S.rendered3d = [];\n  ivLoad(true, T.loadPDF);\n\n  const issues = [];\n  const allPageLabels = PL ? p.pl_pg : p.en_pg;\n\n  try {\n    \/\/ \u2500\u2500 Zbierz strony ze wszystkich slot\u00f3w \u2500\u2500\n    \/\/ pages[] = [{buf, fileType, file, pageLabel, slotIdx, localPageNum}]\n    const pages = [];\n    let globalPageIdx = 0;\n    for (let si = 0; si < S.slots.length; si++) {\n      const slot = S.slots[si];\n      for (let lp = 0; lp < slot.pageCount; lp++) {\n        pages.push({\n          buf: slot.buf,\n          fileType: slot.fileType,\n          file: slot.file,\n          pageLabel: allPageLabels[globalPageIdx] || `str. ${globalPageIdx+1}`,\n          slotIdx: si,\n          localPageNum: lp + 1,\n          globalIdx: globalPageIdx,\n          isMaskPage: globalPageIdx >= 2 && (p.finish === 'gold' || p.finish === 'uv'),\n        });\n        globalPageIdx++;\n      }\n    }\n\n    \/\/ \u2500\u2500 Renderuj podgl\u0105d dla ka\u017cdej strony \u2500\u2500\n    \/\/ Uwaga: renderWidth obliczamy tutaj wst\u0119pnie; je\u015bli wrap jest ukryty to fallback 800px\n    const getRenderWidth = () => {\n      const w = document.getElementById('iv-preview2d-wrap').clientWidth;\n      return Math.round((w > 50 ? w : 800) * Math.min(devicePixelRatio, 2));\n    };\n\n    for (let pi = 0; pi < pages.length; pi++) {\n      const pg = pages[pi];\n      ivLoad(true, PL ? `Renderowanie strony ${pi+1}\/${pages.length}\u2026` : `Rendering page ${pi+1}\/${pages.length}\u2026`);\n      let fullCanvas;\n      if (pg.fileType === 'pdf') {\n        const doc = await ivGetDocument(pg.buf.slice(0));\n        fullCanvas = await ivRenderPage(doc, pg.localPageNum, getRenderWidth());\n      } else {\n        fullCanvas = await ivRenderRaster(pg.file);\n      }\n      S.rendered.push(fullCanvas);\n\n      \/\/ Wersja przyci\u0119ta do TrimBox \/ trim size \u2014 dla podgl\u0105du 3D\n      \/\/ Dla raster\u00f3w (JPG\/TIFF) brak TrimBox \u2014 ivCropToTrim u\u017cyje wymiar\u00f3w produktu\n      const boxes = pg.fileType === 'pdf' ? parsePDFBoxes(pg.buf, pg.localPageNum) : {mediaBox: null, trimBox: null, bleedBox: null};\n      S.rendered3d.push(ivCropToTrim(fullCanvas, p, boxes));\n    }\n\n    \/\/ \u2500\u2500 Sprawdzenie ka\u017cdej strony \u2500\u2500\n    for (let pi = 0; pi < pages.length; pi++) {\n      const pg = pages[pi];\n      const pageIssues = [];\n\n      if (pg.fileType === 'pdf') {\n        await ivCheckPDFPage(pg, p, pageIssues);\n      } else {\n        await ivCheckRasterPage(pg, p, pageIssues);\n      }\n\n      \/\/ Oznacz wyniki etykiet\u0105 strony (tylko gdy > 1 strona)\n      if (pages.length > 1) {\n        pageIssues.forEach(i => { i.page = pg.pageLabel; });\n      }\n      issues.push(...pageIssues);\n    }\n\n    \/\/ \u2500\u2500 Sprawdzenie \u0142\u0105czne \u2014 liczba stron \u2500\u2500\n    const totalPgs = pages.length;\n    if (totalPgs === p.pages) {\n      issues.unshift({t:'ok',\n        title: PL ? `Liczba stron: ${totalPgs} \u2713` : `Page count: ${totalPgs} \u2713`,\n        desc:  PL ? `Zgodna z wymaganiami (${p.pages} stron).` : `Matches requirements (${p.pages} pages).`\n      });\n    } else if (totalPgs < p.pages) {\n      issues.unshift({t:'warn',\n        title: PL ? `Za ma\u0142o stron: ${totalPgs} z ${p.pages}` : `Too few pages: ${totalPgs} of ${p.pages}`,\n        desc:  PL ? `Brakuje ${p.pages-totalPgs} stron: ${allPageLabels.slice(totalPgs).join(', ')}.`\n                  : `Missing ${p.pages-totalPgs} page(s): ${allPageLabels.slice(totalPgs).join(', ')}.`\n      });\n    } else {\n      issues.unshift({t:'warn',\n        title: PL ? `Za du\u017co stron: ${totalPgs} (wymagane ${p.pages})` : `Too many pages: ${totalPgs} (need ${p.pages})`,\n        desc:  PL ? `U\u017cyte zostanie pierwszych ${p.pages} stron.` : `First ${p.pages} pages will be used.`\n      });\n    }\n\n    ivShowResults(issues, p);\n    ivBuildPreviewTabs(p, pages);\n    \/\/ Rysuj 2D od razu \u2014 wrap jest ju\u017c widoczny po ivShowResults\n    ivDraw2DWhenReady(p, 0);\n    \/\/ 3D osobno \u2014 b\u0142\u0105d Three.js nie mo\u017ce przerwa\u0107 reszty\n    try { ivBuild3D(p); } catch(e3d) { console.warn('3D preview error:', e3d); }\n\n  } catch(e) {\n    console.error(e);\n    ivShowResults([{t:'err', title: T.errLoad, desc: e.message}]);\n  } finally {\n    ivLoad(false);\n  }\n}\n\n\/* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n   SPRAWDZENIE STRONY PDF\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 *\/\nasync function ivCheckPDFPage(pg, prod, issues) {\n  const buf = pg.buf;\n  const rawCS   = scanPDFColorSpaces(buf);\n  const rawImgs = scanPDFImages(buf);\n  const jpegInf = scanJpegInPDF(buf);\n  \/\/ TrimBox\/MediaBox z surowych bajt\u00f3w\n  const boxes   = parsePDFBoxes(buf, pg.localPageNum);\n\n  const doc  = await ivGetDocument(buf.slice(0));\n  const page = await doc.getPage(pg.localPageNum);\n  const vp   = page.getViewport({scale:1});\n\n  \/\/ \u2500\u2500 Pobierz operator list raz \u2014 u\u017cywany przez wiele sprawdze\u0144 \u2500\u2500\n  const OPS = pdfjsLib.OPS;\n  const TEXT_OPS = new Set([OPS.showText, OPS.showSpacedText, OPS.nextLineShowText, OPS.nextLineSetSpacingShowText].filter(Boolean));\n  const RGB_OPS  = new Set([OPS.setFillRGBColor, OPS.setStrokeRGBColor].filter(Boolean));\n  const CMYK_OPS = new Set([OPS.setFillCMYKColor, OPS.setStrokeCMYKColor].filter(Boolean));\n  const ops = await page.getOperatorList();\n  const anyText     = TEXT_OPS.size > 0 && ops.fnArray.some(fn => TEXT_OPS.has(fn));\n  const anyRGB_op   = RGB_OPS.size  > 0 && ops.fnArray.some(fn => RGB_OPS.has(fn));\n  const anyCMYK_op  = CMYK_OPS.size > 0 && ops.fnArray.some(fn => CMYK_OPS.has(fn));\n\n  \/\/ \u2500\u2500 Scal wykrywanie kolor\u00f3w: raw scan (s\u0142owniki) + PDF.js OPS (zdekodowane strumienie) \u2500\u2500\n  const hasCMYK = rawCS.hasCMYK || jpegInf.hasCMYK || anyCMYK_op;\n  \/\/ RGB: prawdziwe je\u015bli ma operatory RGB a nie ma CMYK, lub raw wskazuje RGB\n  const hasRGB  = (anyRGB_op && !anyCMYK_op) ||\n                  (rawCS.hasRGB  && !hasCMYK) ||\n                  (jpegInf.hasRGB && !hasCMYK);\n  const hasGray = rawCS.hasGray || jpegInf.hasGray;\n  const seenDims = new Set(rawImgs.map(d => `${d.w}x${d.h}`));\n  const allImgs  = [...rawImgs, ...jpegInf.dims.filter(d => !seenDims.has(`${d.w}x${d.h}`))];\n\n  \/\/ \u2500\u2500 1a. Wymiary strony \u2500\u2500\n  if (!prod.custom) {\n    const actualW_mm = vp.width  \/ PT;\n    const actualH_mm = vp.height \/ PT;\n    const trimW_mm   = prod.w;\n    const trimH_mm   = prod.h;\n\n    if (Math.abs(actualW_mm - trimW_mm) <= TOL + prod.bl * 2 + 5 &&\n        Math.abs(actualH_mm - trimH_mm) <= TOL + prod.bl * 2 + 5) {\n      \/\/ Rozmiar w rozs\u0105dnym zakresie \u2014 raportuj wymiary po ci\u0119ciu\n      \/\/ (faktyczny trim = rozmiar strony minus spady, ale spady sprawdzamy osobno)\n      issues.push({t:'ok',\n        title: PL ? `Wymiary: ${actualW_mm.toFixed(1)}\u00d7${actualH_mm.toFixed(1)} mm \u2713`\n                  : `Dimensions: ${actualW_mm.toFixed(1)}\u00d7${actualH_mm.toFixed(1)} mm \u2713`,\n        desc:  PL ? `Rozmiar strony odpowiada wizyt\u00f3wce ${trimW_mm}\u00d7${trimH_mm} mm ze spadami.`\n                  : `Page size matches ${trimW_mm}\u00d7${trimH_mm} mm with bleeds.`\n      });\n    } else if (actualW_mm < trimW_mm - TOL || actualH_mm < trimH_mm - TOL) {\n      issues.push({t:'err',\n        title: PL ? `Wymiary: plik za ma\u0142y \u2014 ${actualW_mm.toFixed(1)}\u00d7${actualH_mm.toFixed(1)} mm`\n                  : `Dimensions: file too small \u2014 ${actualW_mm.toFixed(1)}\u00d7${actualH_mm.toFixed(1)} mm`,\n        desc:  PL ? `Minimalny rozmiar to ${trimW_mm}\u00d7${trimH_mm} mm (po ci\u0119ciu) + spady min. ${prod.bl} mm z ka\u017cdej strony = co najmniej ${trimW_mm + prod.bl*2}\u00d7${trimH_mm + prod.bl*2} mm.`\n                  : `Minimum size is ${trimW_mm}\u00d7${trimH_mm} mm (trim) + bleeds min. ${prod.bl} mm each side = at least ${trimW_mm + prod.bl*2}\u00d7${trimH_mm + prod.bl*2} mm.`\n      });\n    } else {\n      issues.push({t:'ok',\n        title: PL ? `Wymiary: ${actualW_mm.toFixed(1)}\u00d7${actualH_mm.toFixed(1)} mm \u2713`\n                  : `Dimensions: ${actualW_mm.toFixed(1)}\u00d7${actualH_mm.toFixed(1)} mm \u2713`,\n        desc:  PL ? `Rozmiar strony w porz\u0105dku (docelowo ${trimW_mm}\u00d7${trimH_mm} mm po ci\u0119ciu).`\n                  : `Page size OK (target trim: ${trimW_mm}\u00d7${trimH_mm} mm).`\n      });\n    }\n\n    \/\/ \u2500\u2500 1b. Spady \u2500\u2500\n    const mb = boxes.mediaBox;\n    const tb = boxes.trimBox;\n\n    if (tb && mb) {\n      \/\/ Precyzyjna kalkulacja z TrimBox\n      const bL = (tb.x1 - mb.x1) \/ PT;\n      const bB = (tb.y1 - mb.y1) \/ PT;\n      const bR = (mb.x2 - tb.x2) \/ PT;\n      const bT = (mb.y2 - tb.y2) \/ PT;\n      const minBleed = Math.min(bL, bB, bR, bT);\n      if (minBleed >= prod.bl - TOL) {\n        const overNote = minBleed > prod.bl + TOL\n          ? (PL ? ` (wi\u0119ksze ni\u017c ${prod.bl} mm \u2014 OK)` : ` (larger than ${prod.bl} mm \u2014 OK)`)\n          : '';\n        issues.push({t:'ok',\n          title: PL ? `Spady: L=${bL.toFixed(1)} T=${bT.toFixed(1)} R=${bR.toFixed(1)} B=${bB.toFixed(1)} mm \u2713`\n                    : `Bleeds: L=${bL.toFixed(1)} T=${bT.toFixed(1)} R=${bR.toFixed(1)} B=${bB.toFixed(1)} mm \u2713`,\n          desc:  PL ? `Min. ${minBleed.toFixed(1)} mm \u2014 wymagane min. ${prod.bl} mm z ka\u017cdej strony${overNote}.`\n                    : `Min. ${minBleed.toFixed(1)} mm \u2014 required min. ${prod.bl} mm each side${overNote}.`\n        });\n      } else {\n        issues.push({t:'err',\n          title: PL ? `Spady za ma\u0142e: min. ${minBleed.toFixed(1)} mm (wymagane ${prod.bl} mm)`\n                    : `Bleeds too small: min. ${minBleed.toFixed(1)} mm (required ${prod.bl} mm)`,\n          desc:  PL ? `Spady: L=${bL.toFixed(1)} T=${bT.toFixed(1)} R=${bR.toFixed(1)} B=${bB.toFixed(1)} mm. Ka\u017cda strona musi mie\u0107 min. ${prod.bl} mm spadu.`\n                    : `Bleeds: L=${bL.toFixed(1)} T=${bT.toFixed(1)} R=${bR.toFixed(1)} B=${bB.toFixed(1)} mm. Each side needs min. ${prod.bl} mm bleed.`\n        });\n      }\n    } else {\n      \/\/ Brak TrimBox \u2014 szacuj z rozmiaru strony\n      const actualW_mm2 = vp.width  \/ PT;\n      const actualH_mm2 = vp.height \/ PT;\n      const estBleed    = Math.min((actualW_mm2 - prod.w) \/ 2, (actualH_mm2 - prod.h) \/ 2);\n      if (estBleed >= prod.bl - TOL) {\n        issues.push({t:'warn',\n          title: PL ? `Spady: ~${estBleed.toFixed(1)} mm (brak TrimBox)`\n                    : `Bleeds: ~${estBleed.toFixed(1)} mm (no TrimBox)`,\n          desc:  PL ? `Plik nie zawiera TrimBox \u2014 spady szacowane z rozmiaru strony. Nie mo\u017cna zweryfikowa\u0107 czy obszar spadu jest wype\u0142niony grafik\u0105.`\n                    : `No TrimBox \u2014 bleeds estimated from page size. Cannot verify if bleed area is filled with artwork.`\n        });\n      } else if (estBleed <= 0) {\n        issues.push({t:'err',\n          title: PL ? `Spady: brak (plik ma rozmiar po ci\u0119ciu ${prod.w}\u00d7${prod.h} mm)`\n                    : `Bleeds: missing (file has trim size ${prod.w}\u00d7${prod.h} mm)`,\n          desc:  PL ? `Dodaj ${prod.bl} mm spadu z ka\u017cdej strony. Wymagany rozmiar pliku: co najmniej ${prod.w + prod.bl*2}\u00d7${prod.h + prod.bl*2} mm.`\n                    : `Add ${prod.bl} mm bleed on each side. Required file size: at least ${prod.w + prod.bl*2}\u00d7${prod.h + prod.bl*2} mm.`\n        });\n      } else {\n        issues.push({t:'err',\n          title: PL ? `Spady za ma\u0142e: ~${estBleed.toFixed(1)} mm (wymagane ${prod.bl} mm)`\n                    : `Bleeds too small: ~${estBleed.toFixed(1)} mm (required ${prod.bl} mm)`,\n          desc:  PL ? `Brak TrimBox. Szacowany spad ~${estBleed.toFixed(1)} mm jest mniejszy ni\u017c wymagane ${prod.bl} mm. Wymagany rozmiar: co najmniej ${prod.w + prod.bl*2}\u00d7${prod.h + prod.bl*2} mm.`\n                    : `No TrimBox. Estimated bleed ~${estBleed.toFixed(1)} mm is less than required ${prod.bl} mm. Required size: at least ${prod.w + prod.bl*2}\u00d7${prod.h + prod.bl*2} mm.`\n        });\n      }\n    }\n\n    \/\/ \u2500\u2500 1c. Margines bezpieczny \u2500\u2500\n    \/\/ Margines nie jest weryfikowalny automatycznie (wymaga analizy zawarto\u015bci)\n    \/\/ Informujemy klienta o wymaganiu\n    issues.push({t:'info',\n      title: PL ? `Margines bezpieczny: min. ${prod.margin} mm od kraw\u0119dzi ci\u0119cia`\n                : `Safe margin: min. ${prod.margin} mm from cut line`,\n      desc:  PL ? `Wa\u017cne elementy (teksty, logo) powinny znajdowa\u0107 si\u0119 min. ${prod.margin} mm od linii ci\u0119cia. Automatyczna weryfikacja marginesu nie jest mo\u017cliwa \u2014 sprawd\u017a wizualnie na podgl\u0105dzie.`\n                : `Important elements (text, logo) must be at least ${prod.margin} mm from the cut line. Automatic margin verification is not possible \u2014 check visually in the preview.`\n    });\n\n  } else {\n    \/\/ Baner \u2014 poka\u017c wykryty rozmiar\n    issues.push({t:'info',\n      title: PL ? `Wymiary: ${(vp.width\/PT).toFixed(0)}\u00d7${(vp.height\/PT).toFixed(0)} mm`\n                : `Dimensions: ${(vp.width\/PT).toFixed(0)}\u00d7${(vp.height\/PT).toFixed(0)} mm`,\n      desc:  PL ? 'Upewnij si\u0119, \u017ce rozmiar zgodny z zam\u00f3wieniem (skala 1:1).'\n                : 'Verify the size matches your order (scale 1:1).'\n    });\n    issues.push({t:'info',\n      title: PL ? `Margines bezpieczny: min. ${prod.margin} mm od kraw\u0119dzi`\n                : `Safe margin: min. ${prod.margin} mm from edge`,\n      desc:  PL ? `Wa\u017cne elementy powinny znajdowa\u0107 si\u0119 min. ${prod.margin} mm od kraw\u0119dzi. Sprawd\u017a wizualnie.`\n                : `Important elements must be at least ${prod.margin} mm from the edge. Check visually.`\n    });\n  }\n\n  \/\/ \u2500\u2500 2. Teksty zamienione na krzywe \u2500\u2500\n\n  if (anyText) {\n    issues.push({t:'err',\n      title: PL ? 'Teksty NIE zamienione na krzywe!' : 'Texts NOT converted to curves!',\n      desc:  PL ? 'Wykryto operatory rysowania tekstu (Tj\/TJ). Zamie\u0144 wszystkie teksty na krzywe w programie graficznym przed wys\u0142aniem do druku.'\n                : 'Text drawing operators (Tj\/TJ) detected. Convert all texts to curves in your graphics app before sending to print.'\n    });\n  } else {\n    issues.push({t:'ok',\n      title: PL ? 'Teksty zamienione na krzywe \u2713' : 'Texts converted to curves \u2713',\n      desc:  PL ? 'Nie wykryto operator\u00f3w rysowania tekstu. Teksty zamienione na krzywe.'\n                : 'No text drawing operators found. Texts converted to curves.'\n    });\n  }\n\n  \/\/ \u2500\u2500 3. Kolorystyka \u2500\u2500\n  const isMaskPage = pg.isMaskPage;\n  if (isMaskPage) {\n    \/\/ Strona maski \u2014 sprawd\u017a czy jest tylko K=100\n    const richBlackInfo = detectRichBlack(ops);\n    if (richBlackInfo.hasRichBlack) {\n      issues.push({t:'err',\n        title: PL ? 'Maska: wykryto kolor inny ni\u017c K=100%!' : 'Mask: color other than K=100% detected!',\n        desc:  PL ? `Strony masek uszlachetnie\u0144 musz\u0105 zawiera\u0107 wy\u0142\u0105cznie czarny K=100%. Wykryto: ${richBlackInfo.detail}.`\n                  : `Finishing mask pages must contain only K=100% black. Detected: ${richBlackInfo.detail}.`\n      });\n    } else {\n      issues.push({t:'ok',\n        title: PL ? 'Maska: tylko K=100% \u2713' : 'Mask: K=100% only \u2713',\n        desc:  PL ? 'Strona maski zawiera wy\u0142\u0105cznie czarny K=100% \u2014 prawid\u0142owo.'\n                  : 'Mask page contains only K=100% black \u2014 correct.'\n      });\n    }\n  } else {\n    \/\/ Zwyk\u0142a strona graficzna\n    if (!hasCMYK && hasRGB) {\n      issues.push({t:'err',\n        title: PL ? 'Kolorystyka RGB \u2014 wymagane CMYK!' : 'RGB colors \u2014 CMYK required!',\n        desc:  PL ? 'Plik zawiera przestrze\u0144 barw RGB bez CMYK. Przekonwertuj ca\u0142y dokument do CMYK przed wys\u0142aniem do druku.'\n                  : 'File uses RGB color space without CMYK. Convert the entire document to CMYK before sending to print.'\n      });\n    } else if (hasCMYK && hasRGB) {\n      issues.push({t:'warn',\n        title: PL ? 'Mieszana przestrze\u0144 barw (CMYK + RGB)' : 'Mixed color space (CMYK + RGB)',\n        desc:  PL ? 'Plik zawiera elementy CMYK i RGB. Elementy RGB mog\u0105 by\u0107 automatycznie skonwertowane podczas druku z r\u00f3\u017cnym rezultatem \u2014 zalecamy pe\u0142n\u0105 konwersj\u0119 do CMYK.'\n                  : 'File has both CMYK and RGB elements. RGB elements may be auto-converted during print with varying results \u2014 full CMYK conversion recommended.'\n      });\n    } else if (hasCMYK) {\n      \/\/ Sprawd\u017a rich black (tylko K=100 to dobre, mieszany czarny to problem)\n      const rbInfo = detectRichBlack(ops);\n      if (rbInfo.hasRichBlack) {\n        issues.push({t:'warn',\n          title: PL ? 'CMYK \u2713 \u2014 uwaga: \"bogaty czarny\" (rich black)' : 'CMYK \u2713 \u2014 note: rich black detected',\n          desc:  PL ? 'Wykryto czarny sk\u0142adaj\u0105cy si\u0119 z mieszanki CMYK (np. C:100 M:100 Y:100 K:100). W druku offsetowym mo\u017ce powodowa\u0107 problemy z pasowaniem. Zalecamy u\u017cycie K=100% dla czarnych tekst\u00f3w i linii.'\n                    : 'Black composed of mixed CMYK values detected (e.g. C:100 M:100 Y:100 K:100). In offset printing this can cause misregistration. Use K=100% for black text and lines.'\n        });\n      } else {\n        issues.push({t:'ok',\n          title: PL ? 'Kolorystyka CMYK \u2713' : 'CMYK color space \u2713',\n          desc:  PL ? 'Wykryto przestrze\u0144 CMYK \u2014 plik gotowy do druku pod k\u0105tem kolorystyki.'\n                    : 'CMYK color space detected \u2014 file is print-ready in terms of color.'\n        });\n      }\n    } else if (hasGray && !hasRGB) {\n      issues.push({t:'ok',\n        title: PL ? 'Skala szaro\u015bci \u2713' : 'Grayscale \u2713',\n        desc:  PL ? 'Wykryto tylko skal\u0119 szaro\u015bci \u2014 brak element\u00f3w kolorowych. Plik nadaje si\u0119 do druku.'\n                  : 'Grayscale only \u2014 no color elements found. Suitable for printing.'\n      });\n    } else {\n      issues.push({t:'info',\n        title: PL ? 'Przestrze\u0144 barw: brak deklaracji' : 'Color space: no declaration',\n        desc:  PL ? 'Nie wykryto deklaracji przestrzeni barw. Plik prawdopodobnie czysto wektorowy \u2014 upewnij si\u0119, \u017ce w programie graficznym ustawiona jest przestrze\u0144 CMYK.'\n                  : 'No color space declaration found. Likely pure vector \u2014 ensure CMYK mode is set in your graphics app.'\n      });\n    }\n  }\n\n  \/\/ \u2500\u2500 4. DPI \u2500\u2500\n  \/\/ Metoda: raw scan daje wymiary obraz\u00f3w (w pikselach), CTM ze stream\u00f3w daje skal\u0119 w pt.\n  \/\/ DPI = pixelW * 72 \/ renderWidthPt\n  \/\/ Dla ka\u017cdego obrazu szukamy najbli\u017cszego pasuj\u0105cego CTM transform() przed paintImageXObject\n  let lowestDPI = Infinity, lowestDPINote = '';\n\n  \/\/ Metoda 1: CTM + paintImageXObject \u2014 \u015bled\u017a macierz transformacji\n  {\n    const fnArr = ops.fnArray, argsArr = ops.argsArray;\n    \/\/ Stos CTM (q\/Q push\/pop)\n    let ctmStack = [[1,0,0,1,0,0]];\n    let curCTM   = [1,0,0,1,0,0];\n    const mulCTM = (a, b) => [\n      a[0]*b[0]+a[2]*b[1], a[1]*b[0]+a[3]*b[1],\n      a[0]*b[2]+a[2]*b[3], a[1]*b[2]+a[3]*b[3],\n      a[0]*b[4]+a[2]*b[5]+a[4], a[1]*b[4]+a[3]*b[5]+a[5]\n    ];\n    for (let i = 0; i < fnArr.length; i++) {\n      const fn = fnArr[i];\n      if (fn === OPS.save)      { ctmStack.push([...curCTM]); }\n      else if (fn === OPS.restore && ctmStack.length > 1) { curCTM = ctmStack.pop(); }\n      else if (fn === OPS.transform) { curCTM = mulCTM(curCTM, argsArr[i]); }\n      else if (fn === OPS.paintImageXObject || fn === OPS.paintImageMaskXObject) {\n        \/\/ Skala: sx = d\u0142ugo\u015b\u0107 wektora [a,b], sy = d\u0142ugo\u015b\u0107 [c,d]\n        const sx = Math.sqrt(curCTM[0]*curCTM[0] + curCTM[1]*curCTM[1]);\n        const sy = Math.sqrt(curCTM[2]*curCTM[2] + curCTM[3]*curCTM[3]);\n        if (sx > 0 && sy > 0) {\n          \/\/ Znajd\u017a pasuj\u0105cy obraz po wymiarach \u2014 CTM [sx,sy] to rozmiar obrazu w pt\n          \/\/ szukamy obrazu kt\u00f3rego proporcje pasuj\u0105 do CTM\n          for (const img of allImgs) {\n            const dpiX = img.w * 72 \/ Math.max(sx, 1);\n            const dpiY = img.h * 72 \/ Math.max(sy, 1);\n            const dpi  = Math.min(dpiX, dpiY);\n            if (dpi > 10 && dpi < 3000 && dpi < lowestDPI) {\n              lowestDPI = dpi;\n            }\n          }\n        }\n      }\n    }\n  }\n\n  \/\/ Metoda 2: fallback \u2014 bez CTM, zak\u0142adamy obraz zajmuje ca\u0142\u0105 stron\u0119\n  \/\/ Daje szacunek z g\u00f3ry (mo\u017ce by\u0107 zawy\u017cony je\u015bli obraz jest mniejszy ni\u017c strona)\n  if (lowestDPI === Infinity && allImgs.length > 0) {\n    const pageW_in = vp.width  \/ 72;\n    const pageH_in = vp.height \/ 72;\n    for (const img of allImgs) {\n      const estDPI = Math.min(img.w \/ pageW_in, img.h \/ pageH_in);\n      if (estDPI > 10 && estDPI < 3000 && estDPI < lowestDPI) {\n        lowestDPI = estDPI;\n        lowestDPINote = PL ? ' (szacowane)' : ' (estimated)';\n      }\n    }\n  }\n\n  if (lowestDPI === Infinity) {\n    issues.push({t:'info',\n      title: PL ? 'Brak map bitowych \u2014 plik wektorowy' : 'No bitmaps \u2014 vector file',\n      desc:  PL ? 'Nie wykryto obraz\u00f3w rastrowych. Je\u015bli projekt zawiera zdj\u0119cia, sprawd\u017a DPI w programie graficznym.'\n                : 'No raster images detected. If the design contains photos, verify DPI in your graphics app.'\n    });\n  } else if (lowestDPI < 200) {\n    issues.push({t:'err',\n      title: PL ? `Zbyt niska rozdzielczo\u015b\u0107: ~${Math.round(lowestDPI)} DPI!${lowestDPINote}`\n                : `Resolution too low: ~${Math.round(lowestDPI)} DPI!${lowestDPINote}`,\n      desc:  PL ? 'Wymagane minimum 300 DPI. Obraz b\u0119dzie rozmazany po wydruku.'\n                : 'Minimum 300 DPI required. Image will appear blurry when printed.'\n    });\n  } else if (lowestDPI < 300) {\n    issues.push({t:'warn',\n      title: PL ? `Niska rozdzielczo\u015b\u0107: ~${Math.round(lowestDPI)} DPI${lowestDPINote}`\n                : `Low resolution: ~${Math.round(lowestDPI)} DPI${lowestDPINote}`,\n      desc:  PL ? 'Zalecane minimum to 300 DPI. Jako\u015b\u0107 wydruku mo\u017ce by\u0107 obni\u017cona.'\n                : 'Recommended minimum is 300 DPI. Print quality may be reduced.'\n    });\n  } else {\n    issues.push({t:'ok',\n      title: PL ? `Rozdzielczo\u015b\u0107: min. ~${Math.round(lowestDPI)} DPI \u2713${lowestDPINote}`\n                : `Resolution: min. ~${Math.round(lowestDPI)} DPI \u2713${lowestDPINote}`,\n      desc:  PL ? 'Wykryte mapy bitowe maj\u0105 wystarczaj\u0105c\u0105 rozdzielczo\u015b\u0107 do druku.'\n                : 'Detected bitmaps have sufficient resolution for printing.'\n    });\n  }\n\n  \/\/ \u2500\u2500 5. Transparentno\u015b\u0107 \u2500\u2500\n  const hasTransparency = checkTransparency(buf);\n  if (hasTransparency) {\n    issues.push({t:'warn',\n      title: PL ? 'Wykryto transparentno\u015b\u0107' : 'Transparency detected',\n      desc:  PL ? 'Plik zawiera obszary transparentne. Upewnij si\u0119, \u017ce transparentno\u015b\u0107 jest sp\u0142aszczona lub prawid\u0142owo obs\u0142ugiwana przez RIP drukarni.'\n                : 'File contains transparent areas. Ensure transparency is flattened or properly handled by the printer\\'s RIP.'\n    });\n  }\n}\n\n\/* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n   SPRAWDZENIE STRONY RASTER (JPG\/TIFF)\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 *\/\nasync function ivCheckRasterPage(pg, prod, issues) {\n  \/\/ Strony masek uszlachetnie\u0144 (z\u0142ocenie\/UV) MUSZ\u0104 by\u0107 PDF \u2014 nie rastrami\n  if (pg.isMaskPage) {\n    issues.push({t:'err',\n      title: PL ? 'Maska uszlachetnienia musi by\u0107 plikiem PDF!'\n                : 'Finishing mask must be a PDF file!',\n      desc:  PL ? 'Strony masek z\u0142ocenia i lakieru UV musz\u0105 by\u0107 przygotowane jako wektorowy PDF z tekstem zamienionym na krzywe. Plik rastrowy (JPG\/PNG\/TIFF) nie jest akceptowany dla masek.'\n                : 'Gold foil and UV varnish mask pages must be prepared as vector PDF with texts converted to curves. Raster files (JPG\/PNG\/TIFF) are not accepted for masks.'\n    });\n    return; \/\/ nie sprawdzaj dalej \u2014 plik jest fundamentalnie niepoprawny\n  }\n\n  const view = new DataView(pg.buf);\n  let detectedDPI = null, colorSpace = null;\n  if (pg.fileType === 'jpeg') {\n    ({ dpi: detectedDPI, colorSpace } = parseJpegMeta(view));\n  } else {\n    ({ dpi: detectedDPI, colorSpace } = parseTiffMeta(view));\n  }\n\n  \/\/ Info o formacie\n  issues.push({t:'info',\n    title: PL ? 'Format rastrowy (JPG\/PNG\/TIFF)' : 'Raster format (JPG\/PNG\/TIFF)',\n    desc:  PL ? 'Pliki rastrowe s\u0105 akceptowane. Dla najlepszej jako\u015bci zalecamy format PDF wektorowy.'\n              : 'Raster files are accepted. For best quality, vector PDF format is recommended.'\n  });\n\n  \/\/ DPI\n  if (detectedDPI === null) {\n    issues.push({t:'warn',\n      title: PL ? 'Rozdzielczo\u015b\u0107: brak metadanych DPI' : 'Resolution: no DPI metadata',\n      desc:  PL ? 'Plik nie zawiera informacji o DPI. Sprawd\u017a rozdzielczo\u015b\u0107 w programie graficznym \u2014 wymagane minimum 300 DPI.'\n                : 'No DPI metadata in file. Check resolution in your graphics software \u2014 minimum 300 DPI required.'\n    });\n  } else if (detectedDPI < 200) {\n    issues.push({t:'err',\n      title: PL ? `Zbyt niska rozdzielczo\u015b\u0107: ${Math.round(detectedDPI)} DPI!` : `Resolution too low: ${Math.round(detectedDPI)} DPI!`,\n      desc:  PL ? 'Wymagane minimum 300 DPI. Obraz b\u0119dzie rozmazany po wydruku.' : 'Minimum 300 DPI required. Image will appear blurry when printed.'\n    });\n  } else if (detectedDPI < 300) {\n    issues.push({t:'warn',\n      title: PL ? `Niska rozdzielczo\u015b\u0107: ${Math.round(detectedDPI)} DPI` : `Low resolution: ${Math.round(detectedDPI)} DPI`,\n      desc:  PL ? 'Zalecane minimum to 300 DPI.' : 'Recommended minimum is 300 DPI.'\n    });\n  } else {\n    issues.push({t:'ok',\n      title: PL ? `Rozdzielczo\u015b\u0107: ${Math.round(detectedDPI)} DPI \u2713` : `Resolution: ${Math.round(detectedDPI)} DPI \u2713`,\n      desc:  PL ? 'Rozdzielczo\u015b\u0107 wystarczaj\u0105ca do druku.' : 'Sufficient resolution for printing.'\n    });\n  }\n\n  \/\/ Kolorystyka\n  if (colorSpace === 'cmyk') {\n    issues.push({t:'ok', title: PL ? 'Kolorystyka CMYK \u2713' : 'CMYK \u2713',\n      desc: PL ? 'Plik jest w przestrzeni CMYK.' : 'File is in CMYK color space.'});\n  } else if (colorSpace === 'rgb') {\n    issues.push({t:'err', title: PL ? 'Kolorystyka RGB \u2014 wymagane CMYK!' : 'RGB \u2014 CMYK required!',\n      desc: PL ? 'Plik jest w trybie RGB. Zapisz jako CMYK przed wys\u0142aniem do druku.' : 'File is RGB. Save as CMYK before sending to print.'});\n  } else if (colorSpace === 'gray') {\n    issues.push({t:'ok', title: PL ? 'Skala szaro\u015bci \u2713' : 'Grayscale \u2713',\n      desc: PL ? 'Plik jest w skali szaro\u015bci.' : 'File is in grayscale.'});\n  } else {\n    issues.push({t:'info', title: PL ? 'Przestrze\u0144 barw: nieznana' : 'Color space: unknown',\n      desc: PL ? 'Nie uda\u0142o si\u0119 wykry\u0107 przestrzeni barw. Sprawd\u017a w programie graficznym.' : 'Could not detect color space.'});\n  }\n  issues.push({t:'info',\n    title: PL ? 'Teksty na krzywe: nie dotyczy' : 'Text as curves: N\/A',\n    desc:  PL ? 'Pliki rastrowe s\u0105 sp\u0142aszczone \u2014 zamiana tekst\u00f3w na krzywe nie jest wymagana.'\n              : 'Raster files are flattened \u2014 text-to-curves conversion not required.'\n  });\n}\n\n\/* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n   PARSERY METADANYCH\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 *\/\n\n\/\/ JPEG\nfunction parseJpegMeta(view) {\n  if (view.byteLength < 4 || view.getUint16(0) !== 0xFFD8) return {dpi:null, colorSpace:null};\n  let pos = 2, dpi = null, colorSpace = null;\n  while (pos < view.byteLength - 3) {\n    if (view.getUint8(pos) !== 0xFF) break;\n    const marker = view.getUint8(pos + 1);\n    if (marker === 0xD9 || marker === 0xDA) break;\n    const segLen = view.getUint16(pos + 2, false);\n    if (marker === 0xE0 && segLen >= 14 && dpi === null) {\n      const units = view.getUint8(pos + 11);\n      const xd = view.getUint16(pos + 12, false), yd = view.getUint16(pos + 14, false);\n      if (units === 1 && xd > 0) dpi = Math.min(xd, yd);\n      else if (units === 2 && xd > 0) dpi = Math.min(xd, yd) * 2.54;\n    } else if (marker === 0xEE && segLen >= 14) {\n      const cs = view.getUint8(pos + 15);\n      colorSpace = (cs === 2 || cs === 3) ? 'cmyk' : 'rgb';\n    } else if ((marker === 0xC0 || marker === 0xC1 || marker === 0xC2) && segLen >= 11 && colorSpace === null) {\n      const nComp = view.getUint8(pos + 9);\n      if (nComp === 4) colorSpace = 'cmyk';\n      else if (nComp === 3) colorSpace = 'rgb';\n      else if (nComp === 1) colorSpace = 'gray';\n    }\n    pos += 2 + segLen;\n  }\n  return {dpi, colorSpace};\n}\n\n\/\/ TIFF\nfunction parseTiffMeta(view) {\n  if (view.byteLength < 8) return {dpi:null, colorSpace:null};\n  const bo = view.getUint16(0, false);\n  if (bo !== 0x4949 && bo !== 0x4D4D) return {dpi:null, colorSpace:null};\n  const le = bo === 0x4949;\n  if (view.getUint16(2, le) !== 42) return {dpi:null, colorSpace:null};\n  const ifdOff = view.getUint32(4, le);\n  if (ifdOff + 2 > view.byteLength) return {dpi:null, colorSpace:null};\n  const nEntries = view.getUint16(ifdOff, le);\n  let xRes = null, yRes = null, resUnit = 2, photoInterp = null;\n  for (let i = 0; i < nEntries; i++) {\n    const eOff = ifdOff + 2 + i * 12;\n    if (eOff + 12 > view.byteLength) break;\n    const tag = view.getUint16(eOff, le);\n    if (tag === 282 || tag === 283) {\n      const valOff = view.getUint32(eOff + 8, le);\n      if (valOff + 8 <= view.byteLength) {\n        const num = view.getUint32(valOff, le), den = view.getUint32(valOff + 4, le);\n        if (den) { if (tag === 282) xRes = num\/den; else yRes = num\/den; }\n      }\n    } else if (tag === 296) { resUnit = view.getUint16(eOff + 8, le); }\n      else if (tag === 262) { photoInterp = view.getUint16(eOff + 8, le); }\n  }\n  let dpi = null;\n  if (xRes !== null || yRes !== null) {\n    dpi = Math.min(xRes ?? yRes, yRes ?? xRes);\n    if (resUnit === 3) dpi *= 2.54;\n  }\n  let colorSpace = null;\n  if      (photoInterp === 5) colorSpace = 'cmyk';\n  else if (photoInterp === 2) colorSpace = 'rgb';\n  else if (photoInterp === 0 || photoInterp === 1) colorSpace = 'gray';\n  return {dpi, colorSpace};\n}\n\n\/* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n   RAW PDF SCANNERS\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 *\/\nfunction scanPDFColorSpaces(buf) {\n  const str = new TextDecoder('latin1').decode(new Uint8Array(buf));\n\n  \/\/ \u2500\u2500 1. Operatory kolor\u00f3w w streamach \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n  \/\/ Operator 'k'\/'K' = CMYK (implicit \/DeviceCMYK) \u2014 u\u017cywany przez Corel, Illustrator\n  \/\/ Operator 'rg'\/'RG' = RGB, 'g'\/'G' = Gray\n  \/\/ Skanujemy tylko potencjalne streamy (szukamy charakterystycznych wzorc\u00f3w)\n  \/\/ Format: C M Y K k  lub  C M Y K K  (4 liczby zmiennoprzecinkowe przed liter\u0105)\n  const kRe  = \/[\\d.]+\\s+[\\d.]+\\s+[\\d.]+\\s+[\\d.]+\\s+[kK]\\b\/;\n  const rgRe = \/[\\d.]+\\s+[\\d.]+\\s+[\\d.]+\\s+[rR][gG]\\b\/;\n  const gRe  = \/[\\d.]+\\s+[gG]\\b\/;\n  const hasOp_k  = kRe.test(str);\n  const hasOp_rg = rgRe.test(str);\n  const hasOp_g  = gRe.test(str);\n\n  \/\/ \u2500\u2500 2. Deklaracje przestrzeni barw w s\u0142ownikach (obrazy, zasoby strony) \u2500\u2500\n  const hasCMYK_dev = str.includes('\/DeviceCMYK');\n\n  \/\/ \/DeviceRGB: filtrujemy fa\u0142szywe trafienia \u2014 w PDF\/X plik mo\u017ce zawiera\u0107\n  \/\/ \/DeviceRGB w OutputIntent lub metadanych nawet gdy druk jest CMYK.\n  \/\/ Liczymy wyst\u0105pienia POZA kontekstem OutputIntent\/Metadata.\n  let hasRGB_dev = false;\n  {\n    const re = \/\\\/DeviceRGB\/g; let m;\n    while ((m = re.exec(str)) !== null) {\n      \/\/ Sprawd\u017a kontekst \u2014 je\u015bli nie jeste\u015bmy w OutputIntent\/Metadata, to prawdziwy RGB\n      const ctx = str.slice(Math.max(0, m.index - 200), m.index + 20);\n      if (!ctx.includes('\/OutputIntent') && !ctx.includes('\/Metadata') && !ctx.includes('DestOutputProfile')) {\n        hasRGB_dev = true; break;\n      }\n    }\n  }\n\n  const hasGray_dev = str.includes('\/DeviceGray');\n  const hasCalRGB   = str.includes('\/CalRGB');\n  const hasCalGray  = str.includes('\/CalGray');\n\n  \/\/ \u2500\u2500 3. Profile ICC \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n  let hasICC_CMYK = false, hasICC_RGB = false, hasICC_Gray = false;\n  const iccRe = \/\\\/ICCBased\\b\/g; let iccM;\n  while ((iccM = iccRe.exec(str)) !== null) {\n    const r = str.slice(iccM.index, Math.min(iccM.index + 400, str.length));\n    const n = r.match(\/\\\/N\\s+(\\d+)\/);\n    if (n) {\n      const ch = parseInt(n[1]);\n      if (ch === 4) hasICC_CMYK = true;\n      if (ch === 3) hasICC_RGB  = true;\n      if (ch === 1) hasICC_Gray = true;\n    }\n  }\n\n  \/\/ \u2500\u2500 4. Scal wyniki \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n  \/\/ CMYK: operator k\/K LUB \/DeviceCMYK LUB ICCBased N=4\n  const hasCMYK = hasCMYK_dev || hasICC_CMYK || hasOp_k;\n  \/\/ RGB: \/DeviceRGB (poza metadanymi) LUB operator rg\/RG LUB CalRGB\/ICCBased N=3\n  \/\/ Ale ICCBased N=3 mo\u017ce by\u0107 profilem CMYK blending space \u2014 odrzucamy je\u015bli mamy CMYK\n  const hasRGB  = hasRGB_dev || hasOp_rg || hasCalRGB || (hasICC_RGB && !hasCMYK_dev && !hasOp_k);\n  const hasGray = hasGray_dev || hasCalGray || hasICC_Gray || hasOp_g;\n\n  return { hasCMYK, hasRGB, hasGray };\n}\n\nfunction scanPDFImages(buf) {\n  const str = new TextDecoder('latin1').decode(new Uint8Array(buf));\n  const imgs = [], seen = new Set();\n  const re = \/\\\/Subtype\\s*\\\/Image\\b\/g; let m;\n  while ((m = re.exec(str)) !== null) {\n    const region = str.slice(Math.max(0, m.index - 600), m.index + 1200);\n    const wm = region.match(\/\\\/Width\\s+(\\d+)\/), hm = region.match(\/\\\/Height\\s+(\\d+)\/);\n    if (wm && hm) {\n      const w = parseInt(wm[1]), h = parseInt(hm[1]);\n      const key = `${w}x${h}`;\n      if (w > 15 && h > 15 && !seen.has(key)) { seen.add(key); imgs.push({w, h}); }\n    }\n  }\n  return imgs;\n}\n\nfunction scanJpegInPDF(buf) {\n  const bytes  = new Uint8Array(buf);\n  const result = { found: false, hasCMYK: false, hasRGB: false, hasGray: false, dims: [] };\n  const seen   = new Set();\n  let   i      = 0;\n  while (i < bytes.length - 3) {\n    if (!(bytes[i] === 0xFF && bytes[i+1] === 0xD8 && bytes[i+2] === 0xFF)) { i++; continue; }\n    result.found = true;\n    let pos = i + 2, sofFound = false, w = 0, h = 0, comps = 0, nextI = i + 1;\n    markerLoop: while (pos < bytes.length - 1) {\n      if (bytes[pos] !== 0xFF) break;\n      while (pos < bytes.length && bytes[pos] === 0xFF) pos++;\n      if (pos >= bytes.length) break;\n      const marker = bytes[pos++];\n      if (marker === 0xD9 || marker === 0xDA) { nextI = pos; break; }\n      if (marker === 0xD8 || (marker >= 0xD0 && marker <= 0xD7) || marker === 0x00) continue;\n      if (pos + 1 >= bytes.length) break;\n      const segLen = (bytes[pos] << 8) | bytes[pos+1];\n      if (segLen < 2 || pos + segLen > bytes.length) break;\n      const segData = pos + 2;\n      const isSOF = (marker >= 0xC0 && marker <= 0xC3)||(marker >= 0xC5 && marker <= 0xC7)||(marker >= 0xC9 && marker <= 0xCB)||(marker >= 0xCD && marker <= 0xCF);\n      if (isSOF && segLen >= 11) {\n        h = (bytes[segData+1] << 8)|bytes[segData+2];\n        w = (bytes[segData+3] << 8)|bytes[segData+4];\n        comps = bytes[segData+5]; sofFound = true;\n      }\n      pos += segLen;\n    }\n    if (sofFound && w >= 16 && h >= 16) {\n      const key = `${w}x${h}`;\n      if (!seen.has(key)) { seen.add(key); result.dims.push({w, h}); }\n      if      (comps === 1) result.hasGray = true;\n      else if (comps === 3) result.hasRGB  = true;\n      else if (comps === 4) result.hasCMYK = true;\n    }\n    i = nextI;\n  }\n  return result;\n}\n\n\/* Parsowanie TrimBox\/MediaBox\/BleedBox z surowych bajt\u00f3w PDF *\/\nfunction parsePDFBoxes(buf, pageNum) {\n  const str = new TextDecoder('latin1').decode(new Uint8Array(buf));\n  const result = { mediaBox: null, trimBox: null, bleedBox: null };\n\n  \/\/ Obs\u0142uga liczb ze znakiem (ujemne wsp\u00f3\u0142rz\u0119dne = spady \"poza\" stron\u0105)\n  \/\/ Format: \/TrimBox [0.0000 0.0000 255.1181 141.7323]  lub  \/MediaBox [ -5.6693 -5.6693 ... ]\n  const NUM = '[-+]?[\\\\d]*\\\\.?[\\\\d]+';\n  const parseBox = (name) => {\n    const re = new RegExp('\/' + name + '\\\\s*\\\\[\\\\s*(' + NUM + ')\\\\s+(' + NUM + ')\\\\s+(' + NUM + ')\\\\s+(' + NUM + ')\\\\s*\\\\]', 'g');\n    let m, last = null;\n    while ((m = re.exec(str)) !== null) {\n      const x1 = parseFloat(m[1]), y1 = parseFloat(m[2]);\n      const x2 = parseFloat(m[3]), y2 = parseFloat(m[4]);\n      last = { x1, y1, x2, y2, w: x2 - x1, h: y2 - y1 };\n    }\n    return last;\n  };\n\n  result.mediaBox = parseBox('MediaBox');\n  result.trimBox  = parseBox('TrimBox');\n  result.bleedBox = parseBox('BleedBox');\n\n  \/\/ Je\u015bli nie ma TrimBox ale jest BleedBox \u2014 u\u017cyj jako przybli\u017cenia\n  if (!result.trimBox && result.bleedBox) result.trimBox = result.bleedBox;\n\n  return result;\n}\n\n\/* Wykrywanie rich black (CMYK z wieloma kana\u0142ami jako czarny) *\/\nfunction detectRichBlack(ops) {\n  const OPS = pdfjsLib.OPS;\n  const argsArr = ops.argsArray, fnArr = ops.fnArray;\n  let hasRichBlack = false, detail = '';\n  for (let i = 0; i < fnArr.length; i++) {\n    if (fnArr[i] === OPS.setFillCMYKColor || fnArr[i] === OPS.setStrokeCMYKColor) {\n      const [c, m, y, k] = argsArr[i];\n      if (k > 0.8 && (c > 0.3 || m > 0.3 || y > 0.3)) {\n        hasRichBlack = true;\n        detail = `C:${Math.round(c*100)} M:${Math.round(m*100)} Y:${Math.round(y*100)} K:${Math.round(k*100)}`;\n        break;\n      }\n    }\n  }\n  return { hasRichBlack, detail };\n}\n\n\/* Wykrywanie transparentno\u015bci *\/\nfunction checkTransparency(buf) {\n  const str = new TextDecoder('latin1').decode(new Uint8Array(buf));\n  return str.includes('\/Transparency') || str.includes('\/ca ') || str.includes('\/CA ') || str.includes('\/SMask');\n}\n\n\/* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n   RENDER HELPERS\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 *\/\nasync function ivRenderPage(doc, num, tw) {\n  const pg = await doc.getPage(num);\n  const sc = tw \/ pg.getViewport({scale:1}).width;\n  const vp = pg.getViewport({scale: sc});\n  const c  = document.createElement('canvas');\n  c.width = vp.width; c.height = vp.height;\n  await pg.render({canvasContext: c.getContext('2d'), viewport: vp}).promise;\n  return c;\n}\n\nasync function ivRenderRaster(file) {\n  return new Promise(res => {\n    const reader = new FileReader();\n    reader.onload = e => {\n      const img = new Image();\n      img.onload = () => {\n        const c = document.createElement('canvas');\n        c.width  = img.naturalWidth;\n        c.height = img.naturalHeight;\n        c.getContext('2d').drawImage(img, 0, 0);\n        res(c);\n      };\n      img.onerror = () => res(null);\n      img.src = e.target.result;\n    };\n    reader.onerror = () => res(null);\n    reader.readAsDataURL(file);\n  });\n}\n\n\/* Przycina canvas do obszaru TrimBox (po ci\u0119ciu) dla podgl\u0105du 3D.\n   Je\u015bli brak TrimBox \u2014 oblicza na podstawie wymiar\u00f3w produktu i spad\u00f3w. *\/\nfunction ivCropToTrim(srcCanvas, prod, boxes) {\n  if (!srcCanvas) return srcCanvas;\n\n  const srcW = srcCanvas.width;\n  const srcH = srcCanvas.height;\n\n  \/\/ Oblicz stosunek px\/mm na podstawie rozmiaru canvasu i rozmiaru strony PDF\n  \/\/ srcCanvas odpowiada ca\u0142ej stronie (MediaBox) w pikselach\n  \/\/ Rozmiar strony w mm = prod.w + prod.bl*2 (zak\u0142adamy prawid\u0142owy plik ze spadami)\n  let cropX = 0, cropY = 0, cropW = srcW, cropH = srcH;\n\n  if (prod.custom) {\n    \/\/ Banery \u2014 nie przycinaj, brak znanych wymiar\u00f3w po ci\u0119ciu\n    return srcCanvas;\n  }\n\n  if (boxes && boxes.trimBox && boxes.mediaBox) {\n    \/\/ Mamy TrimBox \u2014 u\u017cyj go do precyzyjnego przyci\u0119cia\n    const mb = boxes.mediaBox;\n    const tb = boxes.trimBox;\n    const mbW = mb.x2 - mb.x1;\n    const mbH = mb.y2 - mb.y1;\n    if (mbW > 0 && mbH > 0) {\n      const scaleX = srcW \/ mbW;\n      const scaleY = srcH \/ mbH;\n      \/\/ TrimBox w uk\u0142adzie PDF (Y od do\u0142u) \u2192 canvas (Y od g\u00f3ry)\n      cropX = (tb.x1 - mb.x1) * scaleX;\n      cropY = (mb.y2 - tb.y2) * scaleY;  \/\/ odwr\u00f3cone Y\n      cropW = (tb.x2 - tb.x1) * scaleX;\n      cropH = (tb.y2 - tb.y1) * scaleY;\n    }\n  } else if (prod.bl > 0) {\n    \/\/ Brak TrimBox \u2014 wytnij spady na podstawie wymiar\u00f3w produktu\n    \/\/ srcCanvas odpowiada (prod.w + prod.bl*2) \u00d7 (prod.h + prod.bl*2) mm\n    const totalW_mm = prod.w + prod.bl * 2;\n    const totalH_mm = prod.h + prod.bl * 2;\n    const pxPerMmX = srcW \/ totalW_mm;\n    const pxPerMmY = srcH \/ totalH_mm;\n    cropX = prod.bl * pxPerMmX;\n    cropY = prod.bl * pxPerMmY;\n    cropW = prod.w * pxPerMmX;\n    cropH = prod.h * pxPerMmY;\n  } else {\n    return srcCanvas; \/\/ brak spad\u00f3w \u2014 nie przycinaj\n  }\n\n  \/\/ Zabezpieczenie przed ujemnymi\/zerowymi warto\u015bciami\n  cropX = Math.max(0, Math.round(cropX));\n  cropY = Math.max(0, Math.round(cropY));\n  cropW = Math.min(srcW - cropX, Math.round(cropW));\n  cropH = Math.min(srcH - cropY, Math.round(cropH));\n  if (cropW <= 0 || cropH <= 0) return srcCanvas;\n\n  const dst = document.createElement('canvas');\n  dst.width = cropW; dst.height = cropH;\n  dst.getContext('2d').drawImage(srcCanvas, cropX, cropY, cropW, cropH, 0, 0, cropW, cropH);\n  return dst;\n}\n\n\/* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n   PODGL\u0104D 2D Z SIATK\u0104\n   Rysuje obraz strony + linie ci\u0119cia (\u017c\u00f3\u0142te), spad\u00f3w (czerwone), marginesu (niebieskie)\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 *\/\nfunction ivDraw2DWhenReady(prod, pageIdx) {\n  const wrap = document.getElementById('iv-preview2d-wrap');\n  \/\/ Je\u015bli wrap ma ju\u017c wymiary \u2014 rysuj od razu\n  if (wrap.clientWidth > 50) {\n    ivDraw2D(prod, pageIdx);\n    return;\n  }\n  \/\/ Poczekaj a\u017c element b\u0119dzie widoczny i layoutowany\n  let tries = 0;\n  const poll = () => {\n    tries++;\n    if (wrap.clientWidth > 50 || tries > 30) {\n      ivDraw2D(prod, pageIdx);\n    } else {\n      requestAnimationFrame(poll);\n    }\n  };\n  requestAnimationFrame(poll);\n}\n\nfunction ivDraw2D(prod, pageIdx) {\n  S.preview2dPage = pageIdx;\n  const canvas = document.getElementById('iv-canvas2d');\n  const ctx    = canvas.getContext('2d');\n  const src    = S.rendered[pageIdx];\n  if (!src) return;\n\n  const wrap = document.getElementById('iv-preview2d-wrap');\n  const maxW = wrap.clientWidth - 32;\n  const maxH = 400;\n\n  \/\/ Wymiary w pikselach renderowanego canvasu\n  const srcW = src.width, srcH = src.height;\n  const scale = Math.min(maxW \/ srcW, maxH \/ srcH, 1);\n  const dispW = Math.round(srcW * scale);\n  const dispH = Math.round(srcH * scale);\n\n  canvas.width  = dispW;\n  canvas.height = dispH;\n\n  \/\/ Rysuj obraz\n  ctx.drawImage(src, 0, 0, dispW, dispH);\n\n  if (prod.custom) return; \/\/ banery \u2014 brak znanych wymiar\u00f3w, nie rysuj siatki\n\n  \/\/ Oblicz pozycje linii w pikselach canvasu\n  \/\/ srcW odpowiada (prod.w + prod.bl*2) mm ze spadem\n  const totalW_mm = prod.w + prod.bl * 2;\n  const totalH_mm = prod.h + prod.bl * 2;\n  const mmToPx = (mm, axis) => axis === 'x'\n    ? mm \/ totalW_mm * dispW\n    : mm \/ totalH_mm * dispH;\n\n  const bl  = prod.bl;   \/\/ spady [mm]\n  const mg  = prod.margin || 3; \/\/ margines bezpieczny [mm]\n\n  \/\/ Linia ci\u0119cia (\u017c\u00f3\u0142ta) \u2014 wewn\u0119trzna kraw\u0119d\u017a spad\u00f3w\n  const cutX1 = mmToPx(bl, 'x');\n  const cutY1 = mmToPx(bl, 'y');\n  const cutX2 = dispW - mmToPx(bl, 'x');\n  const cutY2 = dispH - mmToPx(bl, 'y');\n\n  \/\/ Margines bezpieczny (niebieski) \u2014 wewn\u0105trz linii ci\u0119cia\n  const safX1 = cutX1 + mmToPx(mg, 'x');\n  const safY1 = cutY1 + mmToPx(mg, 'y');\n  const safX2 = cutX2 - mmToPx(mg, 'x');\n  const safY2 = cutY2 - mmToPx(mg, 'y');\n\n  const drawRect = (x1, y1, x2, y2, color, dash) => {\n    ctx.save();\n    ctx.strokeStyle = color;\n    ctx.lineWidth   = 1.5;\n    ctx.setLineDash(dash || []);\n    ctx.strokeRect(x1 + 0.5, y1 + 0.5, x2 - x1, y2 - y1);\n    ctx.restore();\n  };\n\n  \/\/ Linia spad\u00f3w (czerwona, ci\u0105g\u0142a) \u2014 kraw\u0119d\u017a dokumentu = kraw\u0119d\u017a canvas\n  if (bl > 0) {\n    drawRect(0, 0, dispW - 1, dispH - 1, 'rgba(255,59,48,0.85)');\n    \/\/ Linia ci\u0119cia (\u017c\u00f3\u0142ta, ci\u0105g\u0142a)\n    drawRect(cutX1, cutY1, cutX2, cutY2, 'rgba(254,193,0,0.9)');\n  }\n  \/\/ Margines bezpieczny (niebieski, przerywany)\n  drawRect(safX1, safY1, safX2, safY2, 'rgba(0,122,255,0.8)', [4, 3]);\n}\n\nfunction ivBuildPreviewTabs(prod, pages) {\n  const tabs = document.getElementById('iv-preview-tabs');\n  tabs.innerHTML = '';\n  if (pages.length <= 1) { tabs.style.display = 'none'; return; }\n  tabs.style.display = 'flex';\n  const labels = PL ? prod.pl_pg : prod.en_pg;\n  pages.forEach((pg, idx) => {\n    const btn = document.createElement('button');\n    btn.className = 'iv-preview-tab' + (idx === 0 ? ' active' : '');\n    btn.textContent = labels[idx] || (PL ? `Strona ${idx+1}` : `Page ${idx+1}`);\n    btn.onclick = () => {\n      document.querySelectorAll('.iv-preview-tab').forEach((b,i) => b.classList.toggle('active', i===idx));\n      ivDraw2D(prod, idx);\n      ivSwitchPage(idx, prod);\n    };\n    tabs.appendChild(btn);\n  });\n}\n\n\/* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n   WYNIKI UI\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 *\/\nfunction ivShowResults(issues, p) {\n  const counts = {ok:0, warn:0, err:0};\n  issues.forEach(i => { if (counts[i.t] !== undefined) counts[i.t]++; });\n\n  const sum = document.getElementById('iv-summary');\n  const badge = document.getElementById('iv-sum-badge');\n  const txt   = document.getElementById('iv-sum-text');\n\n  if (counts.err > 0) {\n    sum.className = 'iv-summary iv-sum-err';\n    badge.textContent = T.bErr; badge.style.background = '#EB5757';\n    txt.textContent = T.sumErr;\n  } else if (counts.warn > 0) {\n    sum.className = 'iv-summary iv-sum-warn';\n    badge.textContent = T.bWarn; badge.style.background = '#F2994A';\n    txt.textContent = T.sumWarn;\n  } else {\n    sum.className = 'iv-summary iv-sum-ok';\n    badge.textContent = T.bOK; badge.style.background = '#27AE60';\n    txt.textContent = T.sumOK;\n  }\n\n  const list = document.getElementById('iv-checks');\n  list.innerHTML = '';\n  const BDGE = {ok:'OK', warn: PL?'UWAGA':'WARN', err: PL?'B\u0141\u0104D':'ERROR', info:'INFO'};\n  const CLS  = {ok:'iv-ci-ok', warn:'iv-ci-warn', err:'iv-ci-err', info:'iv-ci-info'};\n\n  issues.forEach(iss => {\n    const el = document.createElement('div');\n    el.className = `iv-check-item ${CLS[iss.t] || 'iv-ci-info'}`;\n    const b = BDGE[iss.t] || 'INFO';\n    const pageTag = iss.page ? `<span class=\"iv-ci-page-tag\">${escHtml(iss.page)}<\/span><br>` : '';\n    el.innerHTML = `<span class=\"iv-ci-badge iv-ci-badge-${iss.t||'info'}\">${b}<\/span><div>${pageTag}<div class=\"iv-ci-title\">${escHtml(iss.title)}<\/div>${iss.desc?`<div class=\"iv-ci-desc\">${escHtml(iss.desc)}<\/div>`:''}<\/div>`;\n    list.appendChild(el);\n  });\n\n  document.getElementById('iv-detail').style.display  = 'none';\n  const rc = document.getElementById('iv-results');\n  rc.style.display = 'block';\n  rc.scrollIntoView({behavior:'smooth', block:'start'});\n}\n\nfunction ivReset() {\n  document.getElementById('iv-results').style.display = 'none';\n  ivResetFiles();\n  \/\/ Wracamy do panelu wymaga\u0144 i uploadz zachowanym wyborem produktu\n  const p = PRODS.find(x => x.id === S.prod);\n  if (p) {\n    document.getElementById('iv-detail').style.display = 'block';\n    document.getElementById('iv-detail').scrollIntoView({ behavior:'smooth', block:'start' });\n  } else {\n    document.getElementById('iv-detail').style.display = 'none';\n    document.getElementById('iv-wrap').scrollIntoView({ behavior:'smooth', block:'start' });\n  }\n}\n\nfunction escHtml(s) {\n  return String(s).replace(\/&\/g,'&amp;').replace(\/<\/g,'&lt;').replace(\/>\/g,'&gt;').replace(\/\"\/g,'&quot;');\n}\n\n\/* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n   THREE.JS \u2014 podgl\u0105d 3D\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 *\/\nfunction mkTex(idx) {\n  const src = S.rendered3d[idx] || S.rendered[idx]; if (!src) return null;\n  const t = new THREE.CanvasTexture(src);\n  t.anisotropy = 16; t.needsUpdate = true;\n  return t;\n}\n\nfunction ivBuild3D(p) {\n  if (S.zoomCleanup) { S.zoomCleanup(); S.zoomCleanup = null; }\n  if (S.af)       { cancelAnimationFrame(S.af); S.af = null; }\n  if (S.renderer) { S.renderer.dispose(); S.renderer = null; }\n\n  const wrap   = document.getElementById('iv-canvas-wrap');\n  const canvas = document.getElementById('iv-canvas');\n  const W = wrap.clientWidth, H = wrap.clientHeight;\n\n  const scene  = new THREE.Scene();\n  scene.background = new THREE.Color(0x080808);\n  const camera = new THREE.PerspectiveCamera(34, W\/H, 0.1, 1000);\n  const renderer = new THREE.WebGLRenderer({canvas, antialias: true});\n  renderer.setSize(W, H);\n  renderer.setPixelRatio(Math.min(devicePixelRatio, 2));\n\n  scene.add(new THREE.AmbientLight(0xffffff, 0.18));\n  const dKey = new THREE.DirectionalLight(0xffffff, 0.85);\n  dKey.position.set(2, 4, 6); scene.add(dKey);\n  const dFill = new THREE.DirectionalLight(0xffffff, 0.15);\n  dFill.position.set(-3, 1, 2); scene.add(dFill);\n\n  const group = ivMesh(p);\n  scene.add(group);\n\n  const cDist = ivCameraDist(p);\n  camera.position.set(0, 0.4, cDist);\n  camera.lookAt(0, 0, 0);\n\n  S.renderer = renderer; S.mesh = group; S.camera = camera;\n  S.autoRot = true; S.targetY = 0;\n\n  const zMin = cDist * 0.4, zMax = cDist * 3.5;\n  const onWheel = e => {\n    e.preventDefault();\n    camera.position.z = Math.max(zMin, Math.min(zMax, camera.position.z * (e.deltaY > 0 ? 1.04 : 0.96)));\n  };\n  let _pinch0 = 0;\n  const onPinchStart = e => {\n    if (e.touches.length === 2) {\n      const dx = e.touches[0].clientX - e.touches[1].clientX;\n      const dy = e.touches[0].clientY - e.touches[1].clientY;\n      _pinch0 = Math.sqrt(dx*dx + dy*dy);\n    }\n  };\n  const onPinchMove = e => {\n    if (e.touches.length === 2) {\n      e.preventDefault();\n      const dx = e.touches[0].clientX - e.touches[1].clientX;\n      const dy = e.touches[0].clientY - e.touches[1].clientY;\n      const dist = Math.sqrt(dx*dx + dy*dy);\n      if (_pinch0 > 0) camera.position.z = Math.max(zMin, Math.min(zMax, camera.position.z * (_pinch0\/dist)));\n      _pinch0 = dist;\n    }\n  };\n  canvas.addEventListener('wheel',      onWheel,      {passive:false});\n  canvas.addEventListener('touchstart', onPinchStart, {passive:true});\n  canvas.addEventListener('touchmove',  onPinchMove,  {passive:false});\n  S.zoomCleanup = () => {\n    canvas.removeEventListener('wheel', onWheel);\n    canvas.removeEventListener('touchstart', onPinchStart);\n    canvas.removeEventListener('touchmove', onPinchMove);\n  };\n\n  const ro = new ResizeObserver(() => {\n    const nW = wrap.clientWidth, nH = wrap.clientHeight;\n    camera.aspect = nW\/nH; camera.updateProjectionMatrix();\n    renderer.setSize(nW, nH);\n  });\n  ro.observe(wrap);\n\n  function anim() {\n    S.af = requestAnimationFrame(anim);\n    if (S.autoRot) group.rotation.y -= 0.004;\n    else           group.rotation.y += (S.targetY - group.rotation.y) * 0.08;\n    renderer.render(scene, camera);\n  }\n  anim();\n  ivBuildPageBtns(p);\n}\n\nfunction ivMesh(p) {\n  const g = new THREE.Group();\n\n  \/\/ Pobierz rzeczywiste proporcje z wyrenderowanego pliku\n  const src = S.rendered3d[0] || S.rendered[0];\n  let fileW, fileH;\n  if (src && src.width > 0 && src.height > 0) {\n    \/\/ U\u017cyj faktycznych pikseli pliku jako proporcji\n    fileW = src.width;\n    fileH = src.height;\n  } else if (!p.custom) {\n    \/\/ Fallback na wymiary produktu\n    fileW = p.w; fileH = p.h;\n  } else {\n    \/\/ Baner bez pliku \u2014 domy\u015blny prostok\u0105t poziomy\n    fileW = 16; fileH = 9;\n  }\n\n  const ratio = fileW \/ fileH;\n\n  \/\/ Skaluj do sensownego rozmiaru w scenie \u2014 d\u0142u\u017csza kraw\u0119d\u017a = MAX_SIDE\n  const MAX_SIDE = 9;\n  let meshW, meshH;\n  if (ratio >= 1) {\n    meshW = MAX_SIDE;\n    meshH = MAX_SIDE \/ ratio;\n  } else {\n    meshH = MAX_SIDE;\n    meshW = MAX_SIDE * ratio;\n  }\n\n  \/\/ Grubo\u015b\u0107 zale\u017cna od kategorii\n  const thick = p.model === 'card' ? 0.07\n              : p.model === 'flyer' ? 0.04\n              : 0.02; \/\/ baner\/tablica\n\n  const edge = new THREE.MeshStandardMaterial({color:0x888888, roughness:0.7, metalness:0.0});\n  const fTex = mkTex(0), bTex = mkTex(1);\n  const fM = new THREE.MeshStandardMaterial({map:fTex||null, color:fTex?0xffffff:0xf0f0f0, roughness:0.92, metalness:0.0});\n  const bM = new THREE.MeshStandardMaterial({map:bTex||null, color:bTex?0xffffff:0xe0e0e0, roughness:0.92, metalness:0.0});\n\n  \/\/ Banery \u2014 tylko prz\u00f3d (jednostronne), reszta \u2014 prz\u00f3d i ty\u0142\n  const backMat = p.model === 'banner' ? edge : bM;\n\n  g.add(new THREE.Mesh(\n    new THREE.BoxGeometry(meshW, meshH, thick),\n    [edge, edge, edge, edge, fM, backMat]\n  ));\n\n  g.rotation.x = 0.08;\n  g.rotation.y = 0.3;\n  return g;\n}\n\n\/\/ Oblicz odleg\u0142o\u015b\u0107 kamery na podstawie faktycznych proporcji pliku\nfunction ivCameraDist(p) {\n  const src = S.rendered3d[0] || S.rendered[0];\n  let ratio = 1;\n  if (src && src.width > 0 && src.height > 0) {\n    ratio = src.width \/ src.height;\n  } else if (!p.custom) {\n    ratio = p.w \/ p.h;\n  }\n  \/\/ mesh ma zawsze MAX_SIDE=9 po d\u0142u\u017cszej kraw\u0119dzi\n  \/\/ kamera musi widzie\u0107 ca\u0142y mesh \u2014 dopasuj do aspect ratio sceny i meshu\n  const meshW = ratio >= 1 ? 9 : 9 * ratio;\n  const meshH = ratio >= 1 ? 9 \/ ratio : 9;\n  const maxMeshDim = Math.max(meshW, meshH);\n  return Math.max(13, Math.min(30, maxMeshDim * 1.9));\n}\n\nfunction ivBuildPageBtns(p) {\n  \/\/ Page buttons are handled by ivBuildPreviewTabs now \u2014 3D has its own set for page flip\n  const el = document.getElementById('iv-page-btns');\n  el.innerHTML = '';\n  if (p.pages <= 1) return;\n  const labels = PL ? p.pl_pg : p.en_pg;\n  labels.forEach((lbl, idx) => {\n    const btn = document.createElement('button');\n    btn.className = 'iv-page-btn' + (idx === 0 ? ' active' : '');\n    btn.textContent = lbl;\n    btn.onclick = () => ivSwitchPage3D(idx, p);\n    el.appendChild(btn);\n  });\n}\n\nfunction ivSwitchPage(idx, prod) {\n  ivSwitchPage3D(idx, prod);\n}\n\nfunction ivSwitchPage3D(idx, p) {\n  S.autoRot = false;\n  document.querySelectorAll('.iv-page-btn').forEach((b,i) => b.classList.toggle('active', i===idx));\n  const ind = document.getElementById('iv-page-ind');\n  const labels = PL ? p.pl_pg : p.en_pg;\n  ind.textContent = `${labels[idx]} (${idx+1}\/${p.pages})`;\n  ind.classList.add('show');\n  setTimeout(() => ind.classList.remove('show'), 2500);\n  if ((p.model === 'card' || p.model === 'flyer') && S.mesh) {\n    const front = idx % 2 === 0;\n    const cur   = S.mesh.rotation.y;\n    S.targetY = front\n      ? Math.round(cur\/(Math.PI*2))*Math.PI*2\n      : Math.round((cur-Math.PI)\/(Math.PI*2))*Math.PI*2 + Math.PI;\n    const fi = idx <= 1 ? 0 : 2, bi = idx <= 1 ? 1 : 3;\n    S.mesh.children.forEach(c => {\n      if (!(c instanceof THREE.Mesh) || !Array.isArray(c.material)) return;\n      const ft = mkTex(fi), bt = mkTex(bi);\n      c.material[4] = new THREE.MeshStandardMaterial({map:ft||null, color:ft?0xffffff:0xf0f0f0, roughness:0.92});\n      c.material[5] = new THREE.MeshStandardMaterial({map:bt||null, color:bt?0xffffff:0xe0e0e0, roughness:0.92});\n    });\n  }\n  if (S.autoTimer) clearTimeout(S.autoTimer);\n  S.autoTimer = setTimeout(() => S.autoRot = true, 4000);\n}\n\n\/* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n   DRAG (obr\u00f3t 3D)\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 *\/\nconst cv = document.getElementById('iv-canvas');\ncv.addEventListener('mousedown', e => {S.drag=true; S.autoRot=false; S.prev={x:e.clientX,y:e.clientY};});\ncv.addEventListener('touchstart', e => {\n  if (e.touches.length === 1) { S.drag=true; S.autoRot=false; S.prev={x:e.touches[0].clientX,y:e.touches[0].clientY}; }\n}, {passive:true});\ndocument.addEventListener('mousemove', e => {\n  if (!S.drag || !S.mesh) return;\n  const dx=e.clientX-S.prev.x, dy=e.clientY-S.prev.y;\n  S.mesh.rotation.y += dx*.013; S.mesh.rotation.x += dy*.013;\n  S.mesh.rotation.x = Math.max(-.5, Math.min(.5, S.mesh.rotation.x));\n  S.targetY = S.mesh.rotation.y; S.prev={x:e.clientX,y:e.clientY};\n});\ndocument.addEventListener('touchmove', e => {\n  if (!S.drag || !S.mesh) return;\n  const dx = e.touches[0].clientX - S.prev.x;\n  S.mesh.rotation.y += dx*.013; S.targetY = S.mesh.rotation.y;\n  S.prev = {x:e.touches[0].clientX, y:e.touches[0].clientY};\n}, {passive:true});\nconst stopDrag = () => {\n  if (!S.drag) return; S.drag=false;\n  S.autoTimer = setTimeout(() => S.autoRot=true, 3000);\n};\ndocument.addEventListener('mouseup', stopDrag);\ndocument.addEventListener('touchend', stopDrag);\n\n\/* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n   DARK MODE SYNC\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 *\/\nfunction syncTheme() {\n  const h = document.documentElement, b = document.body || h;\n  const dark =\n    h.classList.contains('dark') || h.classList.contains('dark-mode') || h.getAttribute('data-theme') === 'dark' ||\n    b.classList.contains('dark') || b.classList.contains('dark-mode') || b.classList.contains('night-mode') || b.getAttribute('data-theme') === 'dark';\n  const wrap = document.getElementById('iv-wrap');\n  if (wrap) wrap.classList.toggle('iv-dark', dark);\n}\nconst _themeObs = new MutationObserver(syncTheme);\n_themeObs.observe(document.documentElement, {attributes:true, attributeFilter:['class','data-theme']});\nif (document.body) _themeObs.observe(document.body, {attributes:true, attributeFilter:['class','data-theme']});\nsyncTheme();\n\n\/* \u2500\u2500 INIT \u2500\u2500 *\/\ninit();\n<\/script>\n\n<!--\n  WORDPRESS SHORTCODE \u2014 u\u017cyj pliku PHP do wstrzykni\u0119cia (identycznie jak orygina\u0142)\n  U\u017cycie: [weryfikator]\n-->\n<script>'use strict';\n\/\/ Ustaw workerSrc PRZED jakimkolwiek u\u017cyciem pdf.js\n\/\/ FlyingPress nie cachuje string\u00f3w JS \u2014 tylko tagi <script src>\nwindow._PDFJS_WORKER = 'https:\/\/cdnjs.cloudflare.com\/ajax\/libs\/pdf.js\/3.11.174\/pdf.worker.min.js';<\/script>\n<script>'use strict';\n\/\/ Konfiguracja workera PDF.js\n\/\/ U\u017cywamy workerSrc z CDN (nie ruszanego przez FlyingPress bo to string w JS, nie <script src>)\n\/\/ Je\u015bli worker si\u0119 nie za\u0142aduje, getDocument z\u0142apie b\u0142\u0105d i spr\u00f3buje tryb bez workera\npdfjsLib.GlobalWorkerOptions.workerSrc = window._PDFJS_WORKER;\n\n\/\/ Wrapper dla getDocument obs\u0142uguj\u0105cy brak workera (WebView, CSP)\nasync function ivGetDocument(data) {\n  try {\n    return await pdfjsLib.getDocument({data}).promise;\n  } catch(e) {\n    \/\/ Worker failed \u2014 spr\u00f3buj bez workera\n    if (pdfjsLib.GlobalWorkerOptions.workerSrc !== '') {\n      pdfjsLib.GlobalWorkerOptions.workerSrc = '';\n      try {\n        return await pdfjsLib.getDocument({data}).promise;\n      } catch(e2) {\n        throw e2;\n      }\n    }\n    throw e;\n  }\n}\n\n\/* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n   J\u0118ZYK\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 *\/\nconst PL = !\/\\\/(en)(\\\/|$)\/i.test(window.location.pathname);\n\nconst T = {\n  selLabel:   PL ? 'Wybierz produkt'       : 'Select product',\n  reqLabel:   PL ? 'Wymagania pliku'        : 'File requirements',\n  upLabel:    PL ? 'Wgraj plik projektu'    : 'Upload project file',\n  dzT:        PL ? 'Przeci\u0105gnij plik tutaj (PDF, JPG, TIFF)' : 'Drag file here (PDF, JPG, TIFF)',\n  dzS:        PL ? 'lub'                    : 'or',\n  dzLink:     PL ? 'kliknij, aby wybra\u0107'   : 'click to select',\n  verifyBtn:  PL ? 'Weryfikuj projekt'      : 'Verify design',\n  resLabel:   PL ? 'Wyniki weryfikacji'     : 'Verification results',\n  prevLabel:  PL ? 'Podgl\u0105d projektu'       : 'Project preview',\n  chkLabel:   PL ? 'Szczeg\u00f3\u0142y sprawdzenia' : 'Check details',\n  backBtn:    PL ? '\u2190 Sprawd\u017a inny plik'   : '\u2190 Check another file',\n  rendering:  PL ? 'Renderowanie\u2026'         : 'Rendering\u2026',\n  loadPDF:    PL ? '\u0141adowanie PDF\u2026'        : 'Loading PDF\u2026',\n  loadPrev:   PL ? 'Renderowanie podgl\u0105du\u2026' : 'Rendering preview\u2026',\n  errFormat:  PL ? 'Nieobs\u0142ugiwany format. Wybierz PDF, JPG lub TIFF.' : 'Unsupported format.',\n  errLoad:    PL ? 'B\u0142\u0105d wczytywania pliku' : 'File loading error',\n  sumOK:      PL ? 'Plik gotowy do druku \u2713' : 'File ready for printing \u2713',\n  sumWarn:    PL ? 'Sprawd\u017a zaznaczone punkty przed wys\u0142aniem' : 'Review flagged items before sending',\n  sumErr:     PL ? 'Plik wymaga poprawek przed drukiem' : 'File needs corrections before printing',\n  bOK:        'OK',\n  bWarn:      PL ? 'Uwaga' : 'Warning',\n  bErr:       PL ? 'B\u0142\u0105d'  : 'Error',\n  bInfo:      'INFO',\n  catPlaceholder:    PL ? 'Kategoria produktu\u2026' : 'Product category\u2026',\n  prodPlaceholder:   PL ? 'Wybierz produkt\u2026'    : 'Select product\u2026',\n  formatPlaceholder: PL ? 'Format\u2026'             : 'Format\u2026',\n  sidesPlaceholder:  PL ? 'Druk\u2026'               : 'Print sides\u2026',\n  finishPlaceholder: PL ? 'Uszlachetnienie\u2026'    : 'Finishing\u2026',\n  sides1:            PL ? 'Jednostronne'         : 'Single-sided',\n  sides2:            PL ? 'Dwustronne'           : 'Double-sided',\n  finishBrak:        PL ? 'Bez uszlachetnienia'     : 'No finishing',\n  finishGold:        PL ? 'Ze z\u0142oceniem'            : 'Gold foil',\n  finishUV:          PL ? 'Z lakierem UV'           : 'UV varnish',\n  finishFoliaM:      PL ? 'Z foli\u0105 matow\u0105'          : 'Matte laminate',\n  finishFoliaB:      PL ? 'Z foli\u0105 b\u0142yszcz\u0105c\u0105'      : 'Gloss laminate',\n  finishSoftSkin:    PL ? 'Z foli\u0105 soft skin'       : 'Soft skin laminate',\n  pages:      n  => PL ? `${n} ${n===1?'strona':'strony'}` : `${n} ${n===1?'page':'pages'}`,\n  bleedSize:  (w,h) => PL ? `Ze spadem: ${w}\u00d7${h} mm` : `With bleed: ${w}\u00d7${h} mm`,\n  customSize: PL ? '\ud83d\udcd0 Rozmiar zgodny z zam\u00f3wieniem (skala 1:1)' : '\ud83d\udcd0 Size as per order (scale 1:1)',\n  legBleed:   PL ? 'Linia spad\u00f3w (+2 mm)'        : 'Bleed line (+2 mm)',\n  legMargin:  PL ? 'Margines bezpieczny'          : 'Safe margin',\n  legCut:     PL ? 'Linia ci\u0119cia'                 : 'Cut line',\n};\n\n\/* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n   STA\u0141E\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 *\/\nconst PT  = 2.834645669; \/\/ 1 mm w punktach PDF\nconst TOL = 1.5;         \/\/ tolerancja wymiar\u00f3w [mm]\n\n\/* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n   WYMAGANIA \u2014 etykiety\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 *\/\nconst REQS = {\n  cmyk:      { pl:'Kolorystyka: CMYK (nie RGB)',             en:'Color space: CMYK (not RGB)'        },\n  curves:    { pl:'Teksty zamienione na krzywe',             en:'Texts converted to curves'          },\n  dpi300:    { pl:'Mapy bitowe: min. 300 DPI',              en:'Bitmaps: min. 300 DPI'              },\n  bleed2:    { pl:'Spady: 2 mm z ka\u017cdej strony',            en:'Bleeds: 2 mm on each side'          },\n  margin3:   { pl:'Margines wewn\u0119trzny: min. 3 mm',         en:'Safe margin: min. 3 mm'             },\n  margin5:   { pl:'Margines wewn\u0119trzny: min. 5 mm',         en:'Safe margin: min. 5 mm'             },\n  gradients: { pl:'Przej\u015bcia tonalne \u2192 mapy bitowe',        en:'Gradients \u2192 bitmaps (recommended)'  },\n  masks:     { pl:'Maski uszlachetnie\u0144: K=100%, os. strony',en:'Finishing masks: K=100%, sep. pages'},\n  scale:     { pl:'Rozmiar z zam\u00f3wienia (skala 1:1)',        en:'Size as per order (scale 1:1)'      },\n};\nconst R = k => PL ? REQS[k].pl : REQS[k].en;\n\n\/* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n   KATEGORIE I PRODUKTY\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 *\/\nconst CATS = [\n  { id:'wiz', pl:'\ud83d\udcb3 Wizyt\u00f3wki',        en:'\ud83d\udcb3 Business cards'    },\n  { id:'ulo', pl:'\ud83d\udcc4 Ulotki',           en:'\ud83d\udcc4 Flyers'            },\n  { id:'ban', pl:'\ud83c\udfaa Banery i tablice', en:'\ud83c\udfaa Banners & boards'  },\n];\n\nconst PRODS = [\n  \/\/ \u2500\u2500 Wizyt\u00f3wki 90\u00d750 \u2500\u2500\n  { id:'wiz1s', cat:'wiz', format:'90x50', sides:'1', finish:'brak',\n    pl:'Wizyt\u00f3wka 90\u00d750 mm \u2014 jednostronna', en:'Business card 90\u00d750 mm \u2014 single-sided',\n    w:90,h:50,bl:2,margin:3,pages:1, pl_pg:['Prz\u00f3d'],en_pg:['Front'], model:'card',\n    reqs:['cmyk','curves','dpi300','bleed2','margin3','gradients'] },\n  { id:'wiz1', cat:'wiz', format:'90x50', sides:'2', finish:'brak',\n    pl:'Wizyt\u00f3wka 90\u00d750 mm \u2014 dwustronna', en:'Business card 90\u00d750 mm \u2014 double-sided',\n    w:90,h:50,bl:2,margin:3,pages:2, pl_pg:['Prz\u00f3d','Ty\u0142'],en_pg:['Front','Back'], model:'card',\n    reqs:['cmyk','curves','dpi300','bleed2','margin3','gradients'] },\n  { id:'wiz1fm', cat:'wiz', format:'90x50', sides:'2', finish:'folia_mat',\n    pl:'Wizyt\u00f3wka 90\u00d750 mm \u2014 folia matowa', en:'Business card 90\u00d750 mm \u2014 matte laminate',\n    w:90,h:50,bl:2,margin:3,pages:2, pl_pg:['Prz\u00f3d','Ty\u0142'],en_pg:['Front','Back'], model:'card',\n    reqs:['cmyk','curves','dpi300','bleed2','margin3','gradients'] },\n  { id:'wiz1fb', cat:'wiz', format:'90x50', sides:'2', finish:'folia_bly',\n    pl:'Wizyt\u00f3wka 90\u00d750 mm \u2014 folia b\u0142yszcz\u0105ca', en:'Business card 90\u00d750 mm \u2014 gloss laminate',\n    w:90,h:50,bl:2,margin:3,pages:2, pl_pg:['Prz\u00f3d','Ty\u0142'],en_pg:['Front','Back'], model:'card',\n    reqs:['cmyk','curves','dpi300','bleed2','margin3','gradients'] },\n  { id:'wiz1ss', cat:'wiz', format:'90x50', sides:'2', finish:'soft_skin',\n    pl:'Wizyt\u00f3wka 90\u00d750 mm \u2014 folia soft skin', en:'Business card 90\u00d750 mm \u2014 soft skin laminate',\n    w:90,h:50,bl:2,margin:3,pages:2, pl_pg:['Prz\u00f3d','Ty\u0142'],en_pg:['Front','Back'], model:'card',\n    reqs:['cmyk','curves','dpi300','bleed2','margin3','gradients'] },\n  { id:'wiz3', cat:'wiz', format:'90x50', sides:'2', finish:'gold',\n    pl:'Wizyt\u00f3wka 90\u00d750 mm \u2014 z\u0142ocenie', en:'Business card 90\u00d750 mm \u2014 gold foil',\n    w:90,h:50,bl:2,margin:3,pages:4,\n    pl_pg:['Prz\u00f3d','Ty\u0142','Maska z\u0142ocenia \u2014 prz\u00f3d','Maska z\u0142ocenia \u2014 ty\u0142'],\n    en_pg:['Front','Back','Gold mask \u2014 front','Gold mask \u2014 back'], model:'card',\n    reqs:['cmyk','curves','dpi300','bleed2','margin3','gradients','masks'],\n    note:{ pl:'Strony 3\u20134: maski z\u0142ocenia \u2014 wy\u0142\u0105cznie K=100%. Czarny = obszar z\u0142ocenia.',\n           en:'Pages 3\u20134: gold foil masks \u2014 K=100% only. Black = gold area.' } },\n  { id:'wiz5', cat:'wiz', format:'90x50', sides:'2', finish:'uv',\n    pl:'Wizyt\u00f3wka 90\u00d750 mm \u2014 lakier UV', en:'Business card 90\u00d750 mm \u2014 UV varnish',\n    w:90,h:50,bl:2,margin:3,pages:4,\n    pl_pg:['Prz\u00f3d','Ty\u0142','Maska lakieru \u2014 prz\u00f3d','Maska lakieru \u2014 ty\u0142'],\n    en_pg:['Front','Back','Varnish mask \u2014 front','Varnish mask \u2014 back'], model:'card',\n    reqs:['cmyk','curves','dpi300','bleed2','margin3','gradients','masks'],\n    note:{ pl:'Strony 3\u20134: maski lakieru UV \u2014 wy\u0142\u0105cznie K=100%. Czarny = obszar lakierowania.',\n           en:'Pages 3\u20134: UV varnish masks \u2014 K=100% only. Black = varnish area.' } },\n\n  \/\/ \u2500\u2500 Wizyt\u00f3wki 85\u00d754 \u2500\u2500\n  { id:'wiz2s', cat:'wiz', format:'85x54', sides:'1', finish:'brak',\n    pl:'Wizyt\u00f3wka 85\u00d754 mm \u2014 jednostronna', en:'Business card 85\u00d754 mm \u2014 single-sided',\n    w:85,h:54,bl:2,margin:3,pages:1, pl_pg:['Prz\u00f3d'],en_pg:['Front'], model:'card',\n    reqs:['cmyk','curves','dpi300','bleed2','margin3','gradients'] },\n  { id:'wiz2', cat:'wiz', format:'85x54', sides:'2', finish:'brak',\n    pl:'Wizyt\u00f3wka 85\u00d754 mm \u2014 dwustronna', en:'Business card 85\u00d754 mm \u2014 double-sided',\n    w:85,h:54,bl:2,margin:3,pages:2, pl_pg:['Prz\u00f3d','Ty\u0142'],en_pg:['Front','Back'], model:'card',\n    reqs:['cmyk','curves','dpi300','bleed2','margin3','gradients'] },\n  { id:'wiz2fm', cat:'wiz', format:'85x54', sides:'2', finish:'folia_mat',\n    pl:'Wizyt\u00f3wka 85\u00d754 mm \u2014 folia matowa', en:'Business card 85\u00d754 mm \u2014 matte laminate',\n    w:85,h:54,bl:2,margin:3,pages:2, pl_pg:['Prz\u00f3d','Ty\u0142'],en_pg:['Front','Back'], model:'card',\n    reqs:['cmyk','curves','dpi300','bleed2','margin3','gradients'] },\n  { id:'wiz2fb', cat:'wiz', format:'85x54', sides:'2', finish:'folia_bly',\n    pl:'Wizyt\u00f3wka 85\u00d754 mm \u2014 folia b\u0142yszcz\u0105ca', en:'Business card 85\u00d754 mm \u2014 gloss laminate',\n    w:85,h:54,bl:2,margin:3,pages:2, pl_pg:['Prz\u00f3d','Ty\u0142'],en_pg:['Front','Back'], model:'card',\n    reqs:['cmyk','curves','dpi300','bleed2','margin3','gradients'] },\n  { id:'wiz2ss', cat:'wiz', format:'85x54', sides:'2', finish:'soft_skin',\n    pl:'Wizyt\u00f3wka 85\u00d754 mm \u2014 folia soft skin', en:'Business card 85\u00d754 mm \u2014 soft skin laminate',\n    w:85,h:54,bl:2,margin:3,pages:2, pl_pg:['Prz\u00f3d','Ty\u0142'],en_pg:['Front','Back'], model:'card',\n    reqs:['cmyk','curves','dpi300','bleed2','margin3','gradients'] },\n  { id:'wiz4', cat:'wiz', format:'85x54', sides:'2', finish:'gold',\n    pl:'Wizyt\u00f3wka 85\u00d754 mm \u2014 z\u0142ocenie', en:'Business card 85\u00d754 mm \u2014 gold foil',\n    w:85,h:54,bl:2,margin:3,pages:4,\n    pl_pg:['Prz\u00f3d','Ty\u0142','Maska z\u0142ocenia \u2014 prz\u00f3d','Maska z\u0142ocenia \u2014 ty\u0142'],\n    en_pg:['Front','Back','Gold mask \u2014 front','Gold mask \u2014 back'], model:'card',\n    reqs:['cmyk','curves','dpi300','bleed2','margin3','gradients','masks'],\n    note:{ pl:'Strony 3\u20134: maski z\u0142ocenia \u2014 wy\u0142\u0105cznie K=100%.', en:'Pages 3\u20134: gold foil masks \u2014 K=100% only.' } },\n  { id:'wiz6', cat:'wiz', format:'85x54', sides:'2', finish:'uv',\n    pl:'Wizyt\u00f3wka 85\u00d754 mm \u2014 lakier UV', en:'Business card 85\u00d754 mm \u2014 UV varnish',\n    w:85,h:54,bl:2,margin:3,pages:4,\n    pl_pg:['Prz\u00f3d','Ty\u0142','Maska lakieru \u2014 prz\u00f3d','Maska lakieru \u2014 ty\u0142'],\n    en_pg:['Front','Back','Varnish mask \u2014 front','Varnish mask \u2014 back'], model:'card',\n    reqs:['cmyk','curves','dpi300','bleed2','margin3','gradients','masks'],\n    note:{ pl:'Strony 3\u20134: maski lakieru UV \u2014 wy\u0142\u0105cznie K=100%.', en:'Pages 3\u20134: UV varnish masks \u2014 K=100% only.' } },\n\n  \/\/ \u2500\u2500 Ulotki \u2500\u2500\n  { id:'ulo1s', cat:'ulo', format:'A6', sides:'1',\n    pl:'Ulotka A6 \u2014 105\u00d7148 mm \u2014 jednostronna', en:'Flyer A6 \u2014 105\u00d7148 mm \u2014 single-sided',\n    w:105,h:148,bl:2,margin:3,pages:1, pl_pg:['Prz\u00f3d'],en_pg:['Front'], model:'flyer',\n    reqs:['cmyk','curves','dpi300','bleed2','margin3','gradients'] },\n  { id:'ulo1', cat:'ulo', format:'A6', sides:'2',\n    pl:'Ulotka A6 \u2014 105\u00d7148 mm \u2014 dwustronna', en:'Flyer A6 \u2014 105\u00d7148 mm \u2014 double-sided',\n    w:105,h:148,bl:2,margin:3,pages:2, pl_pg:['Prz\u00f3d','Ty\u0142'],en_pg:['Front','Back'], model:'flyer',\n    reqs:['cmyk','curves','dpi300','bleed2','margin3','gradients'] },\n  { id:'ulo2s', cat:'ulo', format:'A5', sides:'1',\n    pl:'Ulotka A5 \u2014 148\u00d7210 mm \u2014 jednostronna', en:'Flyer A5 \u2014 148\u00d7210 mm \u2014 single-sided',\n    w:148,h:210,bl:2,margin:3,pages:1, pl_pg:['Prz\u00f3d'],en_pg:['Front'], model:'flyer',\n    reqs:['cmyk','curves','dpi300','bleed2','margin3','gradients'] },\n  { id:'ulo2', cat:'ulo', format:'A5', sides:'2',\n    pl:'Ulotka A5 \u2014 148\u00d7210 mm \u2014 dwustronna', en:'Flyer A5 \u2014 148\u00d7210 mm \u2014 double-sided',\n    w:148,h:210,bl:2,margin:3,pages:2, pl_pg:['Prz\u00f3d','Ty\u0142'],en_pg:['Front','Back'], model:'flyer',\n    reqs:['cmyk','curves','dpi300','bleed2','margin3','gradients'] },\n  { id:'ulo3s', cat:'ulo', format:'A4', sides:'1',\n    pl:'Ulotka A4 \u2014 210\u00d7297 mm \u2014 jednostronna', en:'Flyer A4 \u2014 210\u00d7297 mm \u2014 single-sided',\n    w:210,h:297,bl:2,margin:3,pages:1, pl_pg:['Prz\u00f3d'],en_pg:['Front'], model:'flyer',\n    reqs:['cmyk','curves','dpi300','bleed2','margin3','gradients'] },\n  { id:'ulo3', cat:'ulo', format:'A4', sides:'2',\n    pl:'Ulotka A4 \u2014 210\u00d7297 mm \u2014 dwustronna', en:'Flyer A4 \u2014 210\u00d7297 mm \u2014 double-sided',\n    w:210,h:297,bl:2,margin:3,pages:2, pl_pg:['Prz\u00f3d','Ty\u0142'],en_pg:['Front','Back'], model:'flyer',\n    reqs:['cmyk','curves','dpi300','bleed2','margin3','gradients'] },\n  { id:'ulo4s', cat:'ulo', format:'DL', sides:'1',\n    pl:'Ulotka DL \u2014 99\u00d7210 mm \u2014 jednostronna', en:'Flyer DL \u2014 99\u00d7210 mm \u2014 single-sided',\n    w:99,h:210,bl:2,margin:3,pages:1, pl_pg:['Prz\u00f3d'],en_pg:['Front'], model:'flyer',\n    reqs:['cmyk','curves','dpi300','bleed2','margin3','gradients'] },\n  { id:'ulo4', cat:'ulo', format:'DL', sides:'2',\n    pl:'Ulotka DL \u2014 99\u00d7210 mm \u2014 dwustronna', en:'Flyer DL \u2014 99\u00d7210 mm \u2014 double-sided',\n    w:99,h:210,bl:2,margin:3,pages:2, pl_pg:['Prz\u00f3d','Ty\u0142'],en_pg:['Front','Back'], model:'flyer',\n    reqs:['cmyk','curves','dpi300','bleed2','margin3','gradients'] },\n\n  \/\/ \u2500\u2500 Banery i tablice \u2500\u2500\n  { id:'ban1', cat:'ban', pl:'Baner z oczkowaniem', en:'Banner with eyelets',\n    custom:true, pages:1, pl_pg:['Projekt'],en_pg:['Design'], model:'banner',\n    bl:0,margin:5,\n    reqs:['cmyk','curves','dpi300','margin5','gradients','scale'] },\n  { id:'ban2', cat:'ban', pl:'Tablica PCV', en:'PVC board',\n    custom:true, pages:1, pl_pg:['Projekt'],en_pg:['Design'], model:'banner',\n    bl:0,margin:5,\n    reqs:['cmyk','curves','dpi300','margin5','gradients','scale'] },\n  { id:'ban3', cat:'ban', pl:'Tablica Dibond', en:'Dibond board',\n    custom:true, pages:1, pl_pg:['Projekt'],en_pg:['Design'], model:'banner',\n    bl:0,margin:5,\n    reqs:['cmyk','curves','dpi300','margin5','gradients','scale'] },\n];\n\n\/* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n   STATE\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 *\/\nconst S = {\n  cat: null, prod: null, format: null, sides: null, finish: null,\n  \/\/ slots: Array of {file, fileType, buf, pageCount} \u2014 jeden slot = jedna strona lub ca\u0142y PDF\n  slots: [],\n  rendered: [],   \/\/ canvas[] \u2014 pe\u0142na strona ze spadami (dla podgl\u0105du 2D)\n  rendered3d: [], \/\/ canvas[] \u2014 przyci\u0119te do TrimBox (dla podgl\u0105du 3D)\n  renderer: null, mesh: null, camera: null, af: null,\n  drag: false, prev: {x:0,y:0}, autoRot: true, autoTimer: null, targetY: 0,\n  zoomCleanup: null,\n  preview2dPage: 0, \/\/ aktywna strona w podgl\u0105dzie 2D\n};\n\n\/* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n   INICJALIZACJA\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 *\/\nfunction init() {\n  document.getElementById('iv-sel-label').textContent      = T.selLabel;\n  document.getElementById('iv-req-label').textContent      = T.reqLabel;\n  document.getElementById('iv-up-label').textContent       = T.upLabel;\n  document.getElementById('iv-dz-t').textContent           = T.dzT;\n  document.getElementById('iv-dz-link').textContent        = T.dzLink;\n  document.getElementById('iv-dz-s').childNodes[0].nodeValue = T.dzS + ' ';\n  document.getElementById('iv-btn-v').textContent          = T.verifyBtn;\n  document.getElementById('iv-res-label').textContent      = T.resLabel;\n  document.getElementById('iv-prev-label').textContent     = T.prevLabel;\n  document.getElementById('iv-checks-label').textContent   = T.chkLabel;\n  document.getElementById('iv-btn-back').textContent       = T.backBtn;\n  document.getElementById('iv-cload-txt').textContent      = T.rendering;\n  document.getElementById('iv-leg-bleed').textContent      = T.legBleed;\n  document.getElementById('iv-leg-margin').textContent     = T.legMargin;\n  document.getElementById('iv-leg-cut').textContent        = T.legCut;\n  buildCatSelect();\n}\n\n\/* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n   SELECTS \u2014 identyczne jak orygina\u0142\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 *\/\nfunction buildCatSelect() {\n  const sel = document.getElementById('iv-cat-sel');\n  sel.innerHTML = `<option value=\"\" disabled selected>${T.catPlaceholder}<\/option>`;\n  CATS.forEach(c => {\n    const o = document.createElement('option');\n    o.value = c.id; o.textContent = PL ? c.pl : c.en;\n    sel.appendChild(o);\n  });\n  buildProdSelect(null);\n}\n\nfunction onCatChange() {\n  const catId = document.getElementById('iv-cat-sel').value;\n  S.cat = catId; S.prod = null; S.format = null; S.sides = null; S.finish = null;\n  const useCascade = (catId === 'wiz' || catId === 'ulo');\n  document.getElementById('iv-prod-wrap').style.display   = useCascade ? 'none' : '';\n  document.getElementById('iv-format-wrap').style.display = useCascade ? ''     : 'none';\n  document.getElementById('iv-sides-wrap').style.display  = 'none';\n  document.getElementById('iv-finish-wrap').style.display = 'none';\n  if (useCascade) buildFormatSelect(catId);\n  else            buildProdSelect(catId);\n  document.getElementById('iv-detail').style.display  = 'none';\n  document.getElementById('iv-results').style.display = 'none';\n}\n\nfunction buildProdSelect(catId) {\n  const sel = document.getElementById('iv-prod-sel');\n  sel.innerHTML = `<option value=\"\" disabled selected>${T.prodPlaceholder}<\/option>`;\n  if (!catId) return;\n  PRODS.filter(p => p.cat === catId).forEach(p => {\n    const o = document.createElement('option');\n    o.value = p.id; o.textContent = PL ? p.pl : p.en;\n    sel.appendChild(o);\n  });\n}\n\nfunction onProdChange() {\n  const pid = document.getElementById('iv-prod-sel').value;\n  if (!pid) return;\n  S.prod = pid;\n  showProductDetail(PRODS.find(x => x.id === pid));\n}\n\nfunction buildFormatSelect(catId) {\n  const sel = document.getElementById('iv-format-sel');\n  sel.innerHTML = `<option value=\"\" disabled selected>${T.formatPlaceholder}<\/option>`;\n  const seen = new Set();\n  PRODS.filter(p => p.cat === catId).forEach(p => {\n    if (p.format && !seen.has(p.format)) {\n      seen.add(p.format);\n      const o = document.createElement('option');\n      o.value = p.format;\n      o.textContent = p.format === 'A6'  ? 'A6 \u2014 105\u00d7148 mm'\n                    : p.format === 'A5'  ? 'A5 \u2014 148\u00d7210 mm'\n                    : p.format === 'A4'  ? 'A4 \u2014 210\u00d7297 mm'\n                    : p.format === 'DL'  ? 'DL \u2014 99\u00d7210 mm'\n                    : p.format === '90x50' ? '90\u00d750 mm'\n                    : p.format === '85x54' ? '85\u00d754 mm'\n                    : p.format;\n      sel.appendChild(o);\n    }\n  });\n}\n\nfunction onFormatChange() {\n  S.format = document.getElementById('iv-format-sel').value;\n  S.sides = null; S.finish = null; S.prod = null;\n  document.getElementById('iv-sides-wrap').style.display  = '';\n  document.getElementById('iv-finish-wrap').style.display = 'none';\n  buildSidesSelect();\n  document.getElementById('iv-detail').style.display  = 'none';\n  document.getElementById('iv-results').style.display = 'none';\n}\n\nfunction buildSidesSelect() {\n  const sel = document.getElementById('iv-sides-sel');\n  sel.innerHTML = `<option value=\"\" disabled selected>${T.sidesPlaceholder}<\/option>`;\n  ['1','2'].forEach(v => {\n    const o = document.createElement('option');\n    o.value = v; o.textContent = v === '1' ? T.sides1 : T.sides2;\n    sel.appendChild(o);\n  });\n}\n\nfunction onSidesChange() {\n  S.sides = document.getElementById('iv-sides-sel').value;\n  S.finish = null; S.prod = null;\n  if (S.cat === 'wiz' && S.sides === '2') {\n    document.getElementById('iv-finish-wrap').style.display = '';\n    buildFinishSelect();\n    document.getElementById('iv-detail').style.display  = 'none';\n    document.getElementById('iv-results').style.display = 'none';\n  } else {\n    document.getElementById('iv-finish-wrap').style.display = 'none';\n    S.finish = 'brak';\n    resolveAndShow();\n  }\n}\n\nfunction buildFinishSelect() {\n  const sel = document.getElementById('iv-finish-sel');\n  sel.innerHTML = `<option value=\"\" disabled selected>${T.finishPlaceholder}<\/option>`;\n  [\n    {v:'brak',      t: T.finishBrak},\n    {v:'folia_mat', t: T.finishFoliaM},\n    {v:'folia_bly', t: T.finishFoliaB},\n    {v:'soft_skin', t: T.finishSoftSkin},\n    {v:'gold',      t: T.finishGold},\n    {v:'uv',        t: T.finishUV},\n  ].forEach(({v,t}) => {\n    const o = document.createElement('option'); o.value = v; o.textContent = t; sel.appendChild(o);\n  });\n}\n\nfunction onFinishChange() {\n  S.finish = document.getElementById('iv-finish-sel').value;\n  resolveAndShow();\n}\n\nfunction resolveAndShow() {\n  const p = PRODS.find(x =>\n    x.cat    === S.cat &&\n    x.format === S.format &&\n    x.sides  === S.sides &&\n    (S.cat !== 'wiz' || x.finish === S.finish)\n  );\n  if (!p) return;\n  S.prod = p.id;\n  showProductDetail(p);\n}\n\n\/* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n   PANEL WYMAGA\u0143\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 *\/\nfunction showProductDetail(p) {\n  const sr = document.getElementById('iv-size-row');\n  if (p.custom) {\n    sr.textContent = T.customSize;\n  } else {\n    const bw = p.w + p.bl * 2, bh = p.h + p.bl * 2;\n    sr.innerHTML = `\ud83d\udcd0 ${p.w}\u00d7${p.h} mm &nbsp;\u00b7&nbsp; ${T.bleedSize(bw, bh)} &nbsp;\u00b7&nbsp; ${T.pages(p.pages)} PDF`;\n  }\n  const rg = document.getElementById('iv-reqs-grid');\n  rg.innerHTML = '';\n  p.reqs.forEach(k => {\n    const chip = document.createElement('div');\n    chip.className = 'iv-req-chip';\n    chip.innerHTML = `<span class=\"iv-req-dot\"><\/span><span>${R(k)}<\/span>`;\n    rg.appendChild(chip);\n  });\n  const nb = document.getElementById('iv-note');\n  const note = p.note ? (PL ? p.note.pl : p.note.en) : null;\n  if (note) { nb.innerHTML = '\ud83d\udca1 ' + note; nb.style.display = 'block'; }\n  else nb.style.display = 'none';\n\n  document.getElementById('iv-detail').style.display  = 'block';\n  document.getElementById('iv-results').style.display = 'none';\n  ivResetFiles();\n  document.getElementById('iv-detail').scrollIntoView({ behavior:'smooth', block:'nearest' });\n}\n\n\/* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n   MULTI-UPLOAD \u2014 zarz\u0105dzanie slotami\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 *\/\n\n\/\/ Ile stron ju\u017c mamy \u0142\u0105cznie z wgranych slot\u00f3w\nfunction ivCountLoadedPages() {\n  const p = PRODS.find(x => x.id === S.prod);\n  if (!p) return 0;\n  let total = 0;\n  for (const slot of S.slots) {\n    total += slot.pageCount || 1;\n  }\n  return total;\n}\n\nfunction ivUpdateUploadUI() {\n  const p = PRODS.find(x => x.id === S.prod);\n  if (!p) return;\n  const required = p.pages;\n  const loaded   = ivCountLoadedPages();\n  const labels   = PL ? p.pl_pg : p.en_pg;\n\n  \/\/ Przerenderuj list\u0119 plik\u00f3w\n  const list = document.getElementById('iv-files-list');\n  list.innerHTML = '';\n  let pageIdx = 0;\n  S.slots.forEach((slot, si) => {\n    const row = document.createElement('div');\n    row.className = 'iv-file-row';\n    \/\/ Etykieta strony\n    const pagesInSlot = slot.pageCount || 1;\n    let slotLabel = '';\n    if (p.pages > 1) {\n      if (pagesInSlot > 1) {\n        slotLabel = labels.slice(pageIdx, pageIdx + pagesInSlot).join(' + ') || `str. ${pageIdx+1}\u2013${pageIdx+pagesInSlot}`;\n      } else {\n        slotLabel = labels[pageIdx] || `str. ${pageIdx+1}`;\n      }\n    }\n    pageIdx += pagesInSlot;\n\n    row.innerHTML = `\n      ${slotLabel ? `<span class=\"iv-file-slot-label\">${slotLabel}<\/span>` : ''}\n      <span class=\"iv-file-ico\">${slot.fileType === 'pdf' ? '\ud83d\udcc4' : '\ud83d\uddbc\ufe0f'}<\/span>\n      <div style=\"flex:1;min-width:0\">\n        <div class=\"iv-file-name\">${escHtml(slot.file.name)}<\/div>\n        <div class=\"iv-file-sz\">${(slot.file.size\/1024).toFixed(0)} KB${slot.pageCount > 1 ? ` \u00b7 ${slot.pageCount} str.` : ''}<\/div>\n      <\/div>\n      <button class=\"iv-file-rm\" onclick=\"ivRemoveSlot(${si})\">\u2715<\/button>`;\n    list.appendChild(row);\n  });\n\n  \/\/ Info o brakuj\u0105cych stronach\n  const missing    = required - loaded;\n  const missingEl  = document.getElementById('iv-missing-info');\n  const missingTxt = document.getElementById('iv-missing-text');\n  const addBtn     = document.getElementById('iv-add-page-btn');\n  const addLbl     = document.getElementById('iv-add-page-label');\n\n  if (loaded === 0) {\n    missingEl.style.display = 'none';\n    addBtn.style.display    = 'none';\n  } else if (missing > 0) {\n    const nextPageLabel = labels[loaded] || (PL ? `Strona ${loaded+1}` : `Page ${loaded+1}`);\n    missingTxt.textContent = PL\n      ? `Wgrano ${loaded} z ${required} stron. Brakuje: ${labels.slice(loaded).join(', ')}.`\n      : `Uploaded ${loaded} of ${required} pages. Missing: ${labels.slice(loaded).join(', ')}.`;\n    missingEl.style.display = 'flex';\n    addLbl.textContent = PL\n      ? `Dodaj: ${nextPageLabel}`\n      : `Add: ${nextPageLabel}`;\n    addBtn.style.display = 'flex';\n  } else {\n    missingEl.style.display = 'none';\n    addBtn.style.display    = 'none';\n  }\n\n  \/\/ G\u0142\u00f3wna dropzone \u2014 ukryj gdy ju\u017c mamy co najmniej jedn\u0105 stron\u0119\n  document.getElementById('iv-dropzone').style.display = loaded === 0 ? 'block' : 'none';\n\n  \/\/ Przycisk Weryfikuj \u2014 aktywny gdy mamy wystarczaj\u0105co stron\n  document.getElementById('iv-btn-v').disabled = loaded < required;\n}\n\nfunction ivRemoveSlot(idx) {\n  S.slots.splice(idx, 1);\n  ivUpdateUploadUI();\n}\n\nfunction ivResetFiles() {\n  S.slots = []; S.rendered = []; S.rendered3d = [];\n  if (S.zoomCleanup) { S.zoomCleanup(); S.zoomCleanup = null; }\n  if (S.af)       { cancelAnimationFrame(S.af); S.af = null; }\n  if (S.renderer) { S.renderer.dispose(); S.renderer = null; }\n  document.getElementById('iv-file').value = '';\n  document.getElementById('iv-files-list').innerHTML = '';\n  document.getElementById('iv-missing-info').style.display = 'none';\n  document.getElementById('iv-add-page-btn').style.display = 'none';\n  document.getElementById('iv-dropzone').style.display     = 'block';\n  document.getElementById('iv-btn-v').disabled = true;\n}\n\nfunction ivTriggerAdd() {\n  document.getElementById('iv-file').click();\n}\n\nasync function ivHandleFile(file) {\n  if (!file) return;\n  const p = PRODS.find(x => x.id === S.prod);\n  if (!p) return;\n\n  const n = file.name.toLowerCase();\n  const isPDF  = n.endsWith('.pdf')  || file.type === 'application\/pdf';\n  const isJPEG = n.endsWith('.jpg')  || n.endsWith('.jpeg');\n  const isTIFF = n.endsWith('.tif')  || n.endsWith('.tiff');\n  const isPNG  = n.endsWith('.png');\n  if (!isPDF && !isJPEG && !isTIFF && !isPNG) { alert(T.errFormat); return; }\n\n  const fileType = isPDF ? 'pdf' : (isJPEG || isPNG) ? 'jpeg' : 'tiff';\n  const buf = await file.arrayBuffer();\n\n  let pageCount = 1;\n  if (isPDF) {\n    try {\n      const doc = await ivGetDocument(buf.slice(0));\n      pageCount = doc.numPages;\n    } catch(e) { pageCount = 1; }\n  }\n\n  const required = p.pages;\n  const loaded   = ivCountLoadedPages();\n  const remaining = required - loaded;\n\n  \/\/ Je\u015bli PDF ma za du\u017co stron \u2014 przyjmij tylko tyle ile brakuje (poinformuj)\n  const actualPages = Math.min(pageCount, remaining > 0 ? remaining : required);\n\n  S.slots.push({ file, fileType, buf, pageCount: actualPages, _rawPages: pageCount });\n  ivUpdateUploadUI();\n}\n\n\/* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n   UPLOAD EVENTS\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 *\/\nconst dz = document.getElementById('iv-dropzone');\ndz.addEventListener('click', () => document.getElementById('iv-file').click());\ndocument.getElementById('iv-dz-link').addEventListener('click', e => {\n  e.stopPropagation(); document.getElementById('iv-file').click();\n});\ndocument.getElementById('iv-file').addEventListener('change', e => {\n  if (e.target.files[0]) { ivHandleFile(e.target.files[0]); e.target.value = ''; }\n});\ndz.addEventListener('dragover', e => { e.preventDefault(); dz.classList.add('drag'); });\ndz.addEventListener('dragleave', () => dz.classList.remove('drag'));\ndz.addEventListener('drop', e => {\n  e.preventDefault(); dz.classList.remove('drag');\n  if (e.dataTransfer.files[0]) ivHandleFile(e.dataTransfer.files[0]);\n});\n\n\/* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n   LOADING\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 *\/\nfunction ivLoad(v, t='') {\n  document.getElementById('iv-loading').style.display = v ? 'flex' : 'none';\n  if (t) document.getElementById('iv-loading-txt').textContent = t;\n}\n\n\/* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n   WERYFIKACJA G\u0141\u00d3WNA\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 *\/\nasync function ivVerify() {\n  const p = PRODS.find(x => x.id === S.prod);\n  if (!p || S.slots.length === 0) return;\n  S.rendered = []; S.rendered3d = [];\n  ivLoad(true, T.loadPDF);\n\n  const issues = [];\n  const allPageLabels = PL ? p.pl_pg : p.en_pg;\n\n  try {\n    \/\/ \u2500\u2500 Zbierz strony ze wszystkich slot\u00f3w \u2500\u2500\n    \/\/ pages[] = [{buf, fileType, file, pageLabel, slotIdx, localPageNum}]\n    const pages = [];\n    let globalPageIdx = 0;\n    for (let si = 0; si < S.slots.length; si++) {\n      const slot = S.slots[si];\n      for (let lp = 0; lp < slot.pageCount; lp++) {\n        pages.push({\n          buf: slot.buf,\n          fileType: slot.fileType,\n          file: slot.file,\n          pageLabel: allPageLabels[globalPageIdx] || `str. ${globalPageIdx+1}`,\n          slotIdx: si,\n          localPageNum: lp + 1,\n          globalIdx: globalPageIdx,\n          isMaskPage: globalPageIdx >= 2 && (p.finish === 'gold' || p.finish === 'uv'),\n        });\n        globalPageIdx++;\n      }\n    }\n\n    \/\/ \u2500\u2500 Renderuj podgl\u0105d dla ka\u017cdej strony \u2500\u2500\n    \/\/ Uwaga: renderWidth obliczamy tutaj wst\u0119pnie; je\u015bli wrap jest ukryty to fallback 800px\n    const getRenderWidth = () => {\n      const w = document.getElementById('iv-preview2d-wrap').clientWidth;\n      return Math.round((w > 50 ? w : 800) * Math.min(devicePixelRatio, 2));\n    };\n\n    for (let pi = 0; pi < pages.length; pi++) {\n      const pg = pages[pi];\n      ivLoad(true, PL ? `Renderowanie strony ${pi+1}\/${pages.length}\u2026` : `Rendering page ${pi+1}\/${pages.length}\u2026`);\n      let fullCanvas;\n      if (pg.fileType === 'pdf') {\n        const doc = await ivGetDocument(pg.buf.slice(0));\n        fullCanvas = await ivRenderPage(doc, pg.localPageNum, getRenderWidth());\n      } else {\n        fullCanvas = await ivRenderRaster(pg.file);\n      }\n      S.rendered.push(fullCanvas);\n\n      \/\/ Wersja przyci\u0119ta do TrimBox \/ trim size \u2014 dla podgl\u0105du 3D\n      \/\/ Dla raster\u00f3w (JPG\/TIFF) brak TrimBox \u2014 ivCropToTrim u\u017cyje wymiar\u00f3w produktu\n      const boxes = pg.fileType === 'pdf' ? parsePDFBoxes(pg.buf, pg.localPageNum) : {mediaBox: null, trimBox: null, bleedBox: null};\n      S.rendered3d.push(ivCropToTrim(fullCanvas, p, boxes));\n    }\n\n    \/\/ \u2500\u2500 Sprawdzenie ka\u017cdej strony \u2500\u2500\n    for (let pi = 0; pi < pages.length; pi++) {\n      const pg = pages[pi];\n      const pageIssues = [];\n\n      if (pg.fileType === 'pdf') {\n        await ivCheckPDFPage(pg, p, pageIssues);\n      } else {\n        await ivCheckRasterPage(pg, p, pageIssues);\n      }\n\n      \/\/ Oznacz wyniki etykiet\u0105 strony (tylko gdy > 1 strona)\n      if (pages.length > 1) {\n        pageIssues.forEach(i => { i.page = pg.pageLabel; });\n      }\n      issues.push(...pageIssues);\n    }\n\n    \/\/ \u2500\u2500 Sprawdzenie \u0142\u0105czne \u2014 liczba stron \u2500\u2500\n    const totalPgs = pages.length;\n    if (totalPgs === p.pages) {\n      issues.unshift({t:'ok',\n        title: PL ? `Liczba stron: ${totalPgs} \u2713` : `Page count: ${totalPgs} \u2713`,\n        desc:  PL ? `Zgodna z wymaganiami (${p.pages} stron).` : `Matches requirements (${p.pages} pages).`\n      });\n    } else if (totalPgs < p.pages) {\n      issues.unshift({t:'warn',\n        title: PL ? `Za ma\u0142o stron: ${totalPgs} z ${p.pages}` : `Too few pages: ${totalPgs} of ${p.pages}`,\n        desc:  PL ? `Brakuje ${p.pages-totalPgs} stron: ${allPageLabels.slice(totalPgs).join(', ')}.`\n                  : `Missing ${p.pages-totalPgs} page(s): ${allPageLabels.slice(totalPgs).join(', ')}.`\n      });\n    } else {\n      issues.unshift({t:'warn',\n        title: PL ? `Za du\u017co stron: ${totalPgs} (wymagane ${p.pages})` : `Too many pages: ${totalPgs} (need ${p.pages})`,\n        desc:  PL ? `U\u017cyte zostanie pierwszych ${p.pages} stron.` : `First ${p.pages} pages will be used.`\n      });\n    }\n\n    ivShowResults(issues, p);\n    ivBuildPreviewTabs(p, pages);\n    \/\/ Rysuj 2D od razu \u2014 wrap jest ju\u017c widoczny po ivShowResults\n    ivDraw2DWhenReady(p, 0);\n    \/\/ 3D osobno \u2014 b\u0142\u0105d Three.js nie mo\u017ce przerwa\u0107 reszty\n    try { ivBuild3D(p); } catch(e3d) { console.warn('3D preview error:', e3d); }\n\n  } catch(e) {\n    console.error(e);\n    ivShowResults([{t:'err', title: T.errLoad, desc: e.message}]);\n  } finally {\n    ivLoad(false);\n  }\n}\n\n\/* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n   SPRAWDZENIE STRONY PDF\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 *\/\nasync function ivCheckPDFPage(pg, prod, issues) {\n  const buf = pg.buf;\n  const rawCS   = scanPDFColorSpaces(buf);\n  const rawImgs = scanPDFImages(buf);\n  const jpegInf = scanJpegInPDF(buf);\n  \/\/ TrimBox\/MediaBox z surowych bajt\u00f3w\n  const boxes   = parsePDFBoxes(buf, pg.localPageNum);\n\n  const doc  = await ivGetDocument(buf.slice(0));\n  const page = await doc.getPage(pg.localPageNum);\n  const vp   = page.getViewport({scale:1});\n\n  \/\/ \u2500\u2500 Pobierz operator list raz \u2014 u\u017cywany przez wiele sprawdze\u0144 \u2500\u2500\n  const OPS = pdfjsLib.OPS;\n  const TEXT_OPS = new Set([OPS.showText, OPS.showSpacedText, OPS.nextLineShowText, OPS.nextLineSetSpacingShowText].filter(Boolean));\n  const RGB_OPS  = new Set([OPS.setFillRGBColor, OPS.setStrokeRGBColor].filter(Boolean));\n  const CMYK_OPS = new Set([OPS.setFillCMYKColor, OPS.setStrokeCMYKColor].filter(Boolean));\n  const ops = await page.getOperatorList();\n  const anyText     = TEXT_OPS.size > 0 && ops.fnArray.some(fn => TEXT_OPS.has(fn));\n  const anyRGB_op   = RGB_OPS.size  > 0 && ops.fnArray.some(fn => RGB_OPS.has(fn));\n  const anyCMYK_op  = CMYK_OPS.size > 0 && ops.fnArray.some(fn => CMYK_OPS.has(fn));\n\n  \/\/ \u2500\u2500 Scal wykrywanie kolor\u00f3w: raw scan (s\u0142owniki) + PDF.js OPS (zdekodowane strumienie) \u2500\u2500\n  const hasCMYK = rawCS.hasCMYK || jpegInf.hasCMYK || anyCMYK_op;\n  \/\/ RGB: prawdziwe je\u015bli ma operatory RGB a nie ma CMYK, lub raw wskazuje RGB\n  const hasRGB  = (anyRGB_op && !anyCMYK_op) ||\n                  (rawCS.hasRGB  && !hasCMYK) ||\n                  (jpegInf.hasRGB && !hasCMYK);\n  const hasGray = rawCS.hasGray || jpegInf.hasGray;\n  const seenDims = new Set(rawImgs.map(d => `${d.w}x${d.h}`));\n  const allImgs  = [...rawImgs, ...jpegInf.dims.filter(d => !seenDims.has(`${d.w}x${d.h}`))];\n\n  \/\/ \u2500\u2500 1a. Wymiary strony \u2500\u2500\n  if (!prod.custom) {\n    const actualW_mm = vp.width  \/ PT;\n    const actualH_mm = vp.height \/ PT;\n    const trimW_mm   = prod.w;\n    const trimH_mm   = prod.h;\n\n    if (Math.abs(actualW_mm - trimW_mm) <= TOL + prod.bl * 2 + 5 &&\n        Math.abs(actualH_mm - trimH_mm) <= TOL + prod.bl * 2 + 5) {\n      \/\/ Rozmiar w rozs\u0105dnym zakresie \u2014 raportuj wymiary po ci\u0119ciu\n      \/\/ (faktyczny trim = rozmiar strony minus spady, ale spady sprawdzamy osobno)\n      issues.push({t:'ok',\n        title: PL ? `Wymiary: ${actualW_mm.toFixed(1)}\u00d7${actualH_mm.toFixed(1)} mm \u2713`\n                  : `Dimensions: ${actualW_mm.toFixed(1)}\u00d7${actualH_mm.toFixed(1)} mm \u2713`,\n        desc:  PL ? `Rozmiar strony odpowiada wizyt\u00f3wce ${trimW_mm}\u00d7${trimH_mm} mm ze spadami.`\n                  : `Page size matches ${trimW_mm}\u00d7${trimH_mm} mm with bleeds.`\n      });\n    } else if (actualW_mm < trimW_mm - TOL || actualH_mm < trimH_mm - TOL) {\n      issues.push({t:'err',\n        title: PL ? `Wymiary: plik za ma\u0142y \u2014 ${actualW_mm.toFixed(1)}\u00d7${actualH_mm.toFixed(1)} mm`\n                  : `Dimensions: file too small \u2014 ${actualW_mm.toFixed(1)}\u00d7${actualH_mm.toFixed(1)} mm`,\n        desc:  PL ? `Minimalny rozmiar to ${trimW_mm}\u00d7${trimH_mm} mm (po ci\u0119ciu) + spady min. ${prod.bl} mm z ka\u017cdej strony = co najmniej ${trimW_mm + prod.bl*2}\u00d7${trimH_mm + prod.bl*2} mm.`\n                  : `Minimum size is ${trimW_mm}\u00d7${trimH_mm} mm (trim) + bleeds min. ${prod.bl} mm each side = at least ${trimW_mm + prod.bl*2}\u00d7${trimH_mm + prod.bl*2} mm.`\n      });\n    } else {\n      issues.push({t:'ok',\n        title: PL ? `Wymiary: ${actualW_mm.toFixed(1)}\u00d7${actualH_mm.toFixed(1)} mm \u2713`\n                  : `Dimensions: ${actualW_mm.toFixed(1)}\u00d7${actualH_mm.toFixed(1)} mm \u2713`,\n        desc:  PL ? `Rozmiar strony w porz\u0105dku (docelowo ${trimW_mm}\u00d7${trimH_mm} mm po ci\u0119ciu).`\n                  : `Page size OK (target trim: ${trimW_mm}\u00d7${trimH_mm} mm).`\n      });\n    }\n\n    \/\/ \u2500\u2500 1b. Spady \u2500\u2500\n    const mb = boxes.mediaBox;\n    const tb = boxes.trimBox;\n\n    if (tb && mb) {\n      \/\/ Precyzyjna kalkulacja z TrimBox\n      const bL = (tb.x1 - mb.x1) \/ PT;\n      const bB = (tb.y1 - mb.y1) \/ PT;\n      const bR = (mb.x2 - tb.x2) \/ PT;\n      const bT = (mb.y2 - tb.y2) \/ PT;\n      const minBleed = Math.min(bL, bB, bR, bT);\n      if (minBleed >= prod.bl - TOL) {\n        const overNote = minBleed > prod.bl + TOL\n          ? (PL ? ` (wi\u0119ksze ni\u017c ${prod.bl} mm \u2014 OK)` : ` (larger than ${prod.bl} mm \u2014 OK)`)\n          : '';\n        issues.push({t:'ok',\n          title: PL ? `Spady: L=${bL.toFixed(1)} T=${bT.toFixed(1)} R=${bR.toFixed(1)} B=${bB.toFixed(1)} mm \u2713`\n                    : `Bleeds: L=${bL.toFixed(1)} T=${bT.toFixed(1)} R=${bR.toFixed(1)} B=${bB.toFixed(1)} mm \u2713`,\n          desc:  PL ? `Min. ${minBleed.toFixed(1)} mm \u2014 wymagane min. ${prod.bl} mm z ka\u017cdej strony${overNote}.`\n                    : `Min. ${minBleed.toFixed(1)} mm \u2014 required min. ${prod.bl} mm each side${overNote}.`\n        });\n      } else {\n        issues.push({t:'err',\n          title: PL ? `Spady za ma\u0142e: min. ${minBleed.toFixed(1)} mm (wymagane ${prod.bl} mm)`\n                    : `Bleeds too small: min. ${minBleed.toFixed(1)} mm (required ${prod.bl} mm)`,\n          desc:  PL ? `Spady: L=${bL.toFixed(1)} T=${bT.toFixed(1)} R=${bR.toFixed(1)} B=${bB.toFixed(1)} mm. Ka\u017cda strona musi mie\u0107 min. ${prod.bl} mm spadu.`\n                    : `Bleeds: L=${bL.toFixed(1)} T=${bT.toFixed(1)} R=${bR.toFixed(1)} B=${bB.toFixed(1)} mm. Each side needs min. ${prod.bl} mm bleed.`\n        });\n      }\n    } else {\n      \/\/ Brak TrimBox \u2014 szacuj z rozmiaru strony\n      const actualW_mm2 = vp.width  \/ PT;\n      const actualH_mm2 = vp.height \/ PT;\n      const estBleed    = Math.min((actualW_mm2 - prod.w) \/ 2, (actualH_mm2 - prod.h) \/ 2);\n      if (estBleed >= prod.bl - TOL) {\n        issues.push({t:'warn',\n          title: PL ? `Spady: ~${estBleed.toFixed(1)} mm (brak TrimBox)`\n                    : `Bleeds: ~${estBleed.toFixed(1)} mm (no TrimBox)`,\n          desc:  PL ? `Plik nie zawiera TrimBox \u2014 spady szacowane z rozmiaru strony. Nie mo\u017cna zweryfikowa\u0107 czy obszar spadu jest wype\u0142niony grafik\u0105.`\n                    : `No TrimBox \u2014 bleeds estimated from page size. Cannot verify if bleed area is filled with artwork.`\n        });\n      } else if (estBleed <= 0) {\n        issues.push({t:'err',\n          title: PL ? `Spady: brak (plik ma rozmiar po ci\u0119ciu ${prod.w}\u00d7${prod.h} mm)`\n                    : `Bleeds: missing (file has trim size ${prod.w}\u00d7${prod.h} mm)`,\n          desc:  PL ? `Dodaj ${prod.bl} mm spadu z ka\u017cdej strony. Wymagany rozmiar pliku: co najmniej ${prod.w + prod.bl*2}\u00d7${prod.h + prod.bl*2} mm.`\n                    : `Add ${prod.bl} mm bleed on each side. Required file size: at least ${prod.w + prod.bl*2}\u00d7${prod.h + prod.bl*2} mm.`\n        });\n      } else {\n        issues.push({t:'err',\n          title: PL ? `Spady za ma\u0142e: ~${estBleed.toFixed(1)} mm (wymagane ${prod.bl} mm)`\n                    : `Bleeds too small: ~${estBleed.toFixed(1)} mm (required ${prod.bl} mm)`,\n          desc:  PL ? `Brak TrimBox. Szacowany spad ~${estBleed.toFixed(1)} mm jest mniejszy ni\u017c wymagane ${prod.bl} mm. Wymagany rozmiar: co najmniej ${prod.w + prod.bl*2}\u00d7${prod.h + prod.bl*2} mm.`\n                    : `No TrimBox. Estimated bleed ~${estBleed.toFixed(1)} mm is less than required ${prod.bl} mm. Required size: at least ${prod.w + prod.bl*2}\u00d7${prod.h + prod.bl*2} mm.`\n        });\n      }\n    }\n\n    \/\/ \u2500\u2500 1c. Margines bezpieczny \u2500\u2500\n    \/\/ Margines nie jest weryfikowalny automatycznie (wymaga analizy zawarto\u015bci)\n    \/\/ Informujemy klienta o wymaganiu\n    issues.push({t:'info',\n      title: PL ? `Margines bezpieczny: min. ${prod.margin} mm od kraw\u0119dzi ci\u0119cia`\n                : `Safe margin: min. ${prod.margin} mm from cut line`,\n      desc:  PL ? `Wa\u017cne elementy (teksty, logo) powinny znajdowa\u0107 si\u0119 min. ${prod.margin} mm od linii ci\u0119cia. Automatyczna weryfikacja marginesu nie jest mo\u017cliwa \u2014 sprawd\u017a wizualnie na podgl\u0105dzie.`\n                : `Important elements (text, logo) must be at least ${prod.margin} mm from the cut line. Automatic margin verification is not possible \u2014 check visually in the preview.`\n    });\n\n  } else {\n    \/\/ Baner \u2014 poka\u017c wykryty rozmiar\n    issues.push({t:'info',\n      title: PL ? `Wymiary: ${(vp.width\/PT).toFixed(0)}\u00d7${(vp.height\/PT).toFixed(0)} mm`\n                : `Dimensions: ${(vp.width\/PT).toFixed(0)}\u00d7${(vp.height\/PT).toFixed(0)} mm`,\n      desc:  PL ? 'Upewnij si\u0119, \u017ce rozmiar zgodny z zam\u00f3wieniem (skala 1:1).'\n                : 'Verify the size matches your order (scale 1:1).'\n    });\n    issues.push({t:'info',\n      title: PL ? `Margines bezpieczny: min. ${prod.margin} mm od kraw\u0119dzi`\n                : `Safe margin: min. ${prod.margin} mm from edge`,\n      desc:  PL ? `Wa\u017cne elementy powinny znajdowa\u0107 si\u0119 min. ${prod.margin} mm od kraw\u0119dzi. Sprawd\u017a wizualnie.`\n                : `Important elements must be at least ${prod.margin} mm from the edge. Check visually.`\n    });\n  }\n\n  \/\/ \u2500\u2500 2. Teksty zamienione na krzywe \u2500\u2500\n\n  if (anyText) {\n    issues.push({t:'err',\n      title: PL ? 'Teksty NIE zamienione na krzywe!' : 'Texts NOT converted to curves!',\n      desc:  PL ? 'Wykryto operatory rysowania tekstu (Tj\/TJ). Zamie\u0144 wszystkie teksty na krzywe w programie graficznym przed wys\u0142aniem do druku.'\n                : 'Text drawing operators (Tj\/TJ) detected. Convert all texts to curves in your graphics app before sending to print.'\n    });\n  } else {\n    issues.push({t:'ok',\n      title: PL ? 'Teksty zamienione na krzywe \u2713' : 'Texts converted to curves \u2713',\n      desc:  PL ? 'Nie wykryto operator\u00f3w rysowania tekstu. Teksty zamienione na krzywe.'\n                : 'No text drawing operators found. Texts converted to curves.'\n    });\n  }\n\n  \/\/ \u2500\u2500 3. Kolorystyka \u2500\u2500\n  const isMaskPage = pg.isMaskPage;\n  if (isMaskPage) {\n    \/\/ Strona maski \u2014 sprawd\u017a czy jest tylko K=100\n    const richBlackInfo = detectRichBlack(ops);\n    if (richBlackInfo.hasRichBlack) {\n      issues.push({t:'err',\n        title: PL ? 'Maska: wykryto kolor inny ni\u017c K=100%!' : 'Mask: color other than K=100% detected!',\n        desc:  PL ? `Strony masek uszlachetnie\u0144 musz\u0105 zawiera\u0107 wy\u0142\u0105cznie czarny K=100%. Wykryto: ${richBlackInfo.detail}.`\n                  : `Finishing mask pages must contain only K=100% black. Detected: ${richBlackInfo.detail}.`\n      });\n    } else {\n      issues.push({t:'ok',\n        title: PL ? 'Maska: tylko K=100% \u2713' : 'Mask: K=100% only \u2713',\n        desc:  PL ? 'Strona maski zawiera wy\u0142\u0105cznie czarny K=100% \u2014 prawid\u0142owo.'\n                  : 'Mask page contains only K=100% black \u2014 correct.'\n      });\n    }\n  } else {\n    \/\/ Zwyk\u0142a strona graficzna\n    if (!hasCMYK && hasRGB) {\n      issues.push({t:'err',\n        title: PL ? 'Kolorystyka RGB \u2014 wymagane CMYK!' : 'RGB colors \u2014 CMYK required!',\n        desc:  PL ? 'Plik zawiera przestrze\u0144 barw RGB bez CMYK. Przekonwertuj ca\u0142y dokument do CMYK przed wys\u0142aniem do druku.'\n                  : 'File uses RGB color space without CMYK. Convert the entire document to CMYK before sending to print.'\n      });\n    } else if (hasCMYK && hasRGB) {\n      issues.push({t:'warn',\n        title: PL ? 'Mieszana przestrze\u0144 barw (CMYK + RGB)' : 'Mixed color space (CMYK + RGB)',\n        desc:  PL ? 'Plik zawiera elementy CMYK i RGB. Elementy RGB mog\u0105 by\u0107 automatycznie skonwertowane podczas druku z r\u00f3\u017cnym rezultatem \u2014 zalecamy pe\u0142n\u0105 konwersj\u0119 do CMYK.'\n                  : 'File has both CMYK and RGB elements. RGB elements may be auto-converted during print with varying results \u2014 full CMYK conversion recommended.'\n      });\n    } else if (hasCMYK) {\n      \/\/ Sprawd\u017a rich black (tylko K=100 to dobre, mieszany czarny to problem)\n      const rbInfo = detectRichBlack(ops);\n      if (rbInfo.hasRichBlack) {\n        issues.push({t:'warn',\n          title: PL ? 'CMYK \u2713 \u2014 uwaga: \"bogaty czarny\" (rich black)' : 'CMYK \u2713 \u2014 note: rich black detected',\n          desc:  PL ? 'Wykryto czarny sk\u0142adaj\u0105cy si\u0119 z mieszanki CMYK (np. C:100 M:100 Y:100 K:100). W druku offsetowym mo\u017ce powodowa\u0107 problemy z pasowaniem. Zalecamy u\u017cycie K=100% dla czarnych tekst\u00f3w i linii.'\n                    : 'Black composed of mixed CMYK values detected (e.g. C:100 M:100 Y:100 K:100). In offset printing this can cause misregistration. Use K=100% for black text and lines.'\n        });\n      } else {\n        issues.push({t:'ok',\n          title: PL ? 'Kolorystyka CMYK \u2713' : 'CMYK color space \u2713',\n          desc:  PL ? 'Wykryto przestrze\u0144 CMYK \u2014 plik gotowy do druku pod k\u0105tem kolorystyki.'\n                    : 'CMYK color space detected \u2014 file is print-ready in terms of color.'\n        });\n      }\n    } else if (hasGray && !hasRGB) {\n      issues.push({t:'ok',\n        title: PL ? 'Skala szaro\u015bci \u2713' : 'Grayscale \u2713',\n        desc:  PL ? 'Wykryto tylko skal\u0119 szaro\u015bci \u2014 brak element\u00f3w kolorowych. Plik nadaje si\u0119 do druku.'\n                  : 'Grayscale only \u2014 no color elements found. Suitable for printing.'\n      });\n    } else {\n      issues.push({t:'info',\n        title: PL ? 'Przestrze\u0144 barw: brak deklaracji' : 'Color space: no declaration',\n        desc:  PL ? 'Nie wykryto deklaracji przestrzeni barw. Plik prawdopodobnie czysto wektorowy \u2014 upewnij si\u0119, \u017ce w programie graficznym ustawiona jest przestrze\u0144 CMYK.'\n                  : 'No color space declaration found. Likely pure vector \u2014 ensure CMYK mode is set in your graphics app.'\n      });\n    }\n  }\n\n  \/\/ \u2500\u2500 4. DPI \u2500\u2500\n  \/\/ Metoda: raw scan daje wymiary obraz\u00f3w (w pikselach), CTM ze stream\u00f3w daje skal\u0119 w pt.\n  \/\/ DPI = pixelW * 72 \/ renderWidthPt\n  \/\/ Dla ka\u017cdego obrazu szukamy najbli\u017cszego pasuj\u0105cego CTM transform() przed paintImageXObject\n  let lowestDPI = Infinity, lowestDPINote = '';\n\n  \/\/ Metoda 1: CTM + paintImageXObject \u2014 \u015bled\u017a macierz transformacji\n  {\n    const fnArr = ops.fnArray, argsArr = ops.argsArray;\n    \/\/ Stos CTM (q\/Q push\/pop)\n    let ctmStack = [[1,0,0,1,0,0]];\n    let curCTM   = [1,0,0,1,0,0];\n    const mulCTM = (a, b) => [\n      a[0]*b[0]+a[2]*b[1], a[1]*b[0]+a[3]*b[1],\n      a[0]*b[2]+a[2]*b[3], a[1]*b[2]+a[3]*b[3],\n      a[0]*b[4]+a[2]*b[5]+a[4], a[1]*b[4]+a[3]*b[5]+a[5]\n    ];\n    for (let i = 0; i < fnArr.length; i++) {\n      const fn = fnArr[i];\n      if (fn === OPS.save)      { ctmStack.push([...curCTM]); }\n      else if (fn === OPS.restore && ctmStack.length > 1) { curCTM = ctmStack.pop(); }\n      else if (fn === OPS.transform) { curCTM = mulCTM(curCTM, argsArr[i]); }\n      else if (fn === OPS.paintImageXObject || fn === OPS.paintImageMaskXObject) {\n        \/\/ Skala: sx = d\u0142ugo\u015b\u0107 wektora [a,b], sy = d\u0142ugo\u015b\u0107 [c,d]\n        const sx = Math.sqrt(curCTM[0]*curCTM[0] + curCTM[1]*curCTM[1]);\n        const sy = Math.sqrt(curCTM[2]*curCTM[2] + curCTM[3]*curCTM[3]);\n        if (sx > 0 && sy > 0) {\n          \/\/ Znajd\u017a pasuj\u0105cy obraz po wymiarach \u2014 CTM [sx,sy] to rozmiar obrazu w pt\n          \/\/ szukamy obrazu kt\u00f3rego proporcje pasuj\u0105 do CTM\n          for (const img of allImgs) {\n            const dpiX = img.w * 72 \/ Math.max(sx, 1);\n            const dpiY = img.h * 72 \/ Math.max(sy, 1);\n            const dpi  = Math.min(dpiX, dpiY);\n            if (dpi > 10 && dpi < 3000 && dpi < lowestDPI) {\n              lowestDPI = dpi;\n            }\n          }\n        }\n      }\n    }\n  }\n\n  \/\/ Metoda 2: fallback \u2014 bez CTM, zak\u0142adamy obraz zajmuje ca\u0142\u0105 stron\u0119\n  \/\/ Daje szacunek z g\u00f3ry (mo\u017ce by\u0107 zawy\u017cony je\u015bli obraz jest mniejszy ni\u017c strona)\n  if (lowestDPI === Infinity && allImgs.length > 0) {\n    const pageW_in = vp.width  \/ 72;\n    const pageH_in = vp.height \/ 72;\n    for (const img of allImgs) {\n      const estDPI = Math.min(img.w \/ pageW_in, img.h \/ pageH_in);\n      if (estDPI > 10 && estDPI < 3000 && estDPI < lowestDPI) {\n        lowestDPI = estDPI;\n        lowestDPINote = PL ? ' (szacowane)' : ' (estimated)';\n      }\n    }\n  }\n\n  if (lowestDPI === Infinity) {\n    issues.push({t:'info',\n      title: PL ? 'Brak map bitowych \u2014 plik wektorowy' : 'No bitmaps \u2014 vector file',\n      desc:  PL ? 'Nie wykryto obraz\u00f3w rastrowych. Je\u015bli projekt zawiera zdj\u0119cia, sprawd\u017a DPI w programie graficznym.'\n                : 'No raster images detected. If the design contains photos, verify DPI in your graphics app.'\n    });\n  } else if (lowestDPI < 200) {\n    issues.push({t:'err',\n      title: PL ? `Zbyt niska rozdzielczo\u015b\u0107: ~${Math.round(lowestDPI)} DPI!${lowestDPINote}`\n                : `Resolution too low: ~${Math.round(lowestDPI)} DPI!${lowestDPINote}`,\n      desc:  PL ? 'Wymagane minimum 300 DPI. Obraz b\u0119dzie rozmazany po wydruku.'\n                : 'Minimum 300 DPI required. Image will appear blurry when printed.'\n    });\n  } else if (lowestDPI < 300) {\n    issues.push({t:'warn',\n      title: PL ? `Niska rozdzielczo\u015b\u0107: ~${Math.round(lowestDPI)} DPI${lowestDPINote}`\n                : `Low resolution: ~${Math.round(lowestDPI)} DPI${lowestDPINote}`,\n      desc:  PL ? 'Zalecane minimum to 300 DPI. Jako\u015b\u0107 wydruku mo\u017ce by\u0107 obni\u017cona.'\n                : 'Recommended minimum is 300 DPI. Print quality may be reduced.'\n    });\n  } else {\n    issues.push({t:'ok',\n      title: PL ? `Rozdzielczo\u015b\u0107: min. ~${Math.round(lowestDPI)} DPI \u2713${lowestDPINote}`\n                : `Resolution: min. ~${Math.round(lowestDPI)} DPI \u2713${lowestDPINote}`,\n      desc:  PL ? 'Wykryte mapy bitowe maj\u0105 wystarczaj\u0105c\u0105 rozdzielczo\u015b\u0107 do druku.'\n                : 'Detected bitmaps have sufficient resolution for printing.'\n    });\n  }\n\n  \/\/ \u2500\u2500 5. Transparentno\u015b\u0107 \u2500\u2500\n  const hasTransparency = checkTransparency(buf);\n  if (hasTransparency) {\n    issues.push({t:'warn',\n      title: PL ? 'Wykryto transparentno\u015b\u0107' : 'Transparency detected',\n      desc:  PL ? 'Plik zawiera obszary transparentne. Upewnij si\u0119, \u017ce transparentno\u015b\u0107 jest sp\u0142aszczona lub prawid\u0142owo obs\u0142ugiwana przez RIP drukarni.'\n                : 'File contains transparent areas. Ensure transparency is flattened or properly handled by the printer\\'s RIP.'\n    });\n  }\n}\n\n\/* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n   SPRAWDZENIE STRONY RASTER (JPG\/TIFF)\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 *\/\nasync function ivCheckRasterPage(pg, prod, issues) {\n  \/\/ Strony masek uszlachetnie\u0144 (z\u0142ocenie\/UV) MUSZ\u0104 by\u0107 PDF \u2014 nie rastrami\n  if (pg.isMaskPage) {\n    issues.push({t:'err',\n      title: PL ? 'Maska uszlachetnienia musi by\u0107 plikiem PDF!'\n                : 'Finishing mask must be a PDF file!',\n      desc:  PL ? 'Strony masek z\u0142ocenia i lakieru UV musz\u0105 by\u0107 przygotowane jako wektorowy PDF z tekstem zamienionym na krzywe. Plik rastrowy (JPG\/PNG\/TIFF) nie jest akceptowany dla masek.'\n                : 'Gold foil and UV varnish mask pages must be prepared as vector PDF with texts converted to curves. Raster files (JPG\/PNG\/TIFF) are not accepted for masks.'\n    });\n    return; \/\/ nie sprawdzaj dalej \u2014 plik jest fundamentalnie niepoprawny\n  }\n\n  const view = new DataView(pg.buf);\n  let detectedDPI = null, colorSpace = null;\n  if (pg.fileType === 'jpeg') {\n    ({ dpi: detectedDPI, colorSpace } = parseJpegMeta(view));\n  } else {\n    ({ dpi: detectedDPI, colorSpace } = parseTiffMeta(view));\n  }\n\n  \/\/ Info o formacie\n  issues.push({t:'info',\n    title: PL ? 'Format rastrowy (JPG\/PNG\/TIFF)' : 'Raster format (JPG\/PNG\/TIFF)',\n    desc:  PL ? 'Pliki rastrowe s\u0105 akceptowane. Dla najlepszej jako\u015bci zalecamy format PDF wektorowy.'\n              : 'Raster files are accepted. For best quality, vector PDF format is recommended.'\n  });\n\n  \/\/ DPI\n  if (detectedDPI === null) {\n    issues.push({t:'warn',\n      title: PL ? 'Rozdzielczo\u015b\u0107: brak metadanych DPI' : 'Resolution: no DPI metadata',\n      desc:  PL ? 'Plik nie zawiera informacji o DPI. Sprawd\u017a rozdzielczo\u015b\u0107 w programie graficznym \u2014 wymagane minimum 300 DPI.'\n                : 'No DPI metadata in file. Check resolution in your graphics software \u2014 minimum 300 DPI required.'\n    });\n  } else if (detectedDPI < 200) {\n    issues.push({t:'err',\n      title: PL ? `Zbyt niska rozdzielczo\u015b\u0107: ${Math.round(detectedDPI)} DPI!` : `Resolution too low: ${Math.round(detectedDPI)} DPI!`,\n      desc:  PL ? 'Wymagane minimum 300 DPI. Obraz b\u0119dzie rozmazany po wydruku.' : 'Minimum 300 DPI required. Image will appear blurry when printed.'\n    });\n  } else if (detectedDPI < 300) {\n    issues.push({t:'warn',\n      title: PL ? `Niska rozdzielczo\u015b\u0107: ${Math.round(detectedDPI)} DPI` : `Low resolution: ${Math.round(detectedDPI)} DPI`,\n      desc:  PL ? 'Zalecane minimum to 300 DPI.' : 'Recommended minimum is 300 DPI.'\n    });\n  } else {\n    issues.push({t:'ok',\n      title: PL ? `Rozdzielczo\u015b\u0107: ${Math.round(detectedDPI)} DPI \u2713` : `Resolution: ${Math.round(detectedDPI)} DPI \u2713`,\n      desc:  PL ? 'Rozdzielczo\u015b\u0107 wystarczaj\u0105ca do druku.' : 'Sufficient resolution for printing.'\n    });\n  }\n\n  \/\/ Kolorystyka\n  if (colorSpace === 'cmyk') {\n    issues.push({t:'ok', title: PL ? 'Kolorystyka CMYK \u2713' : 'CMYK \u2713',\n      desc: PL ? 'Plik jest w przestrzeni CMYK.' : 'File is in CMYK color space.'});\n  } else if (colorSpace === 'rgb') {\n    issues.push({t:'err', title: PL ? 'Kolorystyka RGB \u2014 wymagane CMYK!' : 'RGB \u2014 CMYK required!',\n      desc: PL ? 'Plik jest w trybie RGB. Zapisz jako CMYK przed wys\u0142aniem do druku.' : 'File is RGB. Save as CMYK before sending to print.'});\n  } else if (colorSpace === 'gray') {\n    issues.push({t:'ok', title: PL ? 'Skala szaro\u015bci \u2713' : 'Grayscale \u2713',\n      desc: PL ? 'Plik jest w skali szaro\u015bci.' : 'File is in grayscale.'});\n  } else {\n    issues.push({t:'info', title: PL ? 'Przestrze\u0144 barw: nieznana' : 'Color space: unknown',\n      desc: PL ? 'Nie uda\u0142o si\u0119 wykry\u0107 przestrzeni barw. Sprawd\u017a w programie graficznym.' : 'Could not detect color space.'});\n  }\n  issues.push({t:'info',\n    title: PL ? 'Teksty na krzywe: nie dotyczy' : 'Text as curves: N\/A',\n    desc:  PL ? 'Pliki rastrowe s\u0105 sp\u0142aszczone \u2014 zamiana tekst\u00f3w na krzywe nie jest wymagana.'\n              : 'Raster files are flattened \u2014 text-to-curves conversion not required.'\n  });\n}\n\n\/* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n   PARSERY METADANYCH\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 *\/\n\n\/\/ JPEG\nfunction parseJpegMeta(view) {\n  if (view.byteLength < 4 || view.getUint16(0) !== 0xFFD8) return {dpi:null, colorSpace:null};\n  let pos = 2, dpi = null, colorSpace = null;\n  while (pos < view.byteLength - 3) {\n    if (view.getUint8(pos) !== 0xFF) break;\n    const marker = view.getUint8(pos + 1);\n    if (marker === 0xD9 || marker === 0xDA) break;\n    const segLen = view.getUint16(pos + 2, false);\n    if (marker === 0xE0 && segLen >= 14 && dpi === null) {\n      const units = view.getUint8(pos + 11);\n      const xd = view.getUint16(pos + 12, false), yd = view.getUint16(pos + 14, false);\n      if (units === 1 && xd > 0) dpi = Math.min(xd, yd);\n      else if (units === 2 && xd > 0) dpi = Math.min(xd, yd) * 2.54;\n    } else if (marker === 0xEE && segLen >= 14) {\n      const cs = view.getUint8(pos + 15);\n      colorSpace = (cs === 2 || cs === 3) ? 'cmyk' : 'rgb';\n    } else if ((marker === 0xC0 || marker === 0xC1 || marker === 0xC2) && segLen >= 11 && colorSpace === null) {\n      const nComp = view.getUint8(pos + 9);\n      if (nComp === 4) colorSpace = 'cmyk';\n      else if (nComp === 3) colorSpace = 'rgb';\n      else if (nComp === 1) colorSpace = 'gray';\n    }\n    pos += 2 + segLen;\n  }\n  return {dpi, colorSpace};\n}\n\n\/\/ TIFF\nfunction parseTiffMeta(view) {\n  if (view.byteLength < 8) return {dpi:null, colorSpace:null};\n  const bo = view.getUint16(0, false);\n  if (bo !== 0x4949 && bo !== 0x4D4D) return {dpi:null, colorSpace:null};\n  const le = bo === 0x4949;\n  if (view.getUint16(2, le) !== 42) return {dpi:null, colorSpace:null};\n  const ifdOff = view.getUint32(4, le);\n  if (ifdOff + 2 > view.byteLength) return {dpi:null, colorSpace:null};\n  const nEntries = view.getUint16(ifdOff, le);\n  let xRes = null, yRes = null, resUnit = 2, photoInterp = null;\n  for (let i = 0; i < nEntries; i++) {\n    const eOff = ifdOff + 2 + i * 12;\n    if (eOff + 12 > view.byteLength) break;\n    const tag = view.getUint16(eOff, le);\n    if (tag === 282 || tag === 283) {\n      const valOff = view.getUint32(eOff + 8, le);\n      if (valOff + 8 <= view.byteLength) {\n        const num = view.getUint32(valOff, le), den = view.getUint32(valOff + 4, le);\n        if (den) { if (tag === 282) xRes = num\/den; else yRes = num\/den; }\n      }\n    } else if (tag === 296) { resUnit = view.getUint16(eOff + 8, le); }\n      else if (tag === 262) { photoInterp = view.getUint16(eOff + 8, le); }\n  }\n  let dpi = null;\n  if (xRes !== null || yRes !== null) {\n    dpi = Math.min(xRes ?? yRes, yRes ?? xRes);\n    if (resUnit === 3) dpi *= 2.54;\n  }\n  let colorSpace = null;\n  if      (photoInterp === 5) colorSpace = 'cmyk';\n  else if (photoInterp === 2) colorSpace = 'rgb';\n  else if (photoInterp === 0 || photoInterp === 1) colorSpace = 'gray';\n  return {dpi, colorSpace};\n}\n\n\/* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n   RAW PDF SCANNERS\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 *\/\nfunction scanPDFColorSpaces(buf) {\n  const str = new TextDecoder('latin1').decode(new Uint8Array(buf));\n\n  \/\/ \u2500\u2500 1. Operatory kolor\u00f3w w streamach \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n  \/\/ Operator 'k'\/'K' = CMYK (implicit \/DeviceCMYK) \u2014 u\u017cywany przez Corel, Illustrator\n  \/\/ Operator 'rg'\/'RG' = RGB, 'g'\/'G' = Gray\n  \/\/ Skanujemy tylko potencjalne streamy (szukamy charakterystycznych wzorc\u00f3w)\n  \/\/ Format: C M Y K k  lub  C M Y K K  (4 liczby zmiennoprzecinkowe przed liter\u0105)\n  const kRe  = \/[\\d.]+\\s+[\\d.]+\\s+[\\d.]+\\s+[\\d.]+\\s+[kK]\\b\/;\n  const rgRe = \/[\\d.]+\\s+[\\d.]+\\s+[\\d.]+\\s+[rR][gG]\\b\/;\n  const gRe  = \/[\\d.]+\\s+[gG]\\b\/;\n  const hasOp_k  = kRe.test(str);\n  const hasOp_rg = rgRe.test(str);\n  const hasOp_g  = gRe.test(str);\n\n  \/\/ \u2500\u2500 2. Deklaracje przestrzeni barw w s\u0142ownikach (obrazy, zasoby strony) \u2500\u2500\n  const hasCMYK_dev = str.includes('\/DeviceCMYK');\n\n  \/\/ \/DeviceRGB: filtrujemy fa\u0142szywe trafienia \u2014 w PDF\/X plik mo\u017ce zawiera\u0107\n  \/\/ \/DeviceRGB w OutputIntent lub metadanych nawet gdy druk jest CMYK.\n  \/\/ Liczymy wyst\u0105pienia POZA kontekstem OutputIntent\/Metadata.\n  let hasRGB_dev = false;\n  {\n    const re = \/\\\/DeviceRGB\/g; let m;\n    while ((m = re.exec(str)) !== null) {\n      \/\/ Sprawd\u017a kontekst \u2014 je\u015bli nie jeste\u015bmy w OutputIntent\/Metadata, to prawdziwy RGB\n      const ctx = str.slice(Math.max(0, m.index - 200), m.index + 20);\n      if (!ctx.includes('\/OutputIntent') && !ctx.includes('\/Metadata') && !ctx.includes('DestOutputProfile')) {\n        hasRGB_dev = true; break;\n      }\n    }\n  }\n\n  const hasGray_dev = str.includes('\/DeviceGray');\n  const hasCalRGB   = str.includes('\/CalRGB');\n  const hasCalGray  = str.includes('\/CalGray');\n\n  \/\/ \u2500\u2500 3. Profile ICC \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n  let hasICC_CMYK = false, hasICC_RGB = false, hasICC_Gray = false;\n  const iccRe = \/\\\/ICCBased\\b\/g; let iccM;\n  while ((iccM = iccRe.exec(str)) !== null) {\n    const r = str.slice(iccM.index, Math.min(iccM.index + 400, str.length));\n    const n = r.match(\/\\\/N\\s+(\\d+)\/);\n    if (n) {\n      const ch = parseInt(n[1]);\n      if (ch === 4) hasICC_CMYK = true;\n      if (ch === 3) hasICC_RGB  = true;\n      if (ch === 1) hasICC_Gray = true;\n    }\n  }\n\n  \/\/ \u2500\u2500 4. Scal wyniki \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n  \/\/ CMYK: operator k\/K LUB \/DeviceCMYK LUB ICCBased N=4\n  const hasCMYK = hasCMYK_dev || hasICC_CMYK || hasOp_k;\n  \/\/ RGB: \/DeviceRGB (poza metadanymi) LUB operator rg\/RG LUB CalRGB\/ICCBased N=3\n  \/\/ Ale ICCBased N=3 mo\u017ce by\u0107 profilem CMYK blending space \u2014 odrzucamy je\u015bli mamy CMYK\n  const hasRGB  = hasRGB_dev || hasOp_rg || hasCalRGB || (hasICC_RGB && !hasCMYK_dev && !hasOp_k);\n  const hasGray = hasGray_dev || hasCalGray || hasICC_Gray || hasOp_g;\n\n  return { hasCMYK, hasRGB, hasGray };\n}\n\nfunction scanPDFImages(buf) {\n  const str = new TextDecoder('latin1').decode(new Uint8Array(buf));\n  const imgs = [], seen = new Set();\n  const re = \/\\\/Subtype\\s*\\\/Image\\b\/g; let m;\n  while ((m = re.exec(str)) !== null) {\n    const region = str.slice(Math.max(0, m.index - 600), m.index + 1200);\n    const wm = region.match(\/\\\/Width\\s+(\\d+)\/), hm = region.match(\/\\\/Height\\s+(\\d+)\/);\n    if (wm && hm) {\n      const w = parseInt(wm[1]), h = parseInt(hm[1]);\n      const key = `${w}x${h}`;\n      if (w > 15 && h > 15 && !seen.has(key)) { seen.add(key); imgs.push({w, h}); }\n    }\n  }\n  return imgs;\n}\n\nfunction scanJpegInPDF(buf) {\n  const bytes  = new Uint8Array(buf);\n  const result = { found: false, hasCMYK: false, hasRGB: false, hasGray: false, dims: [] };\n  const seen   = new Set();\n  let   i      = 0;\n  while (i < bytes.length - 3) {\n    if (!(bytes[i] === 0xFF && bytes[i+1] === 0xD8 && bytes[i+2] === 0xFF)) { i++; continue; }\n    result.found = true;\n    let pos = i + 2, sofFound = false, w = 0, h = 0, comps = 0, nextI = i + 1;\n    markerLoop: while (pos < bytes.length - 1) {\n      if (bytes[pos] !== 0xFF) break;\n      while (pos < bytes.length && bytes[pos] === 0xFF) pos++;\n      if (pos >= bytes.length) break;\n      const marker = bytes[pos++];\n      if (marker === 0xD9 || marker === 0xDA) { nextI = pos; break; }\n      if (marker === 0xD8 || (marker >= 0xD0 && marker <= 0xD7) || marker === 0x00) continue;\n      if (pos + 1 >= bytes.length) break;\n      const segLen = (bytes[pos] << 8) | bytes[pos+1];\n      if (segLen < 2 || pos + segLen > bytes.length) break;\n      const segData = pos + 2;\n      const isSOF = (marker >= 0xC0 && marker <= 0xC3)||(marker >= 0xC5 && marker <= 0xC7)||(marker >= 0xC9 && marker <= 0xCB)||(marker >= 0xCD && marker <= 0xCF);\n      if (isSOF && segLen >= 11) {\n        h = (bytes[segData+1] << 8)|bytes[segData+2];\n        w = (bytes[segData+3] << 8)|bytes[segData+4];\n        comps = bytes[segData+5]; sofFound = true;\n      }\n      pos += segLen;\n    }\n    if (sofFound && w >= 16 && h >= 16) {\n      const key = `${w}x${h}`;\n      if (!seen.has(key)) { seen.add(key); result.dims.push({w, h}); }\n      if      (comps === 1) result.hasGray = true;\n      else if (comps === 3) result.hasRGB  = true;\n      else if (comps === 4) result.hasCMYK = true;\n    }\n    i = nextI;\n  }\n  return result;\n}\n\n\/* Parsowanie TrimBox\/MediaBox\/BleedBox z surowych bajt\u00f3w PDF *\/\nfunction parsePDFBoxes(buf, pageNum) {\n  const str = new TextDecoder('latin1').decode(new Uint8Array(buf));\n  const result = { mediaBox: null, trimBox: null, bleedBox: null };\n\n  \/\/ Obs\u0142uga liczb ze znakiem (ujemne wsp\u00f3\u0142rz\u0119dne = spady \"poza\" stron\u0105)\n  \/\/ Format: \/TrimBox [0.0000 0.0000 255.1181 141.7323]  lub  \/MediaBox [ -5.6693 -5.6693 ... ]\n  const NUM = '[-+]?[\\\\d]*\\\\.?[\\\\d]+';\n  const parseBox = (name) => {\n    const re = new RegExp('\/' + name + '\\\\s*\\\\[\\\\s*(' + NUM + ')\\\\s+(' + NUM + ')\\\\s+(' + NUM + ')\\\\s+(' + NUM + ')\\\\s*\\\\]', 'g');\n    let m, last = null;\n    while ((m = re.exec(str)) !== null) {\n      const x1 = parseFloat(m[1]), y1 = parseFloat(m[2]);\n      const x2 = parseFloat(m[3]), y2 = parseFloat(m[4]);\n      last = { x1, y1, x2, y2, w: x2 - x1, h: y2 - y1 };\n    }\n    return last;\n  };\n\n  result.mediaBox = parseBox('MediaBox');\n  result.trimBox  = parseBox('TrimBox');\n  result.bleedBox = parseBox('BleedBox');\n\n  \/\/ Je\u015bli nie ma TrimBox ale jest BleedBox \u2014 u\u017cyj jako przybli\u017cenia\n  if (!result.trimBox && result.bleedBox) result.trimBox = result.bleedBox;\n\n  return result;\n}\n\n\/* Wykrywanie rich black (CMYK z wieloma kana\u0142ami jako czarny) *\/\nfunction detectRichBlack(ops) {\n  const OPS = pdfjsLib.OPS;\n  const argsArr = ops.argsArray, fnArr = ops.fnArray;\n  let hasRichBlack = false, detail = '';\n  for (let i = 0; i < fnArr.length; i++) {\n    if (fnArr[i] === OPS.setFillCMYKColor || fnArr[i] === OPS.setStrokeCMYKColor) {\n      const [c, m, y, k] = argsArr[i];\n      if (k > 0.8 && (c > 0.3 || m > 0.3 || y > 0.3)) {\n        hasRichBlack = true;\n        detail = `C:${Math.round(c*100)} M:${Math.round(m*100)} Y:${Math.round(y*100)} K:${Math.round(k*100)}`;\n        break;\n      }\n    }\n  }\n  return { hasRichBlack, detail };\n}\n\n\/* Wykrywanie transparentno\u015bci *\/\nfunction checkTransparency(buf) {\n  const str = new TextDecoder('latin1').decode(new Uint8Array(buf));\n  return str.includes('\/Transparency') || str.includes('\/ca ') || str.includes('\/CA ') || str.includes('\/SMask');\n}\n\n\/* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n   RENDER HELPERS\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 *\/\nasync function ivRenderPage(doc, num, tw) {\n  const pg = await doc.getPage(num);\n  const sc = tw \/ pg.getViewport({scale:1}).width;\n  const vp = pg.getViewport({scale: sc});\n  const c  = document.createElement('canvas');\n  c.width = vp.width; c.height = vp.height;\n  await pg.render({canvasContext: c.getContext('2d'), viewport: vp}).promise;\n  return c;\n}\n\nasync function ivRenderRaster(file) {\n  return new Promise(res => {\n    const reader = new FileReader();\n    reader.onload = e => {\n      const img = new Image();\n      img.onload = () => {\n        const c = document.createElement('canvas');\n        c.width  = img.naturalWidth;\n        c.height = img.naturalHeight;\n        c.getContext('2d').drawImage(img, 0, 0);\n        res(c);\n      };\n      img.onerror = () => res(null);\n      img.src = e.target.result;\n    };\n    reader.onerror = () => res(null);\n    reader.readAsDataURL(file);\n  });\n}\n\n\/* Przycina canvas do obszaru TrimBox (po ci\u0119ciu) dla podgl\u0105du 3D.\n   Je\u015bli brak TrimBox \u2014 oblicza na podstawie wymiar\u00f3w produktu i spad\u00f3w. *\/\nfunction ivCropToTrim(srcCanvas, prod, boxes) {\n  if (!srcCanvas) return srcCanvas;\n\n  const srcW = srcCanvas.width;\n  const srcH = srcCanvas.height;\n\n  \/\/ Oblicz stosunek px\/mm na podstawie rozmiaru canvasu i rozmiaru strony PDF\n  \/\/ srcCanvas odpowiada ca\u0142ej stronie (MediaBox) w pikselach\n  \/\/ Rozmiar strony w mm = prod.w + prod.bl*2 (zak\u0142adamy prawid\u0142owy plik ze spadami)\n  let cropX = 0, cropY = 0, cropW = srcW, cropH = srcH;\n\n  if (prod.custom) {\n    \/\/ Banery \u2014 nie przycinaj, brak znanych wymiar\u00f3w po ci\u0119ciu\n    return srcCanvas;\n  }\n\n  if (boxes && boxes.trimBox && boxes.mediaBox) {\n    \/\/ Mamy TrimBox \u2014 u\u017cyj go do precyzyjnego przyci\u0119cia\n    const mb = boxes.mediaBox;\n    const tb = boxes.trimBox;\n    const mbW = mb.x2 - mb.x1;\n    const mbH = mb.y2 - mb.y1;\n    if (mbW > 0 && mbH > 0) {\n      const scaleX = srcW \/ mbW;\n      const scaleY = srcH \/ mbH;\n      \/\/ TrimBox w uk\u0142adzie PDF (Y od do\u0142u) \u2192 canvas (Y od g\u00f3ry)\n      cropX = (tb.x1 - mb.x1) * scaleX;\n      cropY = (mb.y2 - tb.y2) * scaleY;  \/\/ odwr\u00f3cone Y\n      cropW = (tb.x2 - tb.x1) * scaleX;\n      cropH = (tb.y2 - tb.y1) * scaleY;\n    }\n  } else if (prod.bl > 0) {\n    \/\/ Brak TrimBox \u2014 wytnij spady na podstawie wymiar\u00f3w produktu\n    \/\/ srcCanvas odpowiada (prod.w + prod.bl*2) \u00d7 (prod.h + prod.bl*2) mm\n    const totalW_mm = prod.w + prod.bl * 2;\n    const totalH_mm = prod.h + prod.bl * 2;\n    const pxPerMmX = srcW \/ totalW_mm;\n    const pxPerMmY = srcH \/ totalH_mm;\n    cropX = prod.bl * pxPerMmX;\n    cropY = prod.bl * pxPerMmY;\n    cropW = prod.w * pxPerMmX;\n    cropH = prod.h * pxPerMmY;\n  } else {\n    return srcCanvas; \/\/ brak spad\u00f3w \u2014 nie przycinaj\n  }\n\n  \/\/ Zabezpieczenie przed ujemnymi\/zerowymi warto\u015bciami\n  cropX = Math.max(0, Math.round(cropX));\n  cropY = Math.max(0, Math.round(cropY));\n  cropW = Math.min(srcW - cropX, Math.round(cropW));\n  cropH = Math.min(srcH - cropY, Math.round(cropH));\n  if (cropW <= 0 || cropH <= 0) return srcCanvas;\n\n  const dst = document.createElement('canvas');\n  dst.width = cropW; dst.height = cropH;\n  dst.getContext('2d').drawImage(srcCanvas, cropX, cropY, cropW, cropH, 0, 0, cropW, cropH);\n  return dst;\n}\n\n\/* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n   PODGL\u0104D 2D Z SIATK\u0104\n   Rysuje obraz strony + linie ci\u0119cia (\u017c\u00f3\u0142te), spad\u00f3w (czerwone), marginesu (niebieskie)\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 *\/\nfunction ivDraw2DWhenReady(prod, pageIdx) {\n  const wrap = document.getElementById('iv-preview2d-wrap');\n  \/\/ Je\u015bli wrap ma ju\u017c wymiary \u2014 rysuj od razu\n  if (wrap.clientWidth > 50) {\n    ivDraw2D(prod, pageIdx);\n    return;\n  }\n  \/\/ Poczekaj a\u017c element b\u0119dzie widoczny i layoutowany\n  let tries = 0;\n  const poll = () => {\n    tries++;\n    if (wrap.clientWidth > 50 || tries > 30) {\n      ivDraw2D(prod, pageIdx);\n    } else {\n      requestAnimationFrame(poll);\n    }\n  };\n  requestAnimationFrame(poll);\n}\n\nfunction ivDraw2D(prod, pageIdx) {\n  S.preview2dPage = pageIdx;\n  const canvas = document.getElementById('iv-canvas2d');\n  const ctx    = canvas.getContext('2d');\n  const src    = S.rendered[pageIdx];\n  if (!src) return;\n\n  const wrap = document.getElementById('iv-preview2d-wrap');\n  const maxW = wrap.clientWidth - 32;\n  const maxH = 400;\n\n  \/\/ Wymiary w pikselach renderowanego canvasu\n  const srcW = src.width, srcH = src.height;\n  const scale = Math.min(maxW \/ srcW, maxH \/ srcH, 1);\n  const dispW = Math.round(srcW * scale);\n  const dispH = Math.round(srcH * scale);\n\n  canvas.width  = dispW;\n  canvas.height = dispH;\n\n  \/\/ Rysuj obraz\n  ctx.drawImage(src, 0, 0, dispW, dispH);\n\n  if (prod.custom) return; \/\/ banery \u2014 brak znanych wymiar\u00f3w, nie rysuj siatki\n\n  \/\/ Oblicz pozycje linii w pikselach canvasu\n  \/\/ srcW odpowiada (prod.w + prod.bl*2) mm ze spadem\n  const totalW_mm = prod.w + prod.bl * 2;\n  const totalH_mm = prod.h + prod.bl * 2;\n  const mmToPx = (mm, axis) => axis === 'x'\n    ? mm \/ totalW_mm * dispW\n    : mm \/ totalH_mm * dispH;\n\n  const bl  = prod.bl;   \/\/ spady [mm]\n  const mg  = prod.margin || 3; \/\/ margines bezpieczny [mm]\n\n  \/\/ Linia ci\u0119cia (\u017c\u00f3\u0142ta) \u2014 wewn\u0119trzna kraw\u0119d\u017a spad\u00f3w\n  const cutX1 = mmToPx(bl, 'x');\n  const cutY1 = mmToPx(bl, 'y');\n  const cutX2 = dispW - mmToPx(bl, 'x');\n  const cutY2 = dispH - mmToPx(bl, 'y');\n\n  \/\/ Margines bezpieczny (niebieski) \u2014 wewn\u0105trz linii ci\u0119cia\n  const safX1 = cutX1 + mmToPx(mg, 'x');\n  const safY1 = cutY1 + mmToPx(mg, 'y');\n  const safX2 = cutX2 - mmToPx(mg, 'x');\n  const safY2 = cutY2 - mmToPx(mg, 'y');\n\n  const drawRect = (x1, y1, x2, y2, color, dash) => {\n    ctx.save();\n    ctx.strokeStyle = color;\n    ctx.lineWidth   = 1.5;\n    ctx.setLineDash(dash || []);\n    ctx.strokeRect(x1 + 0.5, y1 + 0.5, x2 - x1, y2 - y1);\n    ctx.restore();\n  };\n\n  \/\/ Linia spad\u00f3w (czerwona, ci\u0105g\u0142a) \u2014 kraw\u0119d\u017a dokumentu = kraw\u0119d\u017a canvas\n  if (bl > 0) {\n    drawRect(0, 0, dispW - 1, dispH - 1, 'rgba(255,59,48,0.85)');\n    \/\/ Linia ci\u0119cia (\u017c\u00f3\u0142ta, ci\u0105g\u0142a)\n    drawRect(cutX1, cutY1, cutX2, cutY2, 'rgba(254,193,0,0.9)');\n  }\n  \/\/ Margines bezpieczny (niebieski, przerywany)\n  drawRect(safX1, safY1, safX2, safY2, 'rgba(0,122,255,0.8)', [4, 3]);\n}\n\nfunction ivBuildPreviewTabs(prod, pages) {\n  const tabs = document.getElementById('iv-preview-tabs');\n  tabs.innerHTML = '';\n  if (pages.length <= 1) { tabs.style.display = 'none'; return; }\n  tabs.style.display = 'flex';\n  const labels = PL ? prod.pl_pg : prod.en_pg;\n  pages.forEach((pg, idx) => {\n    const btn = document.createElement('button');\n    btn.className = 'iv-preview-tab' + (idx === 0 ? ' active' : '');\n    btn.textContent = labels[idx] || (PL ? `Strona ${idx+1}` : `Page ${idx+1}`);\n    btn.onclick = () => {\n      document.querySelectorAll('.iv-preview-tab').forEach((b,i) => b.classList.toggle('active', i===idx));\n      ivDraw2D(prod, idx);\n      ivSwitchPage(idx, prod);\n    };\n    tabs.appendChild(btn);\n  });\n}\n\n\/* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n   WYNIKI UI\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 *\/\nfunction ivShowResults(issues, p) {\n  const counts = {ok:0, warn:0, err:0};\n  issues.forEach(i => { if (counts[i.t] !== undefined) counts[i.t]++; });\n\n  const sum = document.getElementById('iv-summary');\n  const badge = document.getElementById('iv-sum-badge');\n  const txt   = document.getElementById('iv-sum-text');\n\n  if (counts.err > 0) {\n    sum.className = 'iv-summary iv-sum-err';\n    badge.textContent = T.bErr; badge.style.background = '#EB5757';\n    txt.textContent = T.sumErr;\n  } else if (counts.warn > 0) {\n    sum.className = 'iv-summary iv-sum-warn';\n    badge.textContent = T.bWarn; badge.style.background = '#F2994A';\n    txt.textContent = T.sumWarn;\n  } else {\n    sum.className = 'iv-summary iv-sum-ok';\n    badge.textContent = T.bOK; badge.style.background = '#27AE60';\n    txt.textContent = T.sumOK;\n  }\n\n  const list = document.getElementById('iv-checks');\n  list.innerHTML = '';\n  const BDGE = {ok:'OK', warn: PL?'UWAGA':'WARN', err: PL?'B\u0141\u0104D':'ERROR', info:'INFO'};\n  const CLS  = {ok:'iv-ci-ok', warn:'iv-ci-warn', err:'iv-ci-err', info:'iv-ci-info'};\n\n  issues.forEach(iss => {\n    const el = document.createElement('div');\n    el.className = `iv-check-item ${CLS[iss.t] || 'iv-ci-info'}`;\n    const b = BDGE[iss.t] || 'INFO';\n    const pageTag = iss.page ? `<span class=\"iv-ci-page-tag\">${escHtml(iss.page)}<\/span><br>` : '';\n    el.innerHTML = `<span class=\"iv-ci-badge iv-ci-badge-${iss.t||'info'}\">${b}<\/span><div>${pageTag}<div class=\"iv-ci-title\">${escHtml(iss.title)}<\/div>${iss.desc?`<div class=\"iv-ci-desc\">${escHtml(iss.desc)}<\/div>`:''}<\/div>`;\n    list.appendChild(el);\n  });\n\n  document.getElementById('iv-detail').style.display  = 'none';\n  const rc = document.getElementById('iv-results');\n  rc.style.display = 'block';\n  rc.scrollIntoView({behavior:'smooth', block:'start'});\n}\n\nfunction ivReset() {\n  document.getElementById('iv-results').style.display = 'none';\n  ivResetFiles();\n  \/\/ Wracamy do panelu wymaga\u0144 i uploadz zachowanym wyborem produktu\n  const p = PRODS.find(x => x.id === S.prod);\n  if (p) {\n    document.getElementById('iv-detail').style.display = 'block';\n    document.getElementById('iv-detail').scrollIntoView({ behavior:'smooth', block:'start' });\n  } else {\n    document.getElementById('iv-detail').style.display = 'none';\n    document.getElementById('iv-wrap').scrollIntoView({ behavior:'smooth', block:'start' });\n  }\n}\n\nfunction escHtml(s) {\n  return String(s).replace(\/&\/g,'&amp;').replace(\/<\/g,'&lt;').replace(\/>\/g,'&gt;').replace(\/\"\/g,'&quot;');\n}\n\n\/* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n   THREE.JS \u2014 podgl\u0105d 3D\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 *\/\nfunction mkTex(idx) {\n  const src = S.rendered3d[idx] || S.rendered[idx]; if (!src) return null;\n  const t = new THREE.CanvasTexture(src);\n  t.anisotropy = 16; t.needsUpdate = true;\n  return t;\n}\n\nfunction ivBuild3D(p) {\n  if (S.zoomCleanup) { S.zoomCleanup(); S.zoomCleanup = null; }\n  if (S.af)       { cancelAnimationFrame(S.af); S.af = null; }\n  if (S.renderer) { S.renderer.dispose(); S.renderer = null; }\n\n  const wrap   = document.getElementById('iv-canvas-wrap');\n  const canvas = document.getElementById('iv-canvas');\n  const W = wrap.clientWidth, H = wrap.clientHeight;\n\n  const scene  = new THREE.Scene();\n  scene.background = new THREE.Color(0x080808);\n  const camera = new THREE.PerspectiveCamera(34, W\/H, 0.1, 1000);\n  const renderer = new THREE.WebGLRenderer({canvas, antialias: true});\n  renderer.setSize(W, H);\n  renderer.setPixelRatio(Math.min(devicePixelRatio, 2));\n\n  scene.add(new THREE.AmbientLight(0xffffff, 0.18));\n  const dKey = new THREE.DirectionalLight(0xffffff, 0.85);\n  dKey.position.set(2, 4, 6); scene.add(dKey);\n  const dFill = new THREE.DirectionalLight(0xffffff, 0.15);\n  dFill.position.set(-3, 1, 2); scene.add(dFill);\n\n  const group = ivMesh(p);\n  scene.add(group);\n\n  const cDist = ivCameraDist(p);\n  camera.position.set(0, 0.4, cDist);\n  camera.lookAt(0, 0, 0);\n\n  S.renderer = renderer; S.mesh = group; S.camera = camera;\n  S.autoRot = true; S.targetY = 0;\n\n  const zMin = cDist * 0.4, zMax = cDist * 3.5;\n  const onWheel = e => {\n    e.preventDefault();\n    camera.position.z = Math.max(zMin, Math.min(zMax, camera.position.z * (e.deltaY > 0 ? 1.04 : 0.96)));\n  };\n  let _pinch0 = 0;\n  const onPinchStart = e => {\n    if (e.touches.length === 2) {\n      const dx = e.touches[0].clientX - e.touches[1].clientX;\n      const dy = e.touches[0].clientY - e.touches[1].clientY;\n      _pinch0 = Math.sqrt(dx*dx + dy*dy);\n    }\n  };\n  const onPinchMove = e => {\n    if (e.touches.length === 2) {\n      e.preventDefault();\n      const dx = e.touches[0].clientX - e.touches[1].clientX;\n      const dy = e.touches[0].clientY - e.touches[1].clientY;\n      const dist = Math.sqrt(dx*dx + dy*dy);\n      if (_pinch0 > 0) camera.position.z = Math.max(zMin, Math.min(zMax, camera.position.z * (_pinch0\/dist)));\n      _pinch0 = dist;\n    }\n  };\n  canvas.addEventListener('wheel',      onWheel,      {passive:false});\n  canvas.addEventListener('touchstart', onPinchStart, {passive:true});\n  canvas.addEventListener('touchmove',  onPinchMove,  {passive:false});\n  S.zoomCleanup = () => {\n    canvas.removeEventListener('wheel', onWheel);\n    canvas.removeEventListener('touchstart', onPinchStart);\n    canvas.removeEventListener('touchmove', onPinchMove);\n  };\n\n  const ro = new ResizeObserver(() => {\n    const nW = wrap.clientWidth, nH = wrap.clientHeight;\n    camera.aspect = nW\/nH; camera.updateProjectionMatrix();\n    renderer.setSize(nW, nH);\n  });\n  ro.observe(wrap);\n\n  function anim() {\n    S.af = requestAnimationFrame(anim);\n    if (S.autoRot) group.rotation.y -= 0.004;\n    else           group.rotation.y += (S.targetY - group.rotation.y) * 0.08;\n    renderer.render(scene, camera);\n  }\n  anim();\n  ivBuildPageBtns(p);\n}\n\nfunction ivMesh(p) {\n  const g = new THREE.Group();\n\n  \/\/ Pobierz rzeczywiste proporcje z wyrenderowanego pliku\n  const src = S.rendered3d[0] || S.rendered[0];\n  let fileW, fileH;\n  if (src && src.width > 0 && src.height > 0) {\n    \/\/ U\u017cyj faktycznych pikseli pliku jako proporcji\n    fileW = src.width;\n    fileH = src.height;\n  } else if (!p.custom) {\n    \/\/ Fallback na wymiary produktu\n    fileW = p.w; fileH = p.h;\n  } else {\n    \/\/ Baner bez pliku \u2014 domy\u015blny prostok\u0105t poziomy\n    fileW = 16; fileH = 9;\n  }\n\n  const ratio = fileW \/ fileH;\n\n  \/\/ Skaluj do sensownego rozmiaru w scenie \u2014 d\u0142u\u017csza kraw\u0119d\u017a = MAX_SIDE\n  const MAX_SIDE = 9;\n  let meshW, meshH;\n  if (ratio >= 1) {\n    meshW = MAX_SIDE;\n    meshH = MAX_SIDE \/ ratio;\n  } else {\n    meshH = MAX_SIDE;\n    meshW = MAX_SIDE * ratio;\n  }\n\n  \/\/ Grubo\u015b\u0107 zale\u017cna od kategorii\n  const thick = p.model === 'card' ? 0.07\n              : p.model === 'flyer' ? 0.04\n              : 0.02; \/\/ baner\/tablica\n\n  const edge = new THREE.MeshStandardMaterial({color:0x888888, roughness:0.7, metalness:0.0});\n  const fTex = mkTex(0), bTex = mkTex(1);\n  const fM = new THREE.MeshStandardMaterial({map:fTex||null, color:fTex?0xffffff:0xf0f0f0, roughness:0.92, metalness:0.0});\n  const bM = new THREE.MeshStandardMaterial({map:bTex||null, color:bTex?0xffffff:0xe0e0e0, roughness:0.92, metalness:0.0});\n\n  \/\/ Banery \u2014 tylko prz\u00f3d (jednostronne), reszta \u2014 prz\u00f3d i ty\u0142\n  const backMat = p.model === 'banner' ? edge : bM;\n\n  g.add(new THREE.Mesh(\n    new THREE.BoxGeometry(meshW, meshH, thick),\n    [edge, edge, edge, edge, fM, backMat]\n  ));\n\n  g.rotation.x = 0.08;\n  g.rotation.y = 0.3;\n  return g;\n}\n\n\/\/ Oblicz odleg\u0142o\u015b\u0107 kamery na podstawie faktycznych proporcji pliku\nfunction ivCameraDist(p) {\n  const src = S.rendered3d[0] || S.rendered[0];\n  let ratio = 1;\n  if (src && src.width > 0 && src.height > 0) {\n    ratio = src.width \/ src.height;\n  } else if (!p.custom) {\n    ratio = p.w \/ p.h;\n  }\n  \/\/ mesh ma zawsze MAX_SIDE=9 po d\u0142u\u017cszej kraw\u0119dzi\n  \/\/ kamera musi widzie\u0107 ca\u0142y mesh \u2014 dopasuj do aspect ratio sceny i meshu\n  const meshW = ratio >= 1 ? 9 : 9 * ratio;\n  const meshH = ratio >= 1 ? 9 \/ ratio : 9;\n  const maxMeshDim = Math.max(meshW, meshH);\n  return Math.max(13, Math.min(30, maxMeshDim * 1.9));\n}\n\nfunction ivBuildPageBtns(p) {\n  \/\/ Page buttons are handled by ivBuildPreviewTabs now \u2014 3D has its own set for page flip\n  const el = document.getElementById('iv-page-btns');\n  el.innerHTML = '';\n  if (p.pages <= 1) return;\n  const labels = PL ? p.pl_pg : p.en_pg;\n  labels.forEach((lbl, idx) => {\n    const btn = document.createElement('button');\n    btn.className = 'iv-page-btn' + (idx === 0 ? ' active' : '');\n    btn.textContent = lbl;\n    btn.onclick = () => ivSwitchPage3D(idx, p);\n    el.appendChild(btn);\n  });\n}\n\nfunction ivSwitchPage(idx, prod) {\n  ivSwitchPage3D(idx, prod);\n}\n\nfunction ivSwitchPage3D(idx, p) {\n  S.autoRot = false;\n  document.querySelectorAll('.iv-page-btn').forEach((b,i) => b.classList.toggle('active', i===idx));\n  const ind = document.getElementById('iv-page-ind');\n  const labels = PL ? p.pl_pg : p.en_pg;\n  ind.textContent = `${labels[idx]} (${idx+1}\/${p.pages})`;\n  ind.classList.add('show');\n  setTimeout(() => ind.classList.remove('show'), 2500);\n  if ((p.model === 'card' || p.model === 'flyer') && S.mesh) {\n    const front = idx % 2 === 0;\n    const cur   = S.mesh.rotation.y;\n    S.targetY = front\n      ? Math.round(cur\/(Math.PI*2))*Math.PI*2\n      : Math.round((cur-Math.PI)\/(Math.PI*2))*Math.PI*2 + Math.PI;\n    const fi = idx <= 1 ? 0 : 2, bi = idx <= 1 ? 1 : 3;\n    S.mesh.children.forEach(c => {\n      if (!(c instanceof THREE.Mesh) || !Array.isArray(c.material)) return;\n      const ft = mkTex(fi), bt = mkTex(bi);\n      c.material[4] = new THREE.MeshStandardMaterial({map:ft||null, color:ft?0xffffff:0xf0f0f0, roughness:0.92});\n      c.material[5] = new THREE.MeshStandardMaterial({map:bt||null, color:bt?0xffffff:0xe0e0e0, roughness:0.92});\n    });\n  }\n  if (S.autoTimer) clearTimeout(S.autoTimer);\n  S.autoTimer = setTimeout(() => S.autoRot = true, 4000);\n}\n\n\/* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n   DRAG (obr\u00f3t 3D)\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 *\/\nconst cv = document.getElementById('iv-canvas');\ncv.addEventListener('mousedown', e => {S.drag=true; S.autoRot=false; S.prev={x:e.clientX,y:e.clientY};});\ncv.addEventListener('touchstart', e => {\n  if (e.touches.length === 1) { S.drag=true; S.autoRot=false; S.prev={x:e.touches[0].clientX,y:e.touches[0].clientY}; }\n}, {passive:true});\ndocument.addEventListener('mousemove', e => {\n  if (!S.drag || !S.mesh) return;\n  const dx=e.clientX-S.prev.x, dy=e.clientY-S.prev.y;\n  S.mesh.rotation.y += dx*.013; S.mesh.rotation.x += dy*.013;\n  S.mesh.rotation.x = Math.max(-.5, Math.min(.5, S.mesh.rotation.x));\n  S.targetY = S.mesh.rotation.y; S.prev={x:e.clientX,y:e.clientY};\n});\ndocument.addEventListener('touchmove', e => {\n  if (!S.drag || !S.mesh) return;\n  const dx = e.touches[0].clientX - S.prev.x;\n  S.mesh.rotation.y += dx*.013; S.targetY = S.mesh.rotation.y;\n  S.prev = {x:e.touches[0].clientX, y:e.touches[0].clientY};\n}, {passive:true});\nconst stopDrag = () => {\n  if (!S.drag) return; S.drag=false;\n  S.autoTimer = setTimeout(() => S.autoRot=true, 3000);\n};\ndocument.addEventListener('mouseup', stopDrag);\ndocument.addEventListener('touchend', stopDrag);\n\n\/* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n   DARK MODE SYNC\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 *\/\nfunction syncTheme() {\n  const h = document.documentElement, b = document.body || h;\n  const dark =\n    h.classList.contains('dark') || h.classList.contains('dark-mode') || h.getAttribute('data-theme') === 'dark' ||\n    b.classList.contains('dark') || b.classList.contains('dark-mode') || b.classList.contains('night-mode') || b.getAttribute('data-theme') === 'dark';\n  const wrap = document.getElementById('iv-wrap');\n  if (wrap) wrap.classList.toggle('iv-dark', dark);\n}\nconst _themeObs = new MutationObserver(syncTheme);\n_themeObs.observe(document.documentElement, {attributes:true, attributeFilter:['class','data-theme']});\nif (document.body) _themeObs.observe(document.body, {attributes:true, attributeFilter:['class','data-theme']});\nsyncTheme();\n\n\/* \u2500\u2500 INIT \u2500\u2500 *\/\ninit();<\/script>\n<\/div>\n<\/div>\n\t\t\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t","protected":false},"excerpt":{"rendered":"<p>Weryfikator projekt\u00f3w BETA W tej zak\u0142adce sprawdzisz, czy Tw\u00f3j projekt jest odpowiednio przygotowany do druku. Zweryfikuj projekt w aplikacji<\/p>","protected":false},"author":1,"featured_media":0,"parent":0,"menu_order":0,"comment_status":"closed","ping_status":"closed","template":"","meta":{"_acf_changed":false,"footnotes":"","_members_access_role":[],"_members_access_error":""},"class_list":["post-10733","page","type-page","status-publish","hentry"],"acf":[],"_links":{"self":[{"href":"https:\/\/app.reklamaindygo.pl\/en\/wp-json\/wp\/v2\/pages\/10733","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/app.reklamaindygo.pl\/en\/wp-json\/wp\/v2\/pages"}],"about":[{"href":"https:\/\/app.reklamaindygo.pl\/en\/wp-json\/wp\/v2\/types\/page"}],"author":[{"embeddable":true,"href":"https:\/\/app.reklamaindygo.pl\/en\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/app.reklamaindygo.pl\/en\/wp-json\/wp\/v2\/comments?post=10733"}],"version-history":[{"count":0,"href":"https:\/\/app.reklamaindygo.pl\/en\/wp-json\/wp\/v2\/pages\/10733\/revisions"}],"wp:attachment":[{"href":"https:\/\/app.reklamaindygo.pl\/en\/wp-json\/wp\/v2\/media?parent=10733"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}