{"id":45120840,"date":"2026-08-21T19:35:06","date_gmt":"2026-08-21T17:35:06","guid":{"rendered":"https:\/\/www.botanopia.com\/?page_id=45120840"},"modified":"2026-08-21T19:35:09","modified_gmt":"2026-08-21T17:35:09","slug":"de-cyanotypie-negativgenerator","status":"publish","type":"page","link":"https:\/\/www.botanopia.com\/de\/de-cyanotypie-negativgenerator\/","title":{"rendered":"Kostenloser Cyanotypie-Negativgenerator"},"content":{"rendered":"\n<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>Kostenloser Cyanotypie-Negativgenerator, verwandeln Sie Fotos in ein digitales Negativ | Botanopia<\/title>\n<meta name=\"description\" content=\"Turn any photo into a print-ready cyanotype negative for free, right in your browser. Adjust levels and contrast, add a step wedge, mirror for contact printing, and download a negative ready for UV exposure. Includes a full step-by-step cyanotype tutorial.\">\n<!-- If this file is embedded as a content block inside an existing WordPress page rather than served as its own page, set the title and meta description above in the SEO plugin's page fields instead, this <title>\/<meta> pair will be ignored by search engines in that case. -->\n<link rel=\"canonical\" href=\"https:\/\/www.botanopia.com\/cyanotype-negative-maker\">\n<!-- TODO-CANONICAL: update this to the real published URL once this page goes live -->\n\n<meta property=\"og:type\" content=\"website\">\n<meta property=\"og:title\" content=\"Free Cyanotype Negative Maker, Turn Any Photo into a Digital Negative\">\n<meta property=\"og:description\" content=\"Upload a photo, adjust tone, and download a print-ready cyanotype negative in your browser. Free, private, with a full step-by-step guide.\">\n<meta property=\"og:url\" content=\"https:\/\/www.botanopia.com\/cyanotype-negative-maker\">\n<meta property=\"og:site_name\" content=\"Botanopia\">\n<!-- TODO-OG-IMAGE: add a 1200x630 screenshot of the tool once one exists, then uncomment:\n<meta property=\"og:image\" content=\"https:\/\/botanopia.com\/path-to-a-1200x630-screenshot.jpg\"> -->\n<meta name=\"twitter:card\" content=\"summary_large_image\">\n\n<script type=\"application\/ld+json\">\n{\n  \"@context\": \"https:\/\/schema.org\",\n  \"@type\": \"HowTo\",\n  \"name\": \"How to Make a Cyanotype Negative From Any Photo\",\n  \"description\": \"Step-by-step instructions for turning a digital photo into a print-ready negative for cyanotype printing, from choosing a photo through exposing and washing the final print.\",\n  \"step\": [\n    { \"@type\": \"HowToStep\", \"name\": \"Choose the right photo\", \"text\": \"Pick a photo with clear contrast and simple, well-defined shapes. Busy or low-contrast photos are harder to translate into a clean cyanotype.\" },\n    { \"@type\": \"HowToStep\", \"name\": \"Convert to black and white and adjust the tone\", \"text\": \"Desaturate the image, then use levels (black point, white point, gamma) and contrast to shape the tonal range before inverting, since cyanotype has a shorter tonal range than a typical photo.\" },\n    { \"@type\": \"HowToStep\", \"name\": \"Invert the image to make a negative\", \"text\": \"Invert the tones so light areas of the photo become dark in the negative and dark areas become light. The dark areas will block UV light and stay white paper; the light, clear areas will pass UV and turn blue.\" },\n    { \"@type\": \"HowToStep\", \"name\": \"Mirror the negative for contact printing\", \"text\": \"Flip the negative horizontally so it reads correctly once it is placed ink-side down against the coated paper or fabric for exposure.\" },\n    { \"@type\": \"HowToStep\", \"name\": \"Print onto transparency film\", \"text\": \"Print the negative onto transparency film matched to your printer type, inkjet or laser, typically at 300 DPI, sized to fit your coated paper or fabric.\" },\n    { \"@type\": \"HowToStep\", \"name\": \"Add a step wedge and test your exposure time\", \"text\": \"Print an 11-step grey scale alongside a test strip, expose and develop it as usual, and note the exposure time that keeps detail in both the lightest and darkest steps.\" },\n    { \"@type\": \"HowToStep\", \"name\": \"Expose in sunlight or under a UV light\", \"text\": \"Place the negative ink-side down against the coated, dried paper or fabric and expose for the time you calibrated, typically a few minutes in direct sun or longer under a UV lamp.\" },\n    { \"@type\": \"HowToStep\", \"name\": \"Wash and let the print oxidize\", \"text\": \"Wash the exposed print in cool water for five to ten minutes until the yellow-green tint clears, then let it air dry. The print will deepen to its final Prussian blue as it oxidizes.\" }\n  ]\n}\n<\/script>\n\n<script type=\"application\/ld+json\">\n{\n  \"@context\": \"https:\/\/schema.org\",\n  \"@type\": \"FAQPage\",\n  \"mainEntity\": [\n    {\n      \"@type\": \"Question\",\n      \"name\": \"Why is my cyanotype not turning blue, or fading right after washing?\",\n      \"acceptedAnswer\": { \"@type\": \"Answer\", \"text\": \"This is almost always underexposure, old or poorly mixed chemistry, or paper that reacts badly with the sensitizer. Try a longer exposure time first, since cyanotype tolerates overexposure far better than underexposure, and confirm your paper does not contain carbonate buffering, which fights the chemistry.\" }\n    },\n    {\n      \"@type\": \"Question\",\n      \"name\": \"Why does my print look grey or washed out instead of deep blue?\",\n      \"acceptedAnswer\": { \"@type\": \"Answer\", \"text\": \"Grey, weak results are usually a sign of underexposure, a negative that is too dense (not enough contrast, so not enough UV gets through), or a wash that was too rough or too long. Increase exposure time, boost contrast in your negative, and wash gently rather than under a direct running tap.\" }\n    },\n    {\n      \"@type\": \"Question\",\n      \"name\": \"Do I need to flip or mirror my cyanotype negative before printing?\",\n      \"acceptedAnswer\": { \"@type\": \"Answer\", \"text\": \"Yes. Inkjet transparencies are exposed ink-side down against the coated surface, which mirrors the image. Flip your negative horizontally before printing so the final print reads correctly.\" }\n    },\n    {\n      \"@type\": \"Question\",\n      \"name\": \"How long should I expose a cyanotype print?\",\n      \"acceptedAnswer\": { \"@type\": \"Answer\", \"text\": \"It depends on your light source, paper, and negative density, which is exactly why a step wedge test strip is worth doing once: expose a printed step wedge, develop it normally, and read off the time that keeps both highlight and shadow detail. Reuse that time for the rest of the edition.\" }\n    },\n    {\n      \"@type\": \"Question\",\n      \"name\": \"What is the best paper size and resolution for a cyanotype digital negative?\",\n      \"acceptedAnswer\": { \"@type\": \"Answer\", \"text\": \"300 DPI is the standard for a sharp cyanotype negative on a home printer, sized to match your coated paper or fabric, commonly A4 or US Letter for a first test.\" }\n    },\n    {\n      \"@type\": \"Question\",\n      \"name\": \"Do I need special transparency film for a laser or inkjet printer?\",\n      \"acceptedAnswer\": { \"@type\": \"Answer\", \"text\": \"OHP transparency film is printer-specific, and using the wrong type is the most common way to damage a printer. Laser printers fuse toner with heat, so they need film rated for laser use; inkjet-only film can melt or warp inside a laser printer. Inkjet printers need film with a coating that accepts liquid ink; laser-only film will leave the ink beading on the surface instead of drying properly. Check the packaging before buying and match it to your printer. If one printed negative isn't dense enough on its own, print it twice and layer the two sheets in registration rather than switching printer types.\" }\n    }\n  ]\n}\n<\/script>\n\n<style>\n\n\/* ============================================================\n   TOKENS\n   Swap --font-display for the real Tiempos Headline Bold file\n   once @font-face is wired up on the site. --font-body and\n   --font-mono use the system's own sans-serif and monospace\n   fonts (Apercu is no longer used on the site).\n   ============================================================ *\/\n:root{\n  --white:       #FFFFFF;\n  --paper:       #FFFFFF;\n  --paper-deep:  #EEF3FA;\n  --ink:         #14181F;\n  --ink-soft:    #64707F;\n  --line:        #DCE4EE;\n  --line-strong: #B9C7DA;\n\n  --prussian:      #1B4C8C;\n  --prussian-deep: #0E2E5C;\n  --prussian-tint: #E7EFFA;\n  --cyan-pale:     #F2F7FC;\n  --focus:       #2F6FE0;\n  --danger:      #A23B2E;\n\n  --font-display: 'Tiempos Headline', Georgia, 'Iowan Old Style', 'Times New Roman', serif;\n  --font-body: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Arial, sans-serif;\n  --font-mono: 'IBM Plex Mono', ui-monospace, 'SFMono-Regular', Menlo, monospace;\n\n  --radius: 2px;\n  --gap: 20px;\n}\n\n*{box-sizing:border-box;}\nhtml,body{margin:0;padding:0;}\nbody{\n  background:var(--paper);\n  color:var(--ink);\n  font-family:var(--font-body);\n  font-size:15px;\n  line-height:1.5;\n  -webkit-font-smoothing:antialiased;\n}\n@media (prefers-reduced-motion: reduce){\n  *{animation-duration:.001ms !important; transition-duration:.001ms !important;}\n}\nbutton{font-family:inherit;}\na{color:var(--prussian);}\n\n\/* ---------- registration-mark motif ---------- *\/\n.reg-corners{position:relative;}\n.reg-corners::before,.reg-corners::after,\n.reg-corners .rc-tr,.reg-corners .rc-bl{\n  content:\"\";\n  position:absolute;\n  width:9px;height:9px;\n  pointer-events:none;\n}\n.reg-corners::before{ top:-1px;left:-1px; border-top:1.5px solid var(--ink-soft); border-left:1.5px solid var(--ink-soft); }\n.reg-corners::after{ bottom:-1px;right:-1px; border-bottom:1.5px solid var(--ink-soft); border-right:1.5px solid var(--ink-soft); }\n.reg-corners .rc-tr{ top:-1px;right:-1px; border-top:1.5px solid var(--ink-soft); border-right:1.5px solid var(--ink-soft); }\n.reg-corners .rc-bl{ bottom:-1px;left:-1px; border-bottom:1.5px solid var(--ink-soft); border-left:1.5px solid var(--ink-soft); }\n\n\/* ---------- page title & privacy note ---------- *\/\n.page-intro{max-width:900px;padding:28px 28px 0;}\n.page-intro h1{\n  font-family:var(--font-display);font-weight:700;font-size:30px;margin:0 0 10px;color:var(--ink);\n}\n.page-intro p{font-size:14px;color:var(--ink-soft);line-height:1.55;margin:0;}\n.page-intro a{color:var(--prussian);text-decoration:underline;}\n@media (max-width:600px){\n  .page-intro{padding:16px 16px 0;}\n  .page-intro h1{font-size:22px;}\n}\n\n.privacy-strip{\n  text-align:center;\n  font-size:12.5px;\n  color:var(--prussian-deep);\n  background:var(--prussian-tint);\n  border-top:1px solid var(--line);\n  border-bottom:1px solid var(--line);\n  padding:10px 20px;\n  margin-top:24px;\n}\n.privacy-strip strong{font-weight:600;}\n\n\/* ---------- layout ---------- *\/\n.app-body{\n  display:grid;\n  grid-template-columns:1fr 380px;\n  gap:0;\n  min-height:calc(100vh - 78px);\n}\n.mobile-note{\n  display:none;\n  align-items:center;justify-content:space-between;gap:12px;\n  background:var(--prussian-tint);\n  color:var(--prussian-deep);\n  padding:10px 20px;\n  font-size:12.5px;\n  line-height:1.4;\n  border-bottom:1px solid var(--line);\n}\n.mobile-note button{\n  background:none;border:none;color:var(--prussian-deep);font-size:16px;\n  line-height:1;cursor:pointer;padding:2px 4px;flex-shrink:0;\n}\n@media (max-width: 980px){\n  .mobile-note{display:flex;}\n  .app-body{grid-template-columns:1fr;}\n  .panel{\n    max-height:none;\n    overflow-y:visible;\n    border-left:none;\n    border-top:1px solid var(--line);\n  }\n  .panel-tabs{position:static;}\n  .panel-footer{position:static;}\n}\n\n.workspace{\n  padding:28px;\n  display:flex;\n  flex-direction:column;\n  gap:16px;\n}\n.viewmode-row{\n  display:flex;align-items:center;justify-content:space-between;\n  flex-wrap:wrap;gap:12px;\n}\n.segmented{\n  display:inline-flex;\n  flex-wrap:wrap;\n  border:1px solid var(--line-strong);\n  border-radius:var(--radius);\n  overflow:hidden;\n}\n.segmented button{\n  background:var(--white);\n  border:none;\n  padding:8px 14px;\n  font-size:12.5px;\n  letter-spacing:.02em;\n  color:var(--ink-soft);\n  cursor:pointer;\n  border-right:1px solid var(--line-strong);\n}\n.segmented button:last-child{border-right:none;}\n.segmented button.active{background:var(--prussian);color:var(--white);}\n.segmented button:hover:not(.active){background:var(--paper-deep);}\n\n.stage-wrap{\n  position:relative;\n  flex:1;\n  min-height:420px;\n  background:\n    linear-gradient(45deg,#0E2E5C08 25%,transparent 25%,transparent 75%,#0E2E5C08 75%),\n    linear-gradient(45deg,#0E2E5C08 25%,transparent 25%,transparent 75%,#0E2E5C08 75%);\n  background-size:20px 20px;\n  background-position:0 0,10px 10px;\n  background-color:#FBFCFE;\n  border:1px solid var(--line);\n  display:flex;align-items:center;justify-content:center;\n  overflow:hidden;\n  touch-action:none;\n}\n.stage-wrap.dragover{outline:2px dashed var(--prussian);outline-offset:-8px;}\n#stage{max-width:100%;max-height:100%;display:block;box-shadow:0 6px 24px rgba(0,0,0,.18);}\n\n.dropzone{\n  text-align:center;color:var(--ink-soft);cursor:pointer;padding:40px;\n}\n.dropzone .dz-icon{\n  width:46px;height:46px;margin:0 auto 14px;\n  border:1.5px solid var(--ink-soft);border-radius:50%;\n  display:flex;align-items:center;justify-content:center;\n}\n.dropzone h2{\n  font-family:var(--font-display);font-weight:700;font-size:20px;margin:0 0 6px;color:var(--ink);\n}\n.dropzone p{margin:0;font-size:13px;}\n.dropzone .privacy-note{\n  margin-top:16px;font-size:11.5px;letter-spacing:.03em;\n  color:var(--prussian);\n}\n\n\/* crop overlay *\/\n#cropOverlay{position:absolute;inset:0;display:none;}\n#stageWrap.wand-active #stage{cursor:crosshair;}\n.crop-rect{\n  position:absolute;border:1.5px dashed var(--white);\n  box-shadow:0 0 0 2000px rgba(10,10,8,.5);\n  cursor:move;\n}\n.crop-handle{\n  position:absolute;width:22px;height:22px;background:var(--white);\n  border:2px solid var(--prussian);border-radius:50%;\n  transform:translate(-50%,-50%);\n  touch-action:none;\n}\n.crop-handle.nw{top:0;left:0;cursor:nwse-resize;}\n.crop-handle.ne{top:0;left:100%;cursor:nesw-resize;}\n.crop-handle.sw{top:100%;left:0;cursor:nesw-resize;}\n.crop-handle.se{top:100%;left:100%;cursor:nwse-resize;}\n\n.crop-toolbar{\n  position:absolute;bottom:14px;left:50%;transform:translateX(-50%);\n  display:flex;gap:8px;flex-wrap:wrap;max-width:92%;justify-content:center;\n  background:var(--white);padding:8px;border-radius:var(--radius);\n  box-shadow:0 4px 16px rgba(0,0,0,.25);\n}\n\n\/* histogram strip *\/\n.hist-row{display:flex;align-items:center;gap:14px;}\n#histCanvas{background:var(--white);border:1px solid var(--line);}\n.hist-label{font-family:var(--font-mono);font-size:10.5px;letter-spacing:.08em;text-transform:uppercase;color:var(--ink-soft);}\n\n\/* ---------- side panel ---------- *\/\n.panel{\n  background:var(--paper-deep);\n  border-left:1px solid var(--line);\n  display:flex;flex-direction:column;\n  max-height:calc(100vh - 78px);\n  overflow-y:auto;\n}\n.panel-tabs{\n  display:flex;border-bottom:1px solid var(--line-strong);\n  position:sticky;top:0;background:var(--paper-deep);z-index:2;\n}\n.panel-tabs button{\n  flex:1;background:none;border:none;padding:14px 6px;\n  font-size:11.5px;letter-spacing:.1em;text-transform:uppercase;\n  color:var(--ink-soft);cursor:pointer;border-bottom:2px solid transparent;\n}\n.panel-tabs button.active{color:var(--ink);border-bottom-color:var(--prussian);}\n\n.panel-section{padding:22px 22px 6px;display:none;}\n.panel-section.active{display:block;}\n\n.field-group{margin-bottom:22px;}\n.field-group h3{\n  font-family:var(--font-mono);font-size:10.5px;letter-spacing:.12em;text-transform:uppercase;\n  color:var(--ink-soft);margin:0 0 12px;display:flex;align-items:center;gap:6px;\n}\n.info-dot{\n  display:inline-flex;align-items:center;justify-content:center;\n  width:14px;height:14px;border-radius:50%;border:1px solid var(--ink-soft);\n  font-size:9.5px;color:var(--ink-soft);cursor:help;flex-shrink:0;\n}\n\n.slider-row{margin-bottom:12px;}\n.slider-row label{\n  display:flex;justify-content:space-between;font-size:12.5px;margin-bottom:5px;color:var(--ink);\n}\n.slider-row output{font-family:var(--font-mono);color:var(--ink-soft);}\ninput[type=\"range\"]{\n  -webkit-appearance:none;width:100%;height:2px;background:var(--line-strong);outline:none;\n}\ninput[type=\"range\"]::-webkit-slider-thumb{\n  -webkit-appearance:none;width:14px;height:14px;border-radius:50%;\n  background:var(--prussian);cursor:pointer;border:2px solid var(--white);box-shadow:0 0 0 1px var(--prussian);\n}\ninput[type=\"range\"]::-moz-range-thumb{\n  width:14px;height:14px;border-radius:50%;background:var(--prussian);cursor:pointer;border:2px solid var(--white);box-shadow:0 0 0 1px var(--prussian);\n}\ninput[type=\"range\"]:focus-visible::-webkit-slider-thumb{outline:2px solid var(--focus);outline-offset:2px;}\n\n.btn-row{display:flex;flex-wrap:wrap;gap:8px;}\n.btn{\n  border:1px solid var(--line-strong);background:var(--white);color:var(--ink);\n  padding:9px 12px;font-size:12px;letter-spacing:.02em;border-radius:var(--radius);\n  cursor:pointer;display:inline-flex;align-items:center;gap:7px;\n}\n.btn:hover{border-color:var(--prussian);color:var(--prussian);}\n.btn:focus-visible{outline:2px solid var(--focus);outline-offset:1px;}\n.btn.icon-only{padding:8px 10px;font-size:16px;line-height:1;}\n#btnRotate{font-size:19px;}\n\/* hover tooltips on the transform icon buttons *\/\n.icon-tip{position:relative;}\n.icon-tip::after{\n  content:attr(data-tip);\n  position:absolute;bottom:calc(100% + 8px);left:50%;transform:translateX(-50%);\n  background:var(--ink);color:var(--paper);\n  font-size:11.5px;line-height:1.4;white-space:nowrap;\n  padding:7px 10px;border-radius:var(--radius);\n  box-shadow:0 6px 20px rgba(0,0,0,.25);\n  opacity:0;pointer-events:none;transition:opacity .12s;z-index:20;\n}\n.icon-tip:hover::after, .icon-tip:focus-visible::after{opacity:1;}\n.btn-primary{background:var(--prussian);border-color:var(--prussian);color:var(--white);}\n.btn-primary:hover{background:var(--prussian-deep);border-color:var(--prussian-deep);color:var(--white);}\n.btn-ghost{border-color:transparent;background:none;}\n.btn-ghost:hover{background:var(--white);}\n.btn-block{width:100%;justify-content:center;}\n.btn[disabled]{opacity:.4;cursor:not-allowed;}\n.btn[disabled]:hover{border-color:var(--line-strong);color:var(--ink);}\n\n.toggle-row{\n  display:flex;align-items:flex-start;justify-content:space-between;gap:12px;\n  padding:10px 0;border-top:1px solid var(--line);\n}\n.toggle-row:first-child{border-top:none;}\n.toggle-copy{font-size:12.5px;}\n.toggle-copy .sub{display:block;color:var(--ink-soft);font-size:11px;margin-top:2px;line-height:1.4;}\n.switch{position:relative;width:36px;height:20px;flex-shrink:0;}\n.switch input{opacity:0;width:0;height:0;}\n.switch span{\n  position:absolute;inset:0;background:var(--line-strong);border-radius:20px;transition:.15s;cursor:pointer;\n}\n.switch span::before{\n  content:\"\";position:absolute;width:14px;height:14px;left:3px;top:3px;\n  background:var(--white);border-radius:50%;transition:.15s;\n}\n.switch input:checked + span{background:var(--prussian);}\n.switch input:checked + span::before{transform:translateX(16px);}\n.switch input:focus-visible + span{outline:2px solid var(--focus);outline-offset:2px;}\n\n.select-row{margin-bottom:14px;}\n.select-row label{display:block;font-size:12.5px;margin-bottom:6px;}\nselect, .num-input{\n  width:100%;padding:9px 10px;border:1px solid var(--line-strong);background:var(--white);\n  color:var(--ink);font-size:13px;border-radius:var(--radius);font-family:var(--font-body);\n}\n.two-col{display:grid;grid-template-columns:1fr 1fr;gap:10px;}\n\n.substrate-toggle{display:flex;gap:8px;margin-bottom:18px;}\n.substrate-toggle button{\n  flex:1;padding:10px;border:1px solid var(--line-strong);background:var(--white);\n  font-size:12.5px;cursor:pointer;border-radius:var(--radius);\n}\n.substrate-toggle button.active{border-color:var(--prussian);background:var(--prussian-tint);color:var(--prussian-deep);font-weight:600;}\n\n.panel-footer{\n  padding:18px 22px 26px;border-top:1px solid var(--line-strong);\n  display:flex;flex-direction:column;gap:10px;position:sticky;bottom:0;background:var(--paper-deep);\n}\n\n.divider{height:1px;background:var(--line);margin:18px 0;border:none;}\n\n.badge{\n  display:inline-block;font-family:var(--font-mono);font-size:9.5px;letter-spacing:.08em;\n  text-transform:uppercase;padding:2px 6px;border:1px solid var(--line-strong);border-radius:2px;\n  color:var(--ink-soft);\n}\n\n\/* tooltip popover *\/\n.pop{position:relative;}\n.pop .pop-body{\n  display:none;position:absolute;z-index:10;left:0;top:20px;width:220px;\n  background:var(--ink);color:var(--paper);font-size:11.5px;line-height:1.5;\n  padding:10px 12px;border-radius:var(--radius);box-shadow:0 6px 20px rgba(0,0,0,.3);\n}\n.pop:hover .pop-body, .pop:focus-within .pop-body, .pop.open .pop-body{display:block;}\n\n\/* step wedge preview swatch *\/\n.wedge-preview{display:flex;height:16px;margin-top:8px;border:1px solid var(--line-strong);}\n.wedge-preview div{flex:1;}\n\n\/* status \/ toast *\/\n.toast{\n  position:fixed;bottom:24px;left:50%;transform:translateX(-50%) translateY(20px);\n  background:var(--ink);color:var(--paper);padding:11px 18px;border-radius:var(--radius);\n  font-size:12.5px;opacity:0;pointer-events:none;transition:.25s;z-index:50;\n}\n.toast.show{opacity:1;transform:translateX(-50%) translateY(0);}\n\n\/* progress veil for exports *\/\n.veil{\n  position:fixed;inset:0;background:rgba(20,18,14,.55);display:none;\n  align-items:center;justify-content:center;z-index:60;color:var(--white);font-size:13px;\n}\n.veil.show{display:flex;}\n.veil .box{background:var(--ink);padding:22px 28px;border-radius:var(--radius);text-align:center;min-width:220px;}\n.veil .spinner{\n  width:22px;height:22px;border-radius:50%;border:2px solid rgba(255,255,255,.25);\n  border-top-color:var(--white);margin:0 auto 12px;animation:spin .8s linear infinite;\n}\n@keyframes spin{to{transform:rotate(360deg);}}\n\n\/* file input hidden *\/\n#fileInput{display:none;}\n\n\/* small screens: workspace controls stack *\/\n@media (max-width:600px){\n  .workspace{padding:16px;}\n  .panel-section{padding:18px 16px 6px;}\n}\n\n\/* ---------- guide \/ SEO content section ---------- *\/\n.guide-section{\n  background:var(--white);\n  border-top:1px solid var(--line);\n  padding:56px 28px 72px;\n}\n.guide-inner{max-width:760px;margin:0 auto;}\n.guide-inner h2{\n  font-family:var(--font-display);font-weight:700;font-size:24px;\n  margin:48px 0 14px;color:var(--ink);\n}\n.guide-inner h2:first-child{margin-top:0;}\n.guide-inner h3{\n  font-family:var(--font-body);font-weight:700;font-size:15px;\n  margin:22px 0 6px;color:var(--prussian-deep);\n}\n.guide-inner p, .guide-inner li{\n  font-size:14px;line-height:1.65;color:var(--ink);margin:0 0 12px;\n}\n.guide-inner a{color:var(--prussian);}\n.guide-intro{font-size:15px;color:var(--ink-soft);max-width:640px;}\n.guide-steps{counter-reset:step;list-style:none;padding:0;margin:24px 0;}\n.guide-steps > li{\n  position:relative;padding:0 0 22px 44px;margin:0;\n  border-left:1px solid var(--line);\n}\n.guide-steps > li:last-child{border-left-color:transparent;padding-bottom:0;}\n.guide-steps > li::before{\n  counter-increment:step;content:counter(step);\n  position:absolute;left:-15px;top:0;\n  width:29px;height:29px;border-radius:50%;\n  background:var(--prussian);color:var(--white);\n  font-family:var(--font-mono);font-size:12.5px;font-weight:600;\n  display:flex;align-items:center;justify-content:center;\n}\n.guide-steps h3{margin-top:0;}\n.needs-list{\n  display:grid;grid-template-columns:repeat(2,1fr);gap:8px 24px;\n  list-style:none;padding:0;margin:16px 0 24px;\n}\n.needs-list li{\n  display:flex;align-items:baseline;gap:8px;font-size:14px;\n}\n.needs-list li::before{content:\"\u2022\";color:var(--prussian);flex-shrink:0;}\n@media (max-width:600px){ .needs-list{grid-template-columns:1fr;} }\n\n.faq details{\n  border-top:1px solid var(--line);padding:14px 0;\n}\n.faq details:last-child{border-bottom:1px solid var(--line);}\n.faq summary{\n  font-weight:600;font-size:14.5px;cursor:pointer;color:var(--ink);\n  list-style:none;display:flex;justify-content:space-between;align-items:center;gap:12px;\n}\n.faq summary::-webkit-details-marker{display:none;}\n.faq summary::after{content:\"+\";font-size:18px;color:var(--prussian);flex-shrink:0;}\n.faq details[open] summary::after{content:\"\\2212\";}\n.faq details p{margin-top:10px;color:var(--ink-soft);}\n\n.product-cta{\n  background:var(--prussian-tint);\n  border:1px solid var(--line-strong);\n  border-radius:var(--radius);\n  padding:22px 24px;\n  margin:32px 0;\n  display:flex;flex-wrap:wrap;gap:16px;align-items:center;justify-content:space-between;\n}\n.product-cta p{margin:0;font-size:14px;color:var(--prussian-deep);max-width:440px;}\n.product-cta .btn-row{flex-shrink:0;}\n.product-cta a.btn{\n  display:inline-flex;background:var(--prussian);color:var(--white);border-color:var(--prussian);\n  padding:10px 16px;border-radius:var(--radius);font-size:12.5px;text-decoration:none;\n}\n.product-cta a.btn:hover{background:var(--prussian-deep);border-color:var(--prussian-deep);}\n.product-cta a.btn.secondary{background:var(--white);color:var(--prussian);border-color:var(--line-strong);}\n\n.back-to-top{display:inline-block;margin-top:8px;font-size:12.5px;}\n<\/style>\n<\/head>\n<body id=\"top\">\n\n<div class=\"page-intro\">\n  <h1>Kostenloser Cyanotypie-Negativgenerator<\/h1>\n  <p>Verwandeln Sie jedes Foto direkt in Ihrem Browser in ein druckfertiges <strong>Cyanotypie-Negativ<\/strong>. Passen Sie Tonwerte und Kontrast an, kehren Sie das Bild um und spiegeln Sie es, f\u00fcgen Sie einen Graukeil zur Kalibrierung hinzu und laden Sie ein Negativ im richtigen Format f\u00fcr den Cyanotypie-Druck auf Papier oder Stoff herunter. M\u00f6chten Sie zuerst den Ablauf verstehen? <a href=\"#guide\">Springen Sie unten zur Schritt-f\u00fcr-Schritt-Anleitung<\/a>.<\/p>\n<\/div>\n\n<div class=\"privacy-strip\">\n  <strong>Funktioniert vollst\u00e4ndig in Ihrem Browser.<\/strong> Ihr Foto wird niemals von uns gespeichert. Alles geschieht auf Ihrem eigenen Ger\u00e4t.\n<\/div>\n\n<div class=\"mobile-note\" id=\"mobileNote\">\n  <span>Dieses Tool ist f\u00fcr Maus und gr\u00f6\u00dferen Bildschirm konzipiert. Zuschneiden und das Entfernen des Hintergrunds sind besonders auf einem Desktop- oder Laptop-Computer einfacher.<\/span>\n  <button id=\"mobileNoteClose\" aria-label=\"Dismiss\">\u00d7<\/button>\n<\/div>\n\n<div class=\"app-body\">\n\n  <!-- ================= WORKSPACE ================= -->\n  <main class=\"workspace\">\n    <div class=\"viewmode-row\">\n      <div style=\"display:flex;align-items:center;gap:8px;\">\n        <div class=\"segmented\" id=\"viewModeSeg\" role=\"tablist\" aria-label=\"Preview mode\">\n          <button data-mode=\"original\" class=\"active\">Original<\/button>\n          <button data-mode=\"tone\">Ton<\/button>\n          <button data-mode=\"negative\">Negativ<\/button>\n          <button data-mode=\"cyanotype\">Cyanotypie-Vorschau<\/button>\n          <button data-mode=\"shirt\">Auf einem T-Shirt<\/button>\n        <\/div>\n        <span class=\"pop info-dot\" tabindex=\"0\">\u24d8<span class=\"pop-body\"><strong>Original<\/strong>: Ihr Foto, unbearbeitet.<br><strong>Ton<\/strong>: Schwarz-Wei\u00df, mit angewendeten Tonwerten und Kontrast, vor der Umkehrung.<br><strong>Negativ<\/strong>: das umgekehrte Bild, das Sie tats\u00e4chlich auf Transparentfolie drucken.<br><strong>Cyanotypie-Vorschau<\/strong>: eine Ann\u00e4herung an den fertigen blauen Druck.<br><strong>Auf einem T-Shirt<\/strong>: eine Simulation des Cyanotypie-Drucks auf einem Kleidungsst\u00fcck.<\/span><\/span>\n      <\/div>\n      <div class=\"btn-row\" id=\"shirtControls\" style=\"display:none;align-items:center;\">\n        <span style=\"font-size:11.5px;color:var(--ink-soft);margin-right:2px;\">Druckgr\u00f6\u00dfe<\/span>\n        <div class=\"segmented\" id=\"shirtSizeSeg\">\n          <button data-size=\"small\">Klein<\/button>\n          <button data-size=\"medium\" class=\"active\">Mittel<\/button>\n          <button data-size=\"large\">Gro\u00df<\/button>\n        <\/div>\n      <\/div>\n      <div id=\"wandControls\" style=\"display:none;flex-direction:column;gap:10px;\">\n        <div class=\"btn-row\" style=\"align-items:center;\">\n          <label style=\"display:flex;align-items:center;gap:6px;font-size:12px;\">\n            <input type=\"checkbox\" id=\"wandContiguous\" checked style=\"width:16px;height:16px;\">\n            Zusammenh\u00e4ngend\n          <\/label>\n          <label style=\"display:flex;align-items:center;gap:6px;font-size:12px;\">\n            <input type=\"checkbox\" id=\"wandAddMode\" style=\"width:16px;height:16px;\">\n            Zur Auswahl hinzuf\u00fcgen\n          <\/label>\n          <label style=\"display:flex;align-items:center;gap:6px;font-size:12px;\">\n            Toleranz\n            <input type=\"range\" id=\"wandTolerance\" min=\"1\" max=\"100\" value=\"30\" style=\"width:90px;\">\n            <output id=\"wandToleranceOut\" style=\"font-family:var(--font-mono);font-size:11px;\">30<\/output>\n          <\/label>\n          <label style=\"display:flex;align-items:center;gap:6px;font-size:12px;\">\n            Weichzeichnen\n            <input type=\"range\" id=\"wandFeather\" min=\"0\" max=\"20\" value=\"2\" style=\"width:70px;\">\n            <output id=\"wandFeatherOut\" style=\"font-family:var(--font-mono);font-size:11px;\">2<\/output>\n          <\/label>\n          <button class=\"btn\" id=\"wandClear\">Auswahl aufheben<\/button>\n        <\/div>\n        <div class=\"btn-row\" style=\"align-items:center;\">\n          <span style=\"font-size:11.5px;color:var(--ink-soft);\">Tippen Sie auf den Hintergrund, um ihn auszuw\u00e4hlen. Aktivieren Sie &#8222;Zur Auswahl hinzuf\u00fcgen&#8220; (oder Umschalt-Klick am Computer), um mehr als einen Bereich auszuw\u00e4hlen.<\/span>\n          <button class=\"btn btn-primary\" id=\"wandApply\" disabled>Anwenden (mit Wei\u00df f\u00fcllen)<\/button>\n          <button class=\"btn btn-ghost\" id=\"wandCancel\">Abbrechen<\/button>\n        <\/div>\n      <\/div>\n      <div class=\"btn-row\">\n        <button class=\"btn icon-only icon-tip\" id=\"btnRotate\" data-tip=\"Um 90\u00b0 drehen, n\u00fctzlich f\u00fcr ein seitlich aufgenommenes Foto\" aria-label=\"Um 90 Grad drehen\">\u27f3<\/button>\n        <button class=\"btn icon-only icon-tip\" id=\"btnFlipH\" data-tip=\"Horizontal spiegeln (nicht dasselbe wie die Druckspiegelung)\" aria-label=\"Horizontal spiegeln\">\u21cb<\/button>\n        <button class=\"btn icon-only icon-tip\" id=\"btnFlipV\" data-tip=\"Vertikal spiegeln\" aria-label=\"Vertikal spiegeln\">\u21f5<\/button>\n        <button class=\"btn\" id=\"btnCrop\" title=\"Zuschneiden\">Zuschneiden<\/button>\n        <button class=\"btn\" id=\"btnWand\" title=\"Hintergrund entfernen oder einheitlich wei\u00df machen\">Hintergrund entfernen<\/button>\n        <span class=\"pop info-dot\" tabindex=\"0\">\u24d8<span class=\"pop-body\">Tippen Sie auf einen Bereich des Hintergrunds, um ihn auszuw\u00e4hlen (wie ein F\u00fclleimer), und klicken Sie dann auf Anwenden, um ihn einheitlich wei\u00df zu machen. Wei\u00df wird im Negativ zu Schwarz, was UV-Licht vollst\u00e4ndig blockiert, sodass Ihr Hintergrund als sauberes Papier statt als fleckiges Blau gedruckt wird. Aktivieren Sie &#8222;Zur Auswahl hinzuf\u00fcgen&#8220; (oder Umschalt-Klick am Computer), um mehrere Bereiche auszuw\u00e4hlen, etwa Himmel und Schatten gleichzeitig. &#8222;Zusammenh\u00e4ngend&#8220; begrenzt die Auswahl auf Pixel, die die angetippte Stelle ber\u00fchren, deaktivieren Sie es, um eine \u00e4hnliche Farbe \u00fcberall im Foto zu erfassen.<\/span><\/span>\n        <button class=\"btn\" id=\"btnReset\" title=\"Zum Original zur\u00fcckkehren\">Zur\u00fccksetzen<\/button>\n        <button class=\"btn\" id=\"btnUndoWand\" title=\"Letzte Hintergrundentfernung r\u00fcckg\u00e4ngig machen\" style=\"display:none;\">Entfernung r\u00fcckg\u00e4ngig machen<\/button>\n        <button class=\"btn\" id=\"btnRemove\" title=\"Foto entfernen und von vorn beginnen\">Foto entfernen<\/button>\n      <\/div>\n    <\/div>\n\n    <div class=\"stage-wrap reg-corners\" id=\"stageWrap\">\n      <span class=\"rc-tr\"><\/span><span class=\"rc-bl\"><\/span>\n      <div class=\"dropzone\" id=\"dropzone\">\n        <div class=\"dz-icon\">\u2191<\/div>\n        <h2>Foto hierher ziehen oder klicken, um eines auszuw\u00e4hlen<\/h2>\n        <p>JPG, PNG oder WebP. Funktioniert am besten mit einem klaren Motiv und einem guten Tonwertumfang.<\/p>\n        <p class=\"privacy-note\">\u25cf Nichts verl\u00e4sst Ihr Ger\u00e4t<\/p>\n      <\/div>\n      <canvas id=\"stage\" style=\"display:none;\"><\/canvas>\n      <div id=\"cropOverlay\">\n        <div class=\"crop-rect\" id=\"cropRect\">\n          <div class=\"crop-handle nw\"><\/div>\n          <div class=\"crop-handle ne\"><\/div>\n          <div class=\"crop-handle sw\"><\/div>\n          <div class=\"crop-handle se\"><\/div>\n        <\/div>\n        <div class=\"crop-toolbar\">\n          <button class=\"btn\" data-ar=\"free\">Frei<\/button>\n          <button class=\"btn\" data-ar=\"1:1\">1:1<\/button>\n          <button class=\"btn\" data-ar=\"4:5\">4:5<\/button>\n          <button class=\"btn\" data-ar=\"3:4\">3:4<\/button>\n          <span style=\"width:1px;background:#ccc;\"><\/span>\n          <button class=\"btn btn-primary\" id=\"cropApply\">Anwenden<\/button>\n          <button class=\"btn btn-ghost\" id=\"cropCancel\">Abbrechen<\/button>\n        <\/div>\n      <\/div>\n      <input type=\"file\" id=\"fileInput\" accept=\"image\/*\">\n    <\/div>\n\n    <div class=\"hist-row\">\n      <canvas id=\"histCanvas\" width=\"220\" height=\"46\"><\/canvas>\n      <span class=\"hist-label\">Tonwert-Histogramm\n        <span class=\"pop info-dot\" tabindex=\"0\">\u24d8<span class=\"pop-body\">Dieses Diagramm zeigt, wie viele Pixel sich auf jeder Helligkeitsstufe befinden, von Schwarz links bis Wei\u00df rechts. Ein hoher Ausschlag direkt an einem Rand bedeutet &#8222;Clipping&#8220;: Details wurden in reines Schwarz oder Wei\u00df gepresst und k\u00f6nnen nicht wiederhergestellt werden. Ziehen Sie die Regler Schwarzpunkt oder Wei\u00dfpunkt von diesem Rand nach innen, bis sich der Ausschlag von der Wand l\u00f6st.<\/span><\/span>\n      <\/span>\n    <\/div>\n  <\/main>\n\n  <!-- ================= PANEL ================= -->\n  <aside class=\"panel\">\n    <div class=\"panel-tabs\">\n      <button class=\"active\" data-tab=\"tone\">Ton<\/button>\n      <button data-tab=\"print\">Drucken und Exportieren<\/button>\n      <button data-tab=\"help\">Anleitung<\/button>\n    <\/div>\n\n    <!-- ---- TONE TAB ---- -->\n    <section class=\"panel-section active\" data-tab=\"tone\">\n\n      <div class=\"substrate-toggle\" role=\"tablist\" aria-label=\"Substrate preset\">\n        <button class=\"active\" id=\"substratePaper\">Papier<\/button>\n        <button id=\"substrateFabric\">Stoff<\/button>\n      <\/div>\n\n      <div class=\"field-group\">\n        <h3>Tonwerte\n          <span class=\"pop info-dot\" tabindex=\"0\">\u24d8<span class=\"pop-body\">Legt den Schwarz- und Wei\u00dfpunkt fest, bevor Gamma und Helligkeit\/Kontrast angewendet werden. Ziehen Sie den Wei\u00dfpunkt nach innen, wenn Ihre Lichter nach der Belichtung matt wirken.<\/span><\/span>\n        <\/h3>\n        <div class=\"slider-row\">\n          <label><span>Schwarzpunkt <span class=\"pop info-dot\" tabindex=\"0\">\u24d8<span class=\"pop-body\">Ziehen Sie diesen Regler von 0 nach innen, um dem Tool mitzuteilen, wo &#8222;echtes Schwarz&#8220; in Ihrem Foto beginnt. Alles, was dunkler ist, wird zu reinem Schwarz. Nutzen Sie ihn daher zusammen mit dem Histogramm und ziehen Sie ihn nicht so weit, dass Sie Details in den Schatten verlieren.<\/span><\/span><\/span> <output id=\"outBlack\">0<\/output><\/label>\n          <input type=\"range\" id=\"black\" min=\"0\" max=\"120\" value=\"0\">\n        <\/div>\n        <div class=\"slider-row\">\n          <label><span>Wei\u00dfpunkt <span class=\"pop info-dot\" tabindex=\"0\">\u24d8<span class=\"pop-body\">Das Gegenst\u00fcck zum Schwarzpunkt: Ziehen Sie diesen Regler von 255 nach unten, um festzulegen, wo &#8222;echtes Wei\u00df&#8220; beginnt. Dieser Regler behebt am ehesten flache, matt wirkende Lichter.<\/span><\/span><\/span> <output id=\"outWhite\">255<\/output><\/label>\n          <input type=\"range\" id=\"white\" min=\"135\" max=\"255\" value=\"255\">\n        <\/div>\n        <div class=\"slider-row\">\n          <label><span>Gamma <span class=\"pop info-dot\" tabindex=\"0\">\u24d8<span class=\"pop-body\">Hellt nur die Mittelt\u00f6ne auf oder verdunkelt sie, w\u00e4hrend Ihr Schwarz- und Wei\u00dfpunkt unver\u00e4ndert bleiben. Cyanotypie neigt dazu, die Trennung der Mittelt\u00f6ne zu verlieren, daher druckt ein Gamma von etwa 1,1 bis 1,4 oft mit mehr sichtbarem Detail als ein flaches 1,0.<\/span><\/span><\/span> <output id=\"outGamma\">1.00<\/output><\/label>\n          <input type=\"range\" id=\"gamma\" min=\"40\" max=\"260\" value=\"100\">\n        <\/div>\n      <\/div>\n\n      <div class=\"field-group\">\n        <h3>Helligkeit und Kontrast<\/h3>\n        <div class=\"slider-row\">\n          <label>Helligkeit <output id=\"outBrightness\">0<\/output><\/label>\n          <input type=\"range\" id=\"brightness\" min=\"-100\" max=\"100\" value=\"0\">\n        <\/div>\n        <div class=\"slider-row\">\n          <label><span>Kontrast <span class=\"pop info-dot\" tabindex=\"0\">\u24d8<span class=\"pop-body\">Macht helle T\u00f6ne heller und dunkle T\u00f6ne dunkler, gleichzeitig. Bei Cyanotypie bewirkt schon wenig viel, da der Prozess von Natur aus bereits einen kr\u00e4ftigen Kontrast mitbringt. Wirkt Ihr Druck hart und hat die dazwischenliegenden Graut\u00f6ne verloren, setzen Sie diesen Regler wieder herunter.<\/span><\/span><\/span> <output id=\"outContrast\">0<\/output><\/label>\n          <input type=\"range\" id=\"contrast\" min=\"-100\" max=\"100\" value=\"0\">\n        <\/div>\n      <\/div>\n\n      <div class=\"field-group\">\n        <h3>Farbe<\/h3>\n        <div class=\"slider-row\">\n          <label><span>S\u00e4ttigung <span class=\"pop info-dot\" tabindex=\"0\">\u24d8<span class=\"pop-body\">Cyanotypie erfasst nur Helligkeit, keine Farbe, daher belichten ein Rot- und ein Gr\u00fcnton gleicher Helligkeit identisch, unabh\u00e4ngig davon, was dieser Regler bewirkt. Ihn zu verringern \u00e4ndert nur, was Sie auf dem Bildschirm sehen, n\u00e4her daran, wie die &#8222;Helligkeit&#8220; jeder Farbe im Vergleich zueinander wahrgenommen wird, was die Beurteilung des Tons mit blo\u00dfem Auge vor der Umwandlung erleichtern kann.<\/span><\/span><\/span> <output id=\"outSaturate\">100<\/output>%<\/label>\n          <input type=\"range\" id=\"saturate\" min=\"0\" max=\"100\" value=\"100\">\n        <\/div>\n      <\/div>\n\n      <button class=\"btn btn-block\" id=\"btnAutoLevels\">Automatische Tonwerte<\/button>\n      <p style=\"font-size:11px;color:var(--ink-soft);margin:8px 0 0;\">Legt Schwarz- und Wei\u00dfpunkt anhand der tats\u00e4chlich dunkelsten und hellsten Pixel Ihres Fotos fest. Ein guter Ausgangspunkt vor der Feinabstimmung mit blo\u00dfem Auge.<\/p>\n    <\/section>\n\n    <!-- ---- PRINT TAB ---- -->\n    <section class=\"panel-section\" data-tab=\"print\">\n\n      <div class=\"field-group\">\n        <h3>Kontaktdruck\n          <span class=\"pop info-dot\" tabindex=\"0\">\u24d8<span class=\"pop-body\">Tintenstrahl-Transparentfolien werden mit der Tintenseite nach unten gegen das beschichtete Papier oder den Stoff belichtet, wodurch das Bild gespiegelt wird. Lassen Sie dies aktiviert, es sei denn, Ihr Drucker kehrt die Ausgabe bereits um.<\/span><\/span>\n        <\/h3>\n        <div class=\"toggle-row\">\n          <div class=\"toggle-copy\">F\u00fcr Kontaktdruck spiegeln\n            <span class=\"sub\">Spiegelt den Export, damit er richtig lesbar ist, sobald er mit der Tintenseite nach unten aufgelegt wird<\/span>\n          <\/div>\n          <label class=\"switch\"><input type=\"checkbox\" id=\"mirrorPrint\" checked><span><\/span><\/label>\n        <\/div>\n      <\/div>\n\n      <div class=\"field-group\">\n        <h3>Seiteneinrichtung<\/h3>\n        <div class=\"select-row\">\n          <label>Papierformat<\/label>\n          <select id=\"paperSize\">\n            <option value=\"A4\">A4 (210 \u00d7 297 mm)<\/option>\n            <option value=\"A3\">A3 (297 \u00d7 420 mm)<\/option>\n            <option value=\"LETTER\">US Letter (8.5 \u00d7 11 in)<\/option>\n            <option value=\"LEGAL\">US Legal (8.5 \u00d7 14 in)<\/option>\n            <option value=\"SQUARE\">Square (200 \u00d7 200 mm)<\/option>\n            <option value=\"CUSTOM\">Benutzerdefiniert\u2026<\/option>\n          <\/select>\n        <\/div>\n        <div class=\"two-col\" id=\"customSizeRow\" style=\"display:none;margin-bottom:14px;\">\n          <div><label style=\"font-size:11.5px;\">Breite (mm)<\/label><input class=\"num-input\" id=\"customW\" type=\"number\" value=\"210\"><\/div>\n          <div><label style=\"font-size:11.5px;\">H\u00f6he (mm)<\/label><input class=\"num-input\" id=\"customH\" type=\"number\" value=\"297\"><\/div>\n        <\/div>\n        <div class=\"two-col\">\n          <div class=\"select-row\">\n            <label>Ausrichtung<\/label>\n            <select id=\"orientation\">\n              <option value=\"portrait\">Hochformat<\/option>\n              <option value=\"landscape\">Querformat<\/option>\n            <\/select>\n          <\/div>\n          <div class=\"select-row\">\n            <label>Aufl\u00f6sung <span class=\"pop info-dot\" tabindex=\"0\">\u24d8<span class=\"pop-body\">Punkte pro Zoll auf der gedruckten Transparentfolie. 300 DPI ist der Standard f\u00fcr scharfe Cyanotypie-Negative auf einem Heim-Tintenstrahldrucker. W\u00e4hlen Sie 600 nur f\u00fcr sehr feine Details, das f\u00fchrt zu deutlich gr\u00f6\u00dferen Dateien und langsameren Exporten. 150 eignet sich f\u00fcr schnelle Teststreifen.<\/span><\/span><\/label>\n            <select id=\"dpi\">\n              <option value=\"150\">150 DPI, Entwurf<\/option>\n              <option value=\"300\" selected>300 DPI, empfohlen<\/option>\n              <option value=\"600\">600 DPI, feines Detail<\/option>\n            <\/select>\n          <\/div>\n        <\/div>\n        <div class=\"select-row\">\n          <label>Anpassung <span class=\"pop info-dot\" tabindex=\"0\">\u24d8<span class=\"pop-body\">&#8222;In R\u00e4nder einpassen&#8220; zeigt Ihr gesamtes Foto, mit etwas Leerraum, falls die Proportionen nicht zum Papier passen. &#8222;R\u00e4nder ausf\u00fcllen&#8220; schneidet die R\u00e4nder Ihres Fotos zu, damit der Druck die gesamte Seite ohne L\u00fccken ausf\u00fcllt.<\/span><\/span><\/label>\n          <select id=\"fitMode\">\n            <option value=\"fit\">In R\u00e4nder einpassen<\/option>\n            <option value=\"fill\">R\u00e4nder ausf\u00fcllen (kann zuschneiden)<\/option>\n          <\/select>\n        <\/div>\n        <div class=\"select-row\">\n          <label>Rand (mm) <output id=\"outMargin\">10<\/output><\/label>\n          <input type=\"range\" id=\"margin\" min=\"0\" max=\"40\" value=\"10\">\n        <\/div>\n      <\/div>\n\n      <div class=\"field-group\">\n        <div class=\"toggle-row\">\n          <div class=\"toggle-copy\">Passkreuze\n            <span class=\"sub\">Eckmarkierungen zum Ausrichten von Papier und Stoff zwischen Teststreifen<\/span>\n          <\/div>\n          <label class=\"switch\"><input type=\"checkbox\" id=\"cropMarks\" checked><span><\/span><\/label>\n        <\/div>\n        <div class=\"toggle-row\">\n          <div class=\"toggle-copy\">Belichtungs-Graukeil\n            <span class=\"sub\">Eine 11-stufige Graustufenskala, mit der Sie Ihre Belichtungszeit einmal bestimmen und wiederverwenden k\u00f6nnen<\/span>\n          <\/div>\n          <label class=\"switch\"><input type=\"checkbox\" id=\"stepWedge\"><span><\/span><\/label>\n        <\/div>\n        <div class=\"wedge-preview\" id=\"wedgePreview\"><\/div>\n      <\/div>\n\n      <div class=\"field-group\">\n        <h3>Poster-\/Wandbild-Kachelung\n          <span class=\"pop info-dot\" tabindex=\"0\">\u24d8<span class=\"pop-body\">Teilt ein gro\u00dfes Negativ auf mehrere Bl\u00e4tter des oben gew\u00e4hlten Papierformats auf, mit \u00dcberlappung, damit Sie sie ausrichten k\u00f6nnen. N\u00fctzlich f\u00fcr \u00fcbergro\u00dfe Stoffwandbilder und Fotogramme.<\/span><\/span>\n        <\/h3>\n        <div class=\"toggle-row\" style=\"border-top:none;\">\n          <div class=\"toggle-copy\">Auf mehrere Bl\u00e4tter aufteilen<\/div>\n          <label class=\"switch\"><input type=\"checkbox\" id=\"posterMode\"><span><\/span><\/label>\n        <\/div>\n        <div id=\"posterOptions\" style=\"display:none;margin-top:12px;\">\n          <div class=\"two-col\">\n            <div><label style=\"font-size:11.5px;\">Endg\u00fcltige Breite (cm)<\/label><input class=\"num-input\" id=\"posterW\" type=\"number\" value=\"60\"><\/div>\n            <div><label style=\"font-size:11.5px;\">Endg\u00fcltige H\u00f6he (cm)<\/label><input class=\"num-input\" id=\"posterH\" type=\"number\" value=\"80\"><\/div>\n          <\/div>\n          <div class=\"select-row\" style=\"margin-top:10px;\">\n            <label>\u00dcberlappung (mm)<\/label>\n            <input class=\"num-input\" id=\"posterOverlap\" type=\"number\" value=\"10\">\n          <\/div>\n          <p style=\"font-size:11px;color:var(--ink-soft);margin:8px 0 0;\" id=\"tileCount\"><\/p>\n        <\/div>\n      <\/div>\n    <\/section>\n\n    <!-- ---- HELP TAB ---- -->\n    <section class=\"panel-section\" data-tab=\"help\">\n      <div class=\"field-group\">\n        <h3>Dieses Tool verstehen<\/h3>\n        <p style=\"font-size:12.5px;\">1. Laden Sie ein Foto hoch, schneiden Sie es dann zu und richten Sie es in <strong>Original<\/strong> gerade aus.<br><br>\n        2. Wechseln Sie zu <strong>Ton<\/strong>, um den Kontrast vor der Umkehrung zu gestalten. Lichter, die jetzt zulaufen, drucken als nicht wiederherstellbares Dunkelblau.<br><br>\n        3. <strong>Negativ<\/strong> ist das, was Sie tats\u00e4chlich auf Transparentfolie drucken. <strong>Cyanotypie-Vorschau<\/strong> n\u00e4hert sich dem fertigen blauen Druck an, und <strong>Auf einem T-Shirt<\/strong> simuliert ihn auf einem Kleidungsst\u00fcck, damit Sie nicht raten m\u00fcssen.<br><br>\n        4. W\u00e4hlen Sie unter <em>Drucken und Exportieren<\/em> Ihr Papier und drucken Sie, oder laden Sie eine druckfertige Datei in voller Aufl\u00f6sung herunter.<\/p>\n      <\/div>\n      <div class=\"field-group\">\n        <h3>Belichtungs-Graukeil<\/h3>\n        <p style=\"font-size:12.5px;\">Drucken Sie den Graukeil einmal auf einen Teststreifen, belichten und entwickeln Sie ihn wie gewohnt, und notieren Sie sich die Belichtungszeit, bei der sowohl in den hellsten als auch den dunkelsten Stufen Details erhalten bleiben. Verwenden Sie diese Zeit f\u00fcr den Rest der Auflage weiter.<\/p>\n      <\/div>\n      <div class=\"field-group\">\n        <h3>Papier oder Stoff<\/h3>\n        <p style=\"font-size:12.5px;\">Stoff verliert bei der Beschichtung und beim Waschen h\u00e4ufig feine Details in den Schatten, daher erh\u00f6ht die Stoff-Voreinstellung Kontrast und Schwarzpunkt leicht. Betrachten Sie dies als Ausgangspunkt, nicht als feste Regel.<\/p>\n      <\/div>\n    <\/section>\n\n    <div class=\"panel-footer\">\n      <button class=\"btn btn-primary btn-block\" id=\"btnPrint\" disabled>Negativ drucken<\/button>\n      <div class=\"two-col\">\n        <button class=\"btn btn-block\" id=\"btnDownloadPNG\" disabled>PNG herunterladen<\/button>\n        <button class=\"btn btn-block\" id=\"btnDownloadPage\" disabled>Seite herunterladen<\/button>\n      <\/div>\n    <\/div>\n  <\/aside>\n<\/div>\n\n<div class=\"toast\" id=\"toast\"><\/div><div class=\"veil\" id=\"veil\"><div class=\"box\"><div class=\"spinner\"><\/div><div id=\"veilText\">Preparing export\u2026<\/div><\/div><\/div>\n\n<script>\n(function(){\n\"use strict\";\n\nfunction initNegativeMaker(){\n\n\/* ===========================================================\n   STATE\n   =========================================================== *\/\nconst state = {\n  hasImage:false,\n  fullCanvas:null,      \/\/ full-resolution, color, as-uploaded (capped at MAX_DIM)\n  sourceCanvas:null,    \/\/ fullCanvas after rotate\/flip\/crop, still full-res color\n  previewSource:null,   \/\/ downscaled sourceCanvas for fast on-screen preview\n  rotation:0,\n  flipH:false,\n  flipV:false,\n  crop:null,            \/\/ {x,y,w,h} fractions of fullCanvas, in *current rotated* space, or null = full\n  viewMode:'original',\n  substrate:'paper',\n  shirt:{color:'#FFFFFF', size:'medium'},\n  wand:{active:false, seeds:[], tolerance:30, feather:2, contiguous:true, maskAlpha:null},\n  bgUndoSnapshot:null,\n  tone:{black:0, white:255, gamma:1, brightness:0, contrast:0, saturate:100},\n  print:{\n    paper:'A4', customW:210, customH:297, orientation:'portrait',\n    dpi:300, fit:'fit', marginMm:10, cropMarks:true, stepWedge:false,\n    mirror:true, poster:false, posterWcm:60, posterHcm:80, posterOverlapMm:10\n  }\n};\nconst MAX_DIM = 4500; \/\/ cap megapixel photos so the browser stays responsive\nconst PREVIEW_DIM = 1100;\n\nconst PAPER_MM = {\n  A4:[210,297], A3:[297,420], LETTER:[215.9,279.4], LEGAL:[215.9,355.6], SQUARE:[200,200]\n};\n\n\/* ===========================================================\n   DOM SHORTCUTS\n   =========================================================== *\/\nconst $ = sel => document.querySelector(sel);\nconst $$ = sel => Array.from(document.querySelectorAll(sel));\n\nconst stage = $('#stage');\nconst stageCtx = stage.getContext('2d');\nconst stageWrap = $('#stageWrap');\nconst dropzone = $('#dropzone');\nconst fileInput = $('#fileInput');\nconst histCanvas = $('#histCanvas');\nconst toast = $('#toast');\nconst veil = $('#veil');\nconst veilText = $('#veilText');\n\nfunction showToast(msg){\n  toast.textContent = msg;\n  toast.classList.add('show');\n  clearTimeout(showToast._t);\n  showToast._t = setTimeout(()=>toast.classList.remove('show'), 2600);\n}\nfunction showVeil(msg){ veilText.textContent = msg; veil.classList.add('show'); }\nfunction hideVeil(){ veil.classList.remove('show'); }\n\n\/* ===========================================================\n   FILE LOADING\n   =========================================================== *\/\n['dragover','dragenter'].forEach(evt=>{\n  stageWrap.addEventListener(evt, e=>{ e.preventDefault(); stageWrap.classList.add('dragover'); });\n});\n['dragleave','drop'].forEach(evt=>{\n  stageWrap.addEventListener(evt, e=>{ e.preventDefault(); stageWrap.classList.remove('dragover'); });\n});\nstageWrap.addEventListener('drop', e=>{\n  const f = e.dataTransfer.files ? e.dataTransfer.files[0] : null;\n  if(f) loadFile(f);\n});\ndropzone.addEventListener('click', ()=>fileInput.click());\nfileInput.addEventListener('change', e=>{\n  if(e.target.files[0]) loadFile(e.target.files[0]);\n});\n\nfunction loadFile(file){\n  if(!file.type.startsWith('image\/')){ showToast('Diese Datei ist kein Bild.'); return; }\n  const img = new Image();\n  const url = URL.createObjectURL(file);\n  img.onload = ()=>{\n    URL.revokeObjectURL(url);\n    initFromImage(img);\n  };\n  img.onerror = ()=>{ showToast('Dieses Bild konnte nicht gelesen werden.'); };\n  img.src = url;\n}\n\nfunction initFromImage(img){\n  let w = img.naturalWidth, h = img.naturalHeight;\n  const scale = Math.min(1, MAX_DIM \/ Math.max(w,h));\n  w = Math.round(w*scale); h = Math.round(h*scale);\n\n  const c = document.createElement('canvas');\n  c.width = w; c.height = h;\n  const ctx = c.getContext('2d');\n  ctx.fillStyle = '#ffffff';   \/\/ transparency-safe: composite onto white paper first\n  ctx.fillRect(0,0,w,h);\n  ctx.drawImage(img,0,0,w,h);\n\n  state.fullCanvas = c;\n  state.rotation = 0; state.flipH = false; state.flipV = false; state.crop = null;\n  state.hasImage = true;\n\n  rebuildSourceCanvas();\n  dropzone.style.display = 'none';\n  stage.style.display = 'block';\n  setControlsEnabled(true);\n  showToast('Foto geladen. Start in der Ansicht Original.');\n}\n\nfunction setControlsEnabled(on){\n  ['btnRotate','btnFlipH','btnFlipV','btnCrop','btnWand','btnReset','btnRemove','btnPrint','btnDownloadPNG','btnDownloadPage']\n    .forEach(id=>{ const el = $('#'+id); if(el) el.disabled = !on; });\n  if(!on){ $('#btnUndoWand').style.display = 'none'; state.bgUndoSnapshot = null; }\n}\n\n\/* ===========================================================\n   TRANSFORM PIPELINE  (rotate \/ flip \/ crop -> sourceCanvas)\n   =========================================================== *\/\nfunction rebuildSourceCanvas(){\n  const src = state.fullCanvas;\n  const rot = state.rotation;\n  const swap = (rot===90 || rot===270);\n  const w = swap ? src.height : src.width;\n  const h = swap ? src.width : src.height;\n\n  const rc = document.createElement('canvas');\n  rc.width = w; rc.height = h;\n  const ctx = rc.getContext('2d');\n  ctx.save();\n  ctx.translate(w\/2, h\/2);\n  ctx.rotate(rot*Math.PI\/180);\n  ctx.scale(state.flipH ? -1:1, state.flipV ? -1:1);\n  ctx.drawImage(src, -src.width\/2, -src.height\/2);\n  ctx.restore();\n\n  let finalCanvas = rc;\n  if(state.crop){\n    const cx = Math.round(state.crop.x * rc.width);\n    const cy = Math.round(state.crop.y * rc.height);\n    const cw = Math.max(1, Math.round(state.crop.w * rc.width));\n    const ch = Math.max(1, Math.round(state.crop.h * rc.height));\n    const cc = document.createElement('canvas');\n    cc.width = cw; cc.height = ch;\n    cc.getContext('2d').drawImage(rc, cx, cy, cw, ch, 0, 0, cw, ch);\n    finalCanvas = cc;\n  }\n\n  state.sourceCanvas = finalCanvas;\n\n  \/\/ build a downscaled preview copy for fast slider interaction\n  const pScale = Math.min(1, PREVIEW_DIM \/ Math.max(finalCanvas.width, finalCanvas.height));\n  const pw = Math.max(1, Math.round(finalCanvas.width*pScale));\n  const ph = Math.max(1, Math.round(finalCanvas.height*pScale));\n  const pc = document.createElement('canvas');\n  pc.width = pw; pc.height = ph;\n  pc.getContext('2d').drawImage(finalCanvas, 0, 0, pw, ph);\n  state.previewSource = pc;\n\n  requestRender();\n}\n\n\/* ===========================================================\n   TONE PIPELINE\n   Builds a 256-entry LUT for black\/white\/gamma\/brightness\/contrast,\n   then applies it (plus optional desaturate \/ invert) to an ImageData.\n   =========================================================== *\/\nfunction buildLUT(t){\n  const lut = new Uint8ClampedArray(256);\n  const {black, white, gamma, brightness, contrast} = t;\n  const range = Math.max(1, white - black);\n  const c = (contrast\/100); \/\/ -1..1\n  const contrastFactor = Math.tan((c + 1) * Math.PI\/4); \/\/ smooth contrast curve\n  for(let i=0;i<256;i++){\n    let v = (i - black) \/ range;      \/\/ levels\n    v = Math.max(0, Math.min(1, v));\n    v = Math.pow(v, 1\/gamma);         \/\/ gamma\n    v = v*255 + brightness*1.2;       \/\/ brightness\n    v = (v-127.5)*contrastFactor + 127.5; \/\/ contrast\n    lut[i] = v;\n  }\n  return lut;\n}\n\nfunction applyTone(srcCanvas, opts){\n  const w = srcCanvas.width, h = srcCanvas.height;\n  const out = document.createElement('canvas');\n  out.width = w; out.height = h;\n  const octx = out.getContext('2d');\n  octx.drawImage(srcCanvas,0,0);\n  const imgData = octx.getImageData(0,0,w,h);\n  const d = imgData.data;\n  const lut = buildLUT(opts.tone);\n  const sat = opts.tone.saturate\/100;\n  const invert = opts.invert;\n  const cyanotype = opts.cyanotype;\n\n  for(let i=0;i<d.length;i+=4){\n    let r=d[i], g=d[i+1], b=d[i+2];\n    \/\/ desaturate toward luminosity\n    const lum = 0.299*r + 0.587*g + 0.114*b;\n    r = lum + (r-lum)*sat;\n    g = lum + (g-lum)*sat;\n    b = lum + (b-lum)*sat;\n    \/\/ grayscale value used for the negative\/tone\/cyanotype paths\n    let gray = 0.299*r + 0.587*g + 0.114*b;\n    gray = lut[Math.max(0,Math.min(255,Math.round(gray)))];\n\n    if(invert) gray = 255-gray;\n\n    if(cyanotype){\n      \/\/ map density to an approximate Prussian-blue print colour\n      const t2 = gray\/255;\n      const rr = 17 + t2*(252-17);\n      const gg = 14 + t2*(252-14);\n      const bb = 158 + t2*(252-158);\n      d[i]=rr; d[i+1]=gg; d[i+2]=bb;\n    } else if(opts.mode==='color'){\n      \/\/ keep colour but with tone curve applied to each channel via lut, sat already applied\n      d[i]  = lut[Math.max(0,Math.min(255,Math.round(r)))];\n      d[i+1]= lut[Math.max(0,Math.min(255,Math.round(g)))];\n      d[i+2]= lut[Math.max(0,Math.min(255,Math.round(b)))];\n      if(invert){ d[i]=255-d[i]; d[i+1]=255-d[i+1]; d[i+2]=255-d[i+2]; }\n    } else {\n      d[i]=gray; d[i+1]=gray; d[i+2]=gray;\n    }\n  }\n  octx.putImageData(imgData,0,0);\n  return out;\n}\n\nfunction currentToneOptions(mode){\n  const base = { tone: state.tone, mode:'gray', invert:false, cyanotype:false };\n  if(mode==='original'){ return { tone:{black:0,white:255,gamma:1,brightness:0,contrast:0,saturate:100}, mode:'color', invert:false, cyanotype:false }; }\n  if(mode==='tone'){ return { tone:state.tone, mode:'gray', invert:false, cyanotype:false }; }\n  if(mode==='negative'){ return { tone:state.tone, mode:'gray', invert:true, cyanotype:false }; }\n  if(mode==='cyanotype'){ return { tone:state.tone, mode:'gray', invert:false, cyanotype:true }; }\n  return base;\n}\n\n\/* ===========================================================\n   RENDER (preview canvas + histogram)\n   =========================================================== *\/\nlet renderQueued = false;\nfunction requestRender(){\n  if(renderQueued) return;\n  renderQueued = true;\n  requestAnimationFrame(()=>{ renderQueued = false; render(); });\n}\n\nfunction render(){\n  if(!state.hasImage) return;\n\n  if(state.wand.active){\n    renderWandPreview();\n    return;\n  }\n\n  if(state.viewMode==='shirt'){\n    renderShirtMockup();\n    return;\n  }\n\n  const opts = currentToneOptions(state.viewMode);\n  const processed = applyTone(state.previewSource, opts);\n\n  if(state.print.stepWedge){\n    const wedgeH = Math.round(processed.height*0.09);\n    stage.width = processed.width;\n    stage.height = processed.height + wedgeH;\n    stageCtx.clearRect(0,0,stage.width,stage.height);\n    stageCtx.fillStyle = '#fff';\n    stageCtx.fillRect(0,0,stage.width,stage.height);\n    stageCtx.drawImage(processed,0,0);\n    drawStepWedge(stageCtx, 0, processed.height, processed.width, wedgeH, wedgeH*0.35);\n  } else {\n    stage.width = processed.width;\n    stage.height = processed.height;\n    stageCtx.drawImage(processed,0,0);\n  }\n  drawHistogram(processed);\n}\n\n\/* ---- \"On a shirt\" mockup ----\n   Uses the Feather Icons \"shirt\" glyph (MIT licensed, viewBox 0 0 24 24) as a\n   simple flat-lay silhouette, filled with the chosen garment colour, with the\n   cyanotype-preview image clipped into a chest-sized rectangle on top. *\/\nconst SHIRT_PATH = \"M20.38 3.46L16 2a4 4 0 0 1-8 0L3.62 3.46a2 2 0 0 0-1.34 2.23l.58 3.47a1 1 0 0 0 .99.84H6v10a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V10h2.15a1 1 0 0 0 .99-.84l.58-3.47a2 2 0 0 0-1.34-2.23z\";\nconst SHIRT_PRINT_AREAS = {\n  small:  {x:9.3, y:12.4, w:5.4, h:4.6},\n  medium: {x:8.4, y:11.4, w:7.2, h:6.4},\n  large:  {x:7.4, y:10.6, w:9.2, h:8.4}\n};\nlet shirtNoiseCanvas = null;\nfunction getShirtNoise(){\n  if(shirtNoiseCanvas) return shirtNoiseCanvas;\n  const n = document.createElement('canvas');\n  n.width = 160; n.height = 160;\n  const nctx = n.getContext('2d');\n  const id = nctx.createImageData(160,160);\n  for(let i=0;i<id.data.length;i+=4){\n    const v = 128 + (Math.random()*40-20);\n    id.data[i]=v; id.data[i+1]=v; id.data[i+2]=v; id.data[i+3]=255;\n  }\n  nctx.putImageData(id,0,0);\n  shirtNoiseCanvas = n;\n  return n;\n}\n\nfunction renderShirtMockup(){\n  const SIZE = 900;\n  stage.width = SIZE; stage.height = SIZE;\n  const ctx = stageCtx;\n  ctx.clearRect(0,0,SIZE,SIZE);\n\n  \/\/ fit the 24x24 icon into the canvas with some padding\n  const pad = SIZE*0.14;\n  const scale = (SIZE - pad*2) \/ 24;\n  const offX = (SIZE - 24*scale)\/2, offY = (SIZE - 24*scale)\/2;\n\n  ctx.save();\n  ctx.translate(offX, offY);\n  ctx.scale(scale, scale);\n\n  const shirtPath = new Path2D(SHIRT_PATH);\n\n  \/\/ soft drop shadow\n  ctx.save();\n  ctx.shadowColor = 'rgba(20,24,31,0.18)';\n  ctx.shadowBlur = 1.2;\n  ctx.shadowOffsetY = 0.5;\n  ctx.fillStyle = state.shirt.color;\n  ctx.fill(shirtPath);\n  ctx.restore();\n\n  \/\/ subtle fabric shading (light top-left to darker bottom-right)\n  ctx.save();\n  ctx.clip(shirtPath);\n  const grad = ctx.createLinearGradient(0,0,24,24);\n  grad.addColorStop(0,'rgba(255,255,255,0.16)');\n  grad.addColorStop(0.5,'rgba(255,255,255,0)');\n  grad.addColorStop(1,'rgba(0,0,0,0.14)');\n  ctx.fillStyle = grad;\n  ctx.fillRect(0,0,24,24);\n  ctx.restore();\n\n  \/\/ printed cyanotype area\n  const area = SHIRT_PRINT_AREAS[state.shirt.size] || SHIRT_PRINT_AREAS.medium;\n  ctx.save();\n  ctx.beginPath();\n  ctx.rect(area.x, area.y, area.w, area.h);\n  ctx.clip();\n\n  const cyOpts = { tone: state.tone, mode:'gray', invert:false, cyanotype:true };\n  const printed = applyTone(state.previewSource, cyOpts);\n  \/\/ cover-fit the printed image into the chest rectangle\n  const srcAR = printed.width\/printed.height, dstAR = area.w\/area.h;\n  let sx=0, sy=0, sw=printed.width, sh=printed.height;\n  if(srcAR > dstAR){ sw = sh*dstAR; sx=(printed.width-sw)\/2; }\n  else { sh = sw\/dstAR; sy=(printed.height-sh)\/2; }\n\n  ctx.globalCompositeOperation = 'multiply';\n  ctx.globalAlpha = 0.92;\n  ctx.drawImage(printed, sx,sy,sw,sh, area.x, area.y, area.w, area.h);\n  ctx.globalAlpha = 1; ctx.globalCompositeOperation = 'source-over';\n\n  \/\/ fine fabric grain over the print for a woven, printed-not-pasted feel\n  ctx.globalCompositeOperation = 'overlay';\n  ctx.globalAlpha = 0.10;\n  ctx.drawImage(getShirtNoise(), area.x, area.y, area.w, area.h);\n  ctx.globalAlpha = 1; ctx.globalCompositeOperation = 'source-over';\n  ctx.restore();\n\n  \/\/ crisp outline on top\n  ctx.lineWidth = 2\/scale;\n  ctx.strokeStyle = 'rgba(20,24,31,0.28)';\n  ctx.stroke(shirtPath);\n\n  ctx.restore();\n\n  drawHistogram(applyTone(state.previewSource, {tone:state.tone, mode:'gray', invert:true, cyanotype:false}));\n}\n\nfunction drawHistogram(canvas){\n  const ctx = canvas.getContext('2d');\n  const {data} = ctx.getImageData(0,0,canvas.width,canvas.height);\n  const bins = new Array(64).fill(0);\n  for(let i=0;i<data.length;i+=4){\n    const l = Math.round(0.299*data[i]+0.587*data[i+1]+0.114*data[i+2]);\n    bins[Math.min(63, l>>2)]++;\n  }\n  const max = Math.max(...bins);\n  const hctx = histCanvas.getContext('2d');\n  const W = histCanvas.width, H = histCanvas.height;\n  hctx.clearRect(0,0,W,H);\n  hctx.fillStyle = '#EEF3FA';\n  hctx.fillRect(0,0,W,H);\n  hctx.fillStyle = '#1B4C8C';\n  const bw = W\/bins.length;\n  bins.forEach((v,i)=>{\n    const bh = max? (v\/max)*(H-4) : 0;\n    hctx.fillRect(i*bw, H-bh, bw-0.5, bh);\n  });\n}\n\n\/* ===========================================================\n   MAGIC WAND \/ BACKGROUND REMOVAL\n   Selection is computed at previewSource resolution for speed, then\n   upscaled at Apply time. Matched pixels get painted flat white\n   (not made transparent) because that's what actually matters for a\n   cyanotype negative: a pure white area inverts to solid black, which\n   blocks UV and prints as clean paper.\n   =========================================================== *\/\nconst wandOverlayEl = $('#wandControls');\n\nfunction enterWandTool(){\n  if(!state.hasImage) return;\n  state.wand.active = true;\n  state.wand.seeds = [];\n  state.wand.maskAlpha = null;\n  $('#wandApply').disabled = true;\n  wandOverlayEl.style.display = 'flex';\n  stageWrap.classList.add('wand-active');\n  cropOverlay.style.display = 'none';\n  state.viewMode = 'original';\n  $$('#viewModeSeg button').forEach(b=>b.classList.toggle('active', b.dataset.mode==='original'));\n  $('#shirtControls').style.display = 'none';\n  requestRender();\n}\nfunction exitWandTool(){\n  state.wand.active = false;\n  wandOverlayEl.style.display = 'none';\n  stageWrap.classList.remove('wand-active');\n  requestRender();\n}\n\nfunction sampleColorAt(canvas, x, y){\n  const ctx = canvas.getContext('2d');\n  const d = ctx.getImageData(x,y,1,1).data;\n  return [d[0], d[1], d[2]];\n}\n\nfunction colorDist(r1,g1,b1,r2,g2,b2){\n  const dr=r1-r2, dg=g1-g2, db=b1-b2;\n  return Math.sqrt(dr*dr+dg*dg+db*db);\n}\n\nfunction recomputeWandMask(){\n  const src = state.previewSource;\n  if(!src || state.wand.seeds.length===0){ state.wand.maskAlpha = null; $('#wandApply').disabled = true; requestRender(); return; }\n  const w = src.width, h = src.height;\n  const ctx = src.getContext('2d');\n  const {data} = ctx.getImageData(0,0,w,h);\n  const maxDist = (state.wand.tolerance\/100) * 441.7;\n  const mask = new Uint8ClampedArray(w*h); \/\/ 0 or 255\n\n  if(state.wand.contiguous){\n    \/\/ BFS flood fill from each seed independently, using that seed's own colour\n    const visited = new Uint8Array(w*h);\n    for(const seed of state.wand.seeds){\n      const idx0 = seed.y*w+seed.x;\n      if(visited[idx0]) continue;\n      const [sr,sg,sb] = [seed.r, seed.g, seed.b];\n      const stack = [idx0];\n      visited[idx0] = 1;\n      while(stack.length){\n        const idx = stack.pop();\n        const px = idx%w, py=(idx\/w)|0;\n        const o = idx*4;\n        const d = colorDist(data[o],data[o+1],data[o+2], sr,sg,sb);\n        if(d>maxDist) continue;\n        mask[idx] = 255;\n        if(px>0){ if(!visited[idx-1]){ visited[idx-1]=1; stack.push(idx-1); } }\n        if(px<w-1){ if(!visited[idx+1]){ visited[idx+1]=1; stack.push(idx+1); } }\n        if(py>0){ if(!visited[idx-w]){ visited[idx-w]=1; stack.push(idx-w); } }\n        if(py<h-1){ if(!visited[idx+w]){ visited[idx+w]=1; stack.push(idx+w); } }\n      }\n    }\n  } else {\n    \/\/ global match: any pixel close to ANY seed colour, anywhere in the image\n    for(let i=0;i<w*h;i++){\n      const o = i*4;\n      for(const seed of state.wand.seeds){\n        if(colorDist(data[o],data[o+1],data[o+2], seed.r,seed.g,seed.b) <= maxDist){ mask[i]=255; break; }\n      }\n    }\n  }\n\n  state.wand.maskAlpha = featherMask(mask, w, h, state.wand.feather);\n  $('#wandApply').disabled = false;\n  requestRender();\n}\n\nfunction featherMask(mask, w, h, radius){\n  if(radius<=0) return mask;\n  \/\/ cheap box blur, a couple of passes, gives a soft-ish edge without heavy cost\n  let src = new Uint8ClampedArray(mask);\n  let dst = new Uint8ClampedArray(mask.length);\n  const passes = 2;\n  for(let p=0;p<passes;p++){\n    for(let y=0;y<h;y++){\n      for(let x=0;x<w;x++){\n        let sum=0, count=0;\n        for(let dy=-radius; dy<=radius; dy+=Math.max(1,Math.round(radius\/3))){\n          const yy=y+dy; if(yy<0||yy>=h) continue;\n          for(let dx=-radius; dx<=radius; dx+=Math.max(1,Math.round(radius\/3))){\n            const xx=x+dx; if(xx<0||xx>=w) continue;\n            sum += src[yy*w+xx]; count++;\n          }\n        }\n        dst[y*w+x] = count? sum\/count : src[y*w+x];\n      }\n    }\n    [src,dst] = [dst,src];\n  }\n  return src;\n}\n\nfunction renderWandPreview(){\n  const src = state.previewSource;\n  stage.width = src.width; stage.height = src.height;\n  stageCtx.clearRect(0,0,src.width,src.height);\n  stageCtx.drawImage(src,0,0);\n\n  if(state.wand.maskAlpha){\n    const overlay = document.createElement('canvas');\n    overlay.width = src.width; overlay.height = src.height;\n    const octx = overlay.getContext('2d');\n    const id = octx.createImageData(src.width, src.height);\n    const mask = state.wand.maskAlpha;\n    for(let i=0;i<mask.length;i++){\n      const o = i*4;\n      id.data[o]=255; id.data[o+1]=0; id.data[o+2]=150; id.data[o+3]=mask[i]*0.55;\n    }\n    octx.putImageData(id,0,0);\n    stageCtx.drawImage(overlay,0,0);\n  }\n  drawHistogram(src);\n}\n\nfunction stageEventToPreviewPixel(e){\n  const rect = stage.getBoundingClientRect();\n  const relX = (e.clientX-rect.left)\/rect.width;\n  const relY = (e.clientY-rect.top)\/rect.height;\n  const x = Math.max(0, Math.min(state.previewSource.width-1, Math.round(relX*state.previewSource.width)));\n  const y = Math.max(0, Math.min(state.previewSource.height-1, Math.round(relY*state.previewSource.height)));\n  return {x,y};\n}\n\nstage.addEventListener('click', e=>{\n  if(!state.wand.active || !state.hasImage) return;\n  const {x,y} = stageEventToPreviewPixel(e);\n  const [r,g,b] = sampleColorAt(state.previewSource, x, y);\n  const addMode = e.shiftKey || $('#wandAddMode').checked;\n  if(!addMode) state.wand.seeds = [];\n  state.wand.seeds.push({x,y,r,g,b});\n  recomputeWandMask();\n});\n\n$('#btnWand').addEventListener('click', ()=> enterWandTool());\n$('#wandCancel').addEventListener('click', ()=> exitWandTool());\n$('#wandClear').addEventListener('click', ()=>{\n  state.wand.seeds = [];\n  state.wand.maskAlpha = null;\n  $('#wandApply').disabled = true;\n  requestRender();\n});\n$('#wandContiguous').addEventListener('change', e=>{\n  state.wand.contiguous = e.target.checked;\n  if(state.wand.seeds.length) recomputeWandMask();\n});\n$('#wandTolerance').addEventListener('input', e=>{\n  state.wand.tolerance = parseInt(e.target.value);\n  $('#wandToleranceOut').textContent = e.target.value;\n  if(state.wand.seeds.length) recomputeWandMask();\n});\n$('#wandFeather').addEventListener('input', e=>{\n  state.wand.feather = parseInt(e.target.value);\n  $('#wandFeatherOut').textContent = e.target.value;\n  if(state.wand.seeds.length) recomputeWandMask();\n});\n\n$('#wandApply').addEventListener('click', async ()=>{\n  if(!state.wand.maskAlpha) return;\n  showVeil('Filling background with white\u2026');\n  await new Promise(r=>setTimeout(r,20));\n\n  \/\/ snapshot everything needed to undo this one destructive step\n  state.bgUndoSnapshot = {\n    fullCanvas: state.fullCanvas,\n    rotation: state.rotation, flipH: state.flipH, flipV: state.flipV,\n    crop: state.crop ? {...state.crop} : null\n  };\n\n  const full = state.sourceCanvas; \/\/ current, post-transform, full-res\n  const fw = full.width, fh = full.height;\n\n  \/\/ upscale the small mask onto a full-res canvas using the browser's own\n  \/\/ (bilinear) image scaling, then read it back as an alpha map\n  const maskSmall = document.createElement('canvas');\n  maskSmall.width = state.previewSource.width; maskSmall.height = state.previewSource.height;\n  const msctx = maskSmall.getContext('2d');\n  const mid = msctx.createImageData(maskSmall.width, maskSmall.height);\n  for(let i=0;i<state.wand.maskAlpha.length;i++){\n    const o=i*4; const v = state.wand.maskAlpha[i];\n    mid.data[o]=v; mid.data[o+1]=v; mid.data[o+2]=v; mid.data[o+3]=255;\n  }\n  msctx.putImageData(mid,0,0);\n\n  const maskFull = document.createElement('canvas');\n  maskFull.width = fw; maskFull.height = fh;\n  maskFull.getContext('2d').drawImage(maskSmall, 0,0, fw, fh);\n  const maskData = maskFull.getContext('2d').getImageData(0,0,fw,fh).data;\n\n  const edited = document.createElement('canvas');\n  edited.width = fw; edited.height = fh;\n  const ectx = edited.getContext('2d');\n  ectx.drawImage(full,0,0);\n  const imgData = ectx.getImageData(0,0,fw,fh);\n  const d = imgData.data;\n  for(let i=0;i<d.length;i+=4){\n    const a = maskData[i]\/255; \/\/ luminance of mask canvas = alpha we want\n    if(a>0){\n      d[i]   = d[i]  *(1-a) + 255*a;\n      d[i+1] = d[i+1]*(1-a) + 255*a;\n      d[i+2] = d[i+2]*(1-a) + 255*a;\n    }\n  }\n  ectx.putImageData(imgData,0,0);\n\n  \/\/ re-base: this flattened result becomes the new \"original\" photo\n  state.fullCanvas = edited;\n  state.rotation = 0; state.flipH = false; state.flipV = false; state.crop = null;\n  rebuildSourceCanvas();\n\n  $('#btnUndoWand').style.display = 'inline-flex';\n  exitWandTool();\n  hideVeil();\n  showToast('Hintergrund mit Wei\u00df gef\u00fcllt. Drehen, Spiegeln und Zuschneiden wurden in das Foto \u00fcbernommen.');\n});\n\n$('#btnUndoWand').addEventListener('click', ()=>{\n  if(!state.bgUndoSnapshot) return;\n  const s = state.bgUndoSnapshot;\n  state.fullCanvas = s.fullCanvas;\n  state.rotation = s.rotation; state.flipH = s.flipH; state.flipV = s.flipV; state.crop = s.crop;\n  state.bgUndoSnapshot = null;\n  $('#btnUndoWand').style.display = 'none';\n  rebuildSourceCanvas();\n  showToast('Hintergrundentfernung r\u00fcckg\u00e4ngig gemacht.');\n});\n\n\n$$('#viewModeSeg button').forEach(btn=>{\n  btn.addEventListener('click', ()=>{\n    if(state.wand.active) exitWandTool();\n    $$('#viewModeSeg button').forEach(b=>b.classList.remove('active'));\n    btn.classList.add('active');\n    state.viewMode = btn.dataset.mode;\n    $('#shirtControls').style.display = (state.viewMode==='shirt') ? 'flex' : 'none';\n    requestRender();\n  });\n});\n\n$$('#shirtSizeSeg button').forEach(btn=>{\n  btn.addEventListener('click', ()=>{\n    $$('#shirtSizeSeg button').forEach(b=>b.classList.remove('active'));\n    btn.classList.add('active');\n    state.shirt.size = btn.dataset.size;\n    requestRender();\n  });\n});\n\n$('#btnRotate').addEventListener('click', ()=>{\n  if(!state.hasImage) return;\n  if(state.wand.active) exitWandTool();\n  state.rotation = (state.rotation+90)%360;\n  state.crop = null;\n  rebuildSourceCanvas();\n});\n$('#btnFlipH').addEventListener('click', ()=>{\n  if(!state.hasImage) return;\n  if(state.wand.active) exitWandTool();\n  state.flipH = !state.flipH; state.crop = null; rebuildSourceCanvas();\n});\n$('#btnFlipV').addEventListener('click', ()=>{\n  if(!state.hasImage) return;\n  if(state.wand.active) exitWandTool();\n  state.flipV = !state.flipV; state.crop = null; rebuildSourceCanvas();\n});\n$('#btnReset').addEventListener('click', ()=>{\n  if(!state.hasImage) return;\n  if(state.wand.active) exitWandTool();\n  state.rotation=0; state.flipH=false; state.flipV=false; state.crop=null;\n  state.tone = {black:0, white:255, gamma:1, brightness:0, contrast:0, saturate:100};\n  syncToneInputs();\n  rebuildSourceCanvas();\n  showToast('Zum Originalfoto zur\u00fcckgesetzt.');\n});\n\n$('#btnRemove').addEventListener('click', ()=>{\n  if(!state.hasImage) return;\n  if(state.wand.active) exitWandTool();\n  state.hasImage = false;\n  state.fullCanvas = null;\n  state.sourceCanvas = null;\n  state.previewSource = null;\n  state.rotation = 0; state.flipH = false; state.flipV = false; state.crop = null;\n  state.tone = {black:0, white:255, gamma:1, brightness:0, contrast:0, saturate:100};\n  state.bgUndoSnapshot = null;\n  $('#btnUndoWand').style.display = 'none';\n  syncToneInputs();\n  stageCtx.clearRect(0,0,stage.width,stage.height);\n  stage.style.display = 'none';\n  dropzone.style.display = 'block';\n  cropOverlay.style.display = 'none';\n  fileInput.value = '';\n  setControlsEnabled(false);\n  const hctx = histCanvas.getContext('2d');\n  hctx.clearRect(0,0,histCanvas.width,histCanvas.height);\n  showToast('Foto entfernt. Ziehen Sie ein neues hierher, um von vorn zu beginnen.');\n});\n\nfunction bindSlider(id, key, outId, format){\n  const el = $('#'+id), out = $('#'+outId);\n  el.addEventListener('input', ()=>{\n    let v = parseFloat(el.value);\n    if(key==='gamma') v = v\/100;\n    state.tone[key] = v;\n    out.textContent = format ? format(v) : v;\n    requestRender();\n  });\n}\nbindSlider('black','black','outBlack');\nbindSlider('white','white','outWhite');\nbindSlider('gamma','gamma','outGamma', v=>v.toFixed(2));\nbindSlider('brightness','brightness','outBrightness');\nbindSlider('contrast','contrast','outContrast');\nbindSlider('saturate','saturate','outSaturate');\n\nfunction syncToneInputs(){\n  $('#black').value = state.tone.black; $('#outBlack').textContent = state.tone.black;\n  $('#white').value = state.tone.white; $('#outWhite').textContent = state.tone.white;\n  $('#gamma').value = Math.round(state.tone.gamma*100); $('#outGamma').textContent = state.tone.gamma.toFixed(2);\n  $('#brightness').value = state.tone.brightness; $('#outBrightness').textContent = state.tone.brightness;\n  $('#contrast').value = state.tone.contrast; $('#outContrast').textContent = state.tone.contrast;\n  $('#saturate').value = state.tone.saturate; $('#outSaturate').textContent = state.tone.saturate;\n}\n\n$('#btnAutoLevels').addEventListener('click', ()=>{\n  if(!state.hasImage) return;\n  const ctx = state.previewSource.getContext('2d');\n  const {data} = ctx.getImageData(0,0,state.previewSource.width, state.previewSource.height);\n  let lo=255, hi=0;\n  for(let i=0;i<data.length;i+=4){\n    const l = 0.299*data[i]+0.587*data[i+1]+0.114*data[i+2];\n    if(l<lo) lo=l; if(l>hi) hi=l;\n  }\n  state.tone.black = Math.max(0, Math.round(lo));\n  state.tone.white = Math.min(255, Math.round(hi));\n  syncToneInputs();\n  requestRender();\n  showToast('Tonwerte anhand des tats\u00e4chlichen Bereichs des Bildes festgelegt.');\n});\n\n\/* substrate presets, sensible starting points, still fully editable *\/\n$('#substratePaper').addEventListener('click', ()=>setSubstrate('paper'));\n$('#substrateFabric').addEventListener('click', ()=>setSubstrate('fabric'));\nfunction setSubstrate(which){\n  state.substrate = which;\n  $('#substratePaper').classList.toggle('active', which==='paper');\n  $('#substrateFabric').classList.toggle('active', which==='fabric');\n  if(which==='fabric'){\n    state.tone.contrast = 15; state.tone.black = 10;\n  } else {\n    state.tone.contrast = 0; state.tone.black = 0;\n  }\n  syncToneInputs();\n  requestRender();\n}\n\n\/* ===========================================================\n   CROP TOOL\n   =========================================================== *\/\nconst cropOverlay = $('#cropOverlay');\nconst cropRect = $('#cropRect');\nlet cropState = null; \/\/ {x,y,w,h} in fraction of displayed stage\nlet lockAR = null;\n\n$('#btnCrop').addEventListener('click', ()=>{\n  if(!state.hasImage) return;\n  if(state.wand.active) exitWandTool();\n  cropState = {x:0.05,y:0.05,w:0.9,h:0.9};\n  drawCropRect();\n  cropOverlay.style.display = 'block';\n});\n$('#cropCancel').addEventListener('click', ()=>{ cropOverlay.style.display='none'; });\n$('#cropApply').addEventListener('click', ()=>{\n  if(!cropState) return;\n  const prevCrop = state.crop || {x:0,y:0,w:1,h:1};\n  \/\/ combine with any existing crop (crop applies to current sourceCanvas fractions)\n  state.crop = {\n    x: prevCrop.x + cropState.x*prevCrop.w,\n    y: prevCrop.y + cropState.y*prevCrop.h,\n    w: cropState.w*prevCrop.w,\n    h: cropState.h*prevCrop.h\n  };\n  cropOverlay.style.display='none';\n  rebuildSourceCanvas();\n  showToast('Zuschnitt angewendet.');\n});\n$$('.crop-toolbar [data-ar]').forEach(b=>{\n  b.addEventListener('click', ()=>{\n    const ar = b.dataset.ar;\n    lockAR = ar==='free' ? null : ar.split(':').map(Number);\n    if(lockAR){\n      const stageRect = stage.getBoundingClientRect();\n      const targetAR = lockAR[0]\/lockAR[1];\n      let w = cropState.w, h = cropState.w * (stageRect.width\/stageRect.height) \/ targetAR;\n      if(h>1){ h=0.9; w = h*targetAR*(stageRect.height\/stageRect.width); }\n      cropState.w=w; cropState.h=h;\n      drawCropRect();\n    }\n  });\n});\n\nfunction drawCropRect(){\n  const wrapRect = stage.getBoundingClientRect();\n  const parentRect = stageWrap.getBoundingClientRect();\n  const left = wrapRect.left-parentRect.left, top = wrapRect.top-parentRect.top;\n  cropRect.style.left = (left + cropState.x*wrapRect.width)+'px';\n  cropRect.style.top = (top + cropState.y*wrapRect.height)+'px';\n  cropRect.style.width = (cropState.w*wrapRect.width)+'px';\n  cropRect.style.height = (cropState.h*wrapRect.height)+'px';\n}\n\n\/\/ dragging: move rect body, resize via corner handles\nlet dragMode=null, dragStart=null;\ncropRect.addEventListener('pointerdown', e=>{\n  const isHandle = e.target.classList.contains('crop-handle');\n  dragMode = isHandle ? [...e.target.classList].find(c=>c!=='crop-handle') : 'move';\n  dragStart = {x:e.clientX, y:e.clientY, crop:{...cropState}};\n  e.target.setPointerCapture(e.pointerId);\n});\ncropRect.addEventListener('pointermove', e=>{\n  if(!dragMode) return;\n  const wrapRect = stage.getBoundingClientRect();\n  const dx = (e.clientX-dragStart.x)\/wrapRect.width;\n  const dy = (e.clientY-dragStart.y)\/wrapRect.height;\n  const c0 = dragStart.crop;\n  let c = {...c0};\n  if(dragMode==='move'){\n    c.x = Math.min(1-c0.w, Math.max(0, c0.x+dx));\n    c.y = Math.min(1-c0.h, Math.max(0, c0.y+dy));\n  } else if(dragMode==='se'){\n    c.w = Math.min(1-c0.x, Math.max(0.05, c0.w+dx));\n    c.h = Math.min(1-c0.y, Math.max(0.05, c0.h+dy));\n  } else if(dragMode==='nw'){\n    const nx = Math.max(0, Math.min(c0.x+c0.w-0.05, c0.x+dx));\n    const ny = Math.max(0, Math.min(c0.y+c0.h-0.05, c0.y+dy));\n    c.w = c0.w + (c0.x-nx); c.h = c0.h + (c0.y-ny); c.x=nx; c.y=ny;\n  } else if(dragMode==='ne'){\n    const ny = Math.max(0, Math.min(c0.y+c0.h-0.05, c0.y+dy));\n    c.w = Math.min(1-c0.x, Math.max(0.05, c0.w+dx));\n    c.h = c0.h + (c0.y-ny); c.y=ny;\n  } else if(dragMode==='sw'){\n    const nx = Math.max(0, Math.min(c0.x+c0.w-0.05, c0.x+dx));\n    c.w = c0.w + (c0.x-nx); c.x = nx;\n    c.h = Math.min(1-c0.y, Math.max(0.05, c0.h+dy));\n  }\n  cropState = c;\n  drawCropRect();\n});\ncropRect.addEventListener('pointerup', ()=>{ dragMode=null; });\n\n\/* ===========================================================\n   PANEL TABS\n   =========================================================== *\/\n$$('.panel-tabs button').forEach(btn=>{\n  btn.addEventListener('click', ()=>{\n    $$('.panel-tabs button').forEach(b=>b.classList.remove('active'));\n    $$('.panel-section').forEach(s=>s.classList.remove('active'));\n    btn.classList.add('active');\n    $(`.panel-section[data-tab=\"${btn.dataset.tab}\"]`).classList.add('active');\n  });\n});\n\n\/* ===========================================================\n   PRINT AND EXPORT SETTINGS\n   =========================================================== *\/\n$('#mirrorPrint').addEventListener('change', e=> state.print.mirror = e.target.checked);\n$('#paperSize').addEventListener('change', e=>{\n  state.print.paper = e.target.value;\n  $('#customSizeRow').style.display = e.target.value==='CUSTOM' ? 'grid' : 'none';\n  updateTileCount();\n});\n$('#customW').addEventListener('input', e=>{ state.print.customW = parseFloat(e.target.value)||210; updateTileCount(); });\n$('#customH').addEventListener('input', e=>{ state.print.customH = parseFloat(e.target.value)||297; updateTileCount(); });\n$('#orientation').addEventListener('change', e=> state.print.orientation = e.target.value);\n$('#dpi').addEventListener('change', e=> state.print.dpi = parseInt(e.target.value));\n$('#fitMode').addEventListener('change', e=> state.print.fit = e.target.value);\n$('#margin').addEventListener('input', e=>{\n  state.print.marginMm = parseFloat(e.target.value);\n  $('#outMargin').textContent = state.print.marginMm;\n});\n$('#cropMarks').addEventListener('change', e=> state.print.cropMarks = e.target.checked);\n$('#stepWedge').addEventListener('change', e=>{\n  state.print.stepWedge = e.target.checked;\n  renderWedgePreview();\n});\n$('#posterMode').addEventListener('change', e=>{\n  state.print.poster = e.target.checked;\n  $('#posterOptions').style.display = e.target.checked ? 'block' : 'none';\n  updateTileCount();\n});\n['posterW','posterH','posterOverlap'].forEach(id=>{\n  $('#'+id).addEventListener('input', e=>{\n    if(id==='posterW') state.print.posterWcm = parseFloat(e.target.value)||60;\n    if(id==='posterH') state.print.posterHcm = parseFloat(e.target.value)||80;\n    if(id==='posterOverlap') state.print.posterOverlapMm = parseFloat(e.target.value)||10;\n    updateTileCount();\n  });\n});\n\nfunction getPaperMM(){\n  let [w,h] = state.print.paper==='CUSTOM' ? [state.print.customW, state.print.customH] : PAPER_MM[state.print.paper];\n  if(state.print.orientation==='landscape'){ [w,h] = [Math.max(w,h), Math.min(w,h)]; }\n  else { [w,h] = [Math.min(w,h), Math.max(w,h)]; }\n  return [w,h];\n}\n\nfunction updateTileCount(){\n  if(!state.print.poster){ $('#tileCount').textContent=''; return; }\n  const [pw,ph] = getPaperMM();\n  const ov = state.print.posterOverlapMm;\n  const usableW = pw - ov, usableH = ph - ov;\n  const totalWmm = state.print.posterWcm*10, totalHmm = state.print.posterHcm*10;\n  const cols = Math.max(1, Math.ceil(totalWmm\/usableW));\n  const rows = Math.max(1, Math.ceil(totalHmm\/usableH));\n  $('#tileCount').textContent = `${cols * rows} Bl\u00e4tter (${cols} horizontal \u00d7 ${rows} vertikal) bei diesem Papierformat.`;\n}\n\nfunction renderWedgePreview(){\n  const wrap = $('#wedgePreview');\n  wrap.innerHTML='';\n  if(!state.print.stepWedge) return;\n  for(let i=0;i<11;i++){\n    const v = Math.round((i\/10)*255);\n    const d = document.createElement('div');\n    d.style.background = `rgb(${v},${v},${v})`;\n    wrap.appendChild(d);\n  }\n}\n\n\/* ===========================================================\n   EXPORT: build a full-resolution negative canvas\n   =========================================================== *\/\nfunction buildFullNegative(){\n  const opts = { tone: state.tone, mode:'gray', invert:true, cyanotype:false };\n  return applyTone(state.sourceCanvas, opts);\n}\n\nfunction mmToPx(mm, dpi){ return Math.round(mm\/25.4*dpi); }\n\nfunction drawRegistrationMarks(ctx, x, y, w, h, len){\n  ctx.save();\n  ctx.strokeStyle = '#000'; ctx.lineWidth = Math.max(1, len*0.08);\n  const corners = [[x,y,1,1],[x+w,y,-1,1],[x,y+h,1,-1],[x+w,y+h,-1,-1]];\n  corners.forEach(([cx,cy,sx,sy])=>{\n    ctx.beginPath();\n    ctx.moveTo(cx, cy+len*sy); ctx.lineTo(cx,cy); ctx.lineTo(cx+len*sx, cy);\n    ctx.stroke();\n  });\n  ctx.restore();\n}\n\n\/**\n * Draws an 11-step grey scale (0 = black \u2026 10 = white) inside the box (x,y,w,h).\n * labelSpace reserves room below the swatches for the numbers, and must already\n * be included by the caller in whatever it measured \"h\" against.\n *\/\nfunction drawStepWedge(ctx, x, y, w, h, labelSpace){\n  const steps = 11;\n  const stepW = w\/steps;\n  const swatchH = h - labelSpace;\n  ctx.save();\n  for(let i=0;i<steps;i++){\n    const v = Math.round((i\/(steps-1))*255);\n    ctx.fillStyle = `rgb(${v},${v},${v})`;\n    ctx.fillRect(x+i*stepW, y, stepW+0.5, swatchH);\n  }\n  ctx.strokeStyle = '#999'; ctx.lineWidth = 1;\n  ctx.strokeRect(x,y,w,swatchH);\n  const fontSize = Math.max(9, labelSpace*0.6);\n  ctx.fillStyle = '#000'; ctx.font = `${fontSize}px sans-serif`;\n  ctx.textAlign = 'center'; ctx.textBaseline = 'middle';\n  for(let i=0;i<steps;i++){\n    ctx.fillText(String(i), x+i*stepW+stepW\/2, y+swatchH+labelSpace\/2);\n  }\n  ctx.restore();\n}\n\n\/**\n * Renders one full page canvas for a (possibly cropped) portion of the negative.\n * srcRectMm\/srcSizeMm describe, in mm of the *final artwork*, which slice this tile covers;\n * used only by the poster tiler; for a single-sheet export pass null.\n *\/\nfunction renderPageCanvas({negativeCanvas, tile}){\n  const dpi = state.print.dpi;\n  const [pwMm, phMm] = getPaperMM();\n  const pageW = mmToPx(pwMm, dpi), pageH = mmToPx(phMm, dpi);\n  const marginPx = mmToPx(state.print.marginMm, dpi);\n\n  const page = document.createElement('canvas');\n  page.width = pageW; page.height = pageH;\n  const ctx = page.getContext('2d');\n  ctx.fillStyle = '#ffffff'; ctx.fillRect(0,0,pageW,pageH);\n\n  const contentW = pageW - marginPx*2;\n  let contentH = pageH - marginPx*2;\n  if(state.print.stepWedge){ if(!tile){ contentH -= mmToPx(14 + 4, dpi); } } \/\/ wedge height + gap above it\n\n  let drawW, drawH, sx=0, sy=0, sw=negativeCanvas.width, sh=negativeCanvas.height;\n\n  if(tile){\n    \/\/ poster tile: negativeCanvas here is already the full-res negative;\n    \/\/ sx\/sy\/sw\/sh select the pixel slice for this tile, scaled 1:1 to the page content box\n    sx = tile.sx; sy = tile.sy; sw = tile.sw; sh = tile.sh;\n    drawW = contentW; drawH = contentH;\n  } else if(state.print.fit==='fit'){\n    const scale = Math.min(contentW\/sw, contentH\/sh);\n    drawW = sw*scale; drawH = sh*scale;\n  } else {\n    const scale = Math.max(contentW\/sw, contentH\/sh);\n    drawW = sw*scale; drawH = sh*scale;\n    \/\/ center-crop source to content aspect\n    const cropW = contentW\/scale, cropH = contentH\/scale;\n    sx = (sw-cropW)\/2; sy = (sh-cropH)\/2; sw = cropW; sh = cropH;\n    drawW = contentW; drawH = contentH;\n  }\n\n  const dx = marginPx + (contentW-drawW)\/2;\n  const dy = marginPx + (contentH-drawH)\/2;\n\n  ctx.save();\n  if(state.print.mirror){\n    ctx.translate(pageW,0); ctx.scale(-1,1);\n    ctx.drawImage(negativeCanvas, sx,sy,sw,sh, pageW-dx-drawW, dy, drawW, drawH);\n  } else {\n    ctx.drawImage(negativeCanvas, sx,sy,sw,sh, dx, dy, drawW, drawH);\n  }\n  ctx.restore();\n\n  if(state.print.cropMarks){\n    drawRegistrationMarks(ctx, marginPx*0.4, marginPx*0.4, pageW-marginPx*0.8, pageH-marginPx*0.8, mmToPx(4,dpi));\n  }\n  if(state.print.stepWedge){\n   if(!tile){\n    const wedgeH = mmToPx(14, dpi);\n    drawStepWedge(ctx, marginPx, pageH-marginPx-wedgeH, contentW, wedgeH, wedgeH*0.3);\n   }\n  }\n  return page;\n}\n\nfunction canvasToBlob(canvas){\n  return new Promise(res=> canvas.toBlob(res, 'image\/png'));\n}\nfunction downloadBlob(blob, filename){\n  const a = document.createElement('a');\n  a.href = URL.createObjectURL(blob);\n  a.download = filename;\n  document.body.appendChild(a); a.click(); a.remove();\n}\n\n\/* ---- Download PNG (just the negative, native resolution) ---- *\/\n$('#btnDownloadPNG').addEventListener('click', async ()=>{\n  if(!state.hasImage) return;\n  showVeil('Rendering full-resolution negative\u2026');\n  await new Promise(r=>setTimeout(r,20));\n  let neg = buildFullNegative();\n  if(state.print.mirror){\n    const m = document.createElement('canvas'); m.width=neg.width; m.height=neg.height;\n    const mctx = m.getContext('2d'); mctx.translate(neg.width,0); mctx.scale(-1,1); mctx.drawImage(neg,0,0);\n    neg = m;\n  }\n  if(state.print.stepWedge){\n    const wedgeH = Math.round(neg.height*0.09);\n    const withWedge = document.createElement('canvas');\n    withWedge.width = neg.width; withWedge.height = neg.height + wedgeH;\n    const wctx = withWedge.getContext('2d');\n    wctx.fillStyle = '#fff'; wctx.fillRect(0,0,withWedge.width,withWedge.height);\n    wctx.drawImage(neg,0,0);\n    drawStepWedge(wctx, 0, neg.height, neg.width, wedgeH, wedgeH*0.35);\n    neg = withWedge;\n  }\n  const blob = await canvasToBlob(neg);\n  downloadBlob(blob, 'botanopia-cyanotype-negative.png');\n  hideVeil();\n  showToast('Negativ heruntergeladen.');\n});\n\n\/* ---- Download print-ready page(s) ---- *\/\n$('#btnDownloadPage').addEventListener('click', async ()=>{\n  if(!state.hasImage) return;\n  if(state.print.poster){\n    await exportPoster();\n    return;\n  }\n  showVeil('Laying out your print-ready page\u2026');\n  await new Promise(r=>setTimeout(r,20));\n  const neg = buildFullNegative();\n  const page = renderPageCanvas({negativeCanvas:neg, tile:null});\n  const blob = await canvasToBlob(page);\n  downloadBlob(blob, `botanopia-cyanotype-${state.print.paper}-${state.print.dpi}dpi.png`);\n  hideVeil();\n  showToast('Druckfertige Seite heruntergeladen.');\n});\n\n\/* ---- Print directly ----\n   Built with DOM methods rather than a raw HTML string, so there is no\n   literal tag text sitting inside this script anywhere. Some page\n   builder or editor content scanners misread that kind of text even\n   when it is safely inside a JS string. *\/\n$('#btnPrint').addEventListener('click', async ()=>{\n  if(!state.hasImage) return;\n  showVeil('Preparing print preview\u2026');\n  await new Promise(r=>setTimeout(r,20));\n  const neg = buildFullNegative();\n  const page = renderPageCanvas({negativeCanvas:neg, tile:null});\n  const dataUrl = page.toDataURL('image\/png');\n  const [pwMm, phMm] = getPaperMM();\n\n  const w = window.open('', '_blank');\n  const doc = w.document;\n\n  doc.title = 'Negativ drucken';\n\n  const styleEl = doc.createElement('style');\n  styleEl.textContent =\n    '@page{ size:' + pwMm + 'mm ' + phMm + 'mm; margin:0; } ' +\n    'html,body{margin:0;padding:0;} ' +\n    'img{width:' + pwMm + 'mm;height:' + phMm + 'mm;display:block;}';\n  doc.head.appendChild(styleEl);\n\n  const imgEl = doc.createElement('img');\n  imgEl.src = dataUrl;\n  imgEl.addEventListener('load', ()=>{ setTimeout(()=>w.print(), 150); });\n  doc.body.appendChild(imgEl);\n\n  hideVeil();\n});\n\n\/* ---- Poster \/ tiling export (zipped via JSZip, loaded on demand) ---- *\/\nlet jszipLoading = null;\nfunction ensureJSZip(){\n  if(window.JSZip) return Promise.resolve();\n  if(jszipLoading) return jszipLoading;\n  jszipLoading = new Promise((resolve,reject)=>{\n    const s = document.createElement('script');\n    s.src = 'https:\/\/cdnjs.cloudflare.com\/ajax\/libs\/jszip\/3.10.1\/jszip.min.js';\n    s.onload = resolve; s.onerror = reject;\n    document.head.appendChild(s);\n  });\n  return jszipLoading;\n}\n\nasync function exportPoster(){\n  showVeil('Tiling your poster, this can take a moment\u2026');\n  try{\n    await ensureJSZip();\n    await new Promise(r=>setTimeout(r,20));\n    const neg = buildFullNegative();\n    const dpi = state.print.dpi;\n    const [pwMm, phMm] = getPaperMM();\n    const ov = state.print.posterOverlapMm;\n    const usableWmm = pwMm - ov, usableHmm = phMm - ov;\n    const totalWmm = state.print.posterWcm*10, totalHmm = state.print.posterHcm*10;\n    const cols = Math.max(1, Math.ceil(totalWmm\/usableWmm));\n    const rows = Math.max(1, Math.ceil(totalHmm\/usableHmm));\n\n    \/\/ scale full negative so that totalWmm\/totalHmm maps to the full negative's pixel size\n    const pxPerMmX = neg.width\/totalWmm, pxPerMmY = neg.height\/totalHmm;\n\n    const zip = new JSZip();\n    for(let r=0;r<rows;r++){\n      for(let c=0;c<cols;c++){\n        const sxMm = c*usableWmm, syMm = r*usableHmm;\n        const sx = Math.round(sxMm*pxPerMmX), sy = Math.round(syMm*pxPerMmY);\n        const sw = Math.min(neg.width-sx, Math.round(pwMm*pxPerMmX));\n        const sh = Math.min(neg.height-sy, Math.round(phMm*pxPerMmY));\n        const page = renderPageCanvas({negativeCanvas:neg, tile:{sx,sy,sw,sh}});\n        const blob = await canvasToBlob(page);\n        zip.file(`row${r+1}-col${c+1}.png`, blob);\n      }\n    }\n    const content = await zip.generateAsync({type:'blob'});\n    downloadBlob(content, 'botanopia-cyanotype-poster-tiles.zip');\n    showToast(`${rows*cols} Kacheln exportiert.`);\n  }catch(err){\n    console.error(err);\n    showToast('Die Posterkacheln konnten nicht erstellt werden. \u00dcberpr\u00fcfen Sie Ihre Verbindung und versuchen Sie es erneut.');\n  }\n  hideVeil();\n}\n\nconst mobileNoteCloseBtn = $('#mobileNoteClose');\nif(mobileNoteCloseBtn){\n  mobileNoteCloseBtn.addEventListener('click', ()=>{\n    $('#mobileNote').style.display = 'none';\n  });\n}\n\n\/* tap-to-open info tooltips (\":hover\" doesn't exist on touch devices) *\/\n$$('.pop').forEach(pop=>{\n  pop.addEventListener('click', e=>{\n    e.stopPropagation();\n    const wasOpen = pop.classList.contains('open');\n    $$('.pop.open').forEach(p=>p.classList.remove('open'));\n    if(!wasOpen) pop.classList.add('open');\n  });\n});\ndocument.addEventListener('click', ()=> $$('.pop.open').forEach(p=>p.classList.remove('open')));\n\n\/* init *\/\nrenderWedgePreview();\nupdateTileCount();\n\n} \/\/ end initNegativeMaker\n\nif(document.readyState === 'loading'){\n  document.addEventListener('DOMContentLoaded', initNegativeMaker);\n} else {\n  initNegativeMaker();\n}\n})();\n<\/script>\n<section class=\"guide-section\" id=\"guide\">\n  <div class=\"guide-inner\">\n\n    <h2>Ein Cyanotypie-Negativ erstellen, Schritt f\u00fcr Schritt<\/h2>\n    <p class=\"guide-intro\">Ein Cyanotypie-Negativ ist einfach ein Schwarz-Wei\u00df-Bild mit vertauschten Tonwerten: Helle Bereiche Ihres Fotos m\u00fcssen dunkel werden, und dunkle Bereiche m\u00fcssen hell werden, damit UV-Licht bei der Belichtung durch die transparenten Teile dringt und von den dunklen Teilen blockiert wird. Hier ist die Kurzversion des Prozesses. F\u00fcr eine ausf\u00fchrlichere Erkl\u00e4rung der Cyanotypie von Anfang bis Ende besuchen Sie unsere <a href=\"https:\/\/www.botanopia.com\/de\/cyanotypie\/\">vollst\u00e4ndige Cyanotypie-Anleitung<\/a>.<\/p>\n\n    <ol class=\"guide-steps\">\n      <li>\n        <h3>1. W\u00e4hlen Sie das richtige Foto<\/h3>\n        <p>Cyanotypie hat einen k\u00fcrzeren Tonwertumfang als ein normales Foto, daher lassen sich Bilder mit klarem Kontrast und einfachen, gut erkennbaren Formen am besten umsetzen. Portr\u00e4ts, botanische Motive und Architekturaufnahmen mit starkem Licht funktionieren alle gut. Sehr flache, kontrastarme Fotos (ein bedeckter Himmel, eine diesige Landschaft) neigen dazu, matschig zu drucken.<\/p>\n      <\/li>\n      <li>\n        <h3>2. In Schwarz-Wei\u00df umwandeln und den Ton anpassen<\/h3>\n        <p>Ents\u00e4ttigen Sie zun\u00e4chst das Bild und nutzen Sie dann Tonwerte (Schwarzpunkt, Wei\u00dfpunkt, Gamma) sowie Kontrast, um den Tonwertumfang zu gestalten, bevor Sie irgendetwas umkehren. Das ist der Schritt, den die meisten Anleitungen \u00fcberspringen, und genau der, der einen flachen Druck von einem mit echter Tiefe unterscheidet. Nutzen Sie das <a href=\"#top\">Histogramm des Negativgenerators<\/a>, um zu pr\u00fcfen, dass Sie keine Details in reines Schwarz oder Wei\u00df abschneiden, bevor Sie fortfahren.<\/p>\n      <\/li>\n      <li>\n        <h3>3. Das Bild umkehren, um ein Negativ zu erstellen<\/h3>\n        <p>Das Umkehren vertauscht die Tonwerte: Was in Ihrem Foto hell war, wird im Negativ dunkel, und was dunkel war, wird hell. Die dunklen Bereiche des Negativs blockieren UV-Licht, sodass dieser Teil des Papiers wei\u00df bleibt. Die hellen, transparenten Bereiche lassen UV durch, sodass dieser Teil preu\u00dfischblau wird.<\/p>\n      <\/li>\n      <li>\n        <h3>4. Das Negativ f\u00fcr den Kontaktdruck spiegeln<\/h3>\n        <p>Tintenstrahl-Transparentfolien werden mit der Tintenseite nach unten gegen das beschichtete Papier oder den Stoff belichtet, wodurch das Bild gespiegelt wird. Spiegeln Sie Ihr Negativ vor dem Drucken horizontal, damit der fertige Druck seitenrichtig ist. Das wird beim ersten Mal besonders leicht vergessen, l\u00e4sst sich aber schnell beheben, sobald Sie wissen, worauf Sie achten m\u00fcssen.<\/p>\n      <\/li>\n      <li>\n        <h3>5. Auf Transparentfolie drucken<\/h3>\n        <p>Drucken Sie das Negativ auf OHP-Transparentfolie passend zu Ihrem Druckertyp (pr\u00fcfen Sie auf der Verpackung die Angabe &#8222;Tintenstrahl&#8220; oder &#8222;Laser&#8220;, sie sind nicht austauschbar), \u00fcblicherweise mit 300 DPI, im Format Ihres beschichteten Papiers oder Stoffs. Lassen Sie Tintenstrahldrucke vollst\u00e4ndig trocknen oder Laserdrucke vollst\u00e4ndig abk\u00fchlen, bevor Sie die Folie anfassen oder gegen Ihre beschichtete Oberfl\u00e4che legen. Wirkt ein Testdruck zu d\u00fcnn (Licht dringt noch durch die dunkelsten Bereiche), drucken Sie eine zweite Kopie und legen Sie beide Bl\u00e4tter passgenau \u00fcbereinander, anstatt zu versuchen, mehr Dichte aus einem einzelnen Druck herauszuholen.<\/p>\n      <\/li>\n      <li>\n        <h3>6. Einen Graukeil hinzuf\u00fcgen und die Belichtungszeit testen<\/h3>\n        <p>Drucken Sie eine 11-stufige Graustufenskala neben einem Teststreifen, belichten und entwickeln Sie ihn genau wie einen echten Druck, und notieren Sie sich die Belichtungszeit, bei der sowohl in den hellsten als auch den dunkelsten Stufen sichtbare Details erhalten bleiben. Verwenden Sie diese Zeit f\u00fcr den Rest der Auflage weiter, anstatt bei jedem Druck erneut zu raten.<\/p>\n      <\/li>\n      <li>\n        <h3>7. Bei Sonnenlicht oder unter einer UV-Lampe belichten<\/h3>\n        <p>Legen Sie das Negativ mit der Tintenseite nach unten auf das getrocknete, beschichtete Papier oder den Stoff, beschweren Sie es unter Glas oder in einem Belichtungsrahmen f\u00fcr vollst\u00e4ndigen Kontakt, und belichten Sie f\u00fcr die von Ihnen kalibrierte Zeit, \u00fcblicherweise einige Minuten in direkter Sonne oder etwas l\u00e4nger unter einer UV-Lampe. Der Druck sollte vor dem Waschen bronzefarben aussehen; \u00dcberbelichtung ist fast immer verzeihender als Unterbelichtung.<\/p>\n      <\/li>\n      <li>\n        <h3>8. Waschen und den Druck oxidieren lassen<\/h3>\n        <p>Waschen Sie den belichteten Druck vorsichtig f\u00fcnf bis zehn Minuten lang in k\u00fchlem Wasser, bis der gelbgr\u00fcne Farbstich verschwindet, und lassen Sie ihn dann fernab von direktem Licht an der Luft trocknen. Das Bild vertieft sich im Laufe des n\u00e4chsten Tages beim Oxidieren weiter zu seinem endg\u00fcltigen Blau.<\/p>\n      <\/li>\n    <\/ol>\n\n    <div class=\"product-cta\">\n      <p><strong>Neu bei der Cyanotypie?<\/strong> Unser DIY-Set enth\u00e4lt alles, was Sie f\u00fcr den Einstieg brauchen.<\/p>\n      <div class=\"btn-row\">\n        <a class=\"btn\" href=\"https:\/\/www.botanopia.com\/de\/shop\/geschenkboxen-und-bastelsets\/cyanotypie-diy-set-blaudrucke-erstellen\/\">Cyanotypie-Set entdecken<\/a>\n      <\/div>\n    <\/div>\n\n    <h2>Was Sie ben\u00f6tigen<\/h2>\n    <ul class=\"needs-list\">\n      <li>Ein Foto mit gutem Kontrast<\/li>\n      <li>Ein Cyanotypie-Set (Sensibilisator + Papier oder Stoff)<\/li>\n      <li>OHP-Transparentfolie passend zu Ihrem Drucker<\/li>\n      <li>Ein Drucker (Tintenstrahl oder Laser)<\/li>\n      <li>Sonnenlicht oder eine UV-Lichtquelle<\/li>\n      <li>Eine Wanne zum Waschen der Drucke<\/li>\n    <\/ul>\n\n    <h2>Die besten Fotos f\u00fcr Cyanotypie-Negative ausw\u00e4hlen<\/h2>\n    <p>Achten Sie auf ein klares Motiv, stark gerichtetes Licht und eine Mischung aus wirklich dunklen und wirklich hellen Bereichen, statt eines Fotos, das sich vor allem in den Mittelt\u00f6nen bewegt. Botanische Motive mit hinterleuchteten Bl\u00e4ttern, Portr\u00e4ts mit hartem Seitenlicht und Architekturaufnahmen mit tiefen Schatten halten sich nach der Umkehrung meist gut. Hat Ihr Foto einen unruhigen oder ablenkenden Hintergrund, kann das <a href=\"#top\">Werkzeug zur Hintergrundentfernung oben<\/a> ihn vor der Umkehrung einheitlich wei\u00df machen, wodurch der Hintergrund des fertigen Drucks sauberes Papier bleibt statt fleckiges Blau.<\/p>\n\n    <h2>Cyanotypie-Negative: Papier oder Stoff<\/h2>\n    <p>Dasselbe Negativ verh\u00e4lt sich je nachdem, ob Sie Papier oder Stoff verwenden, leicht unterschiedlich. Stoff verliert bei der Beschichtung und beim Waschen h\u00e4ufig feine Details in den Schatten, daher halten etwas mehr Kontrast und ein leicht angehobener Schwarzpunkt in Ihrem Negativ meist besser als die Einstellungen, die Sie f\u00fcr Papier verwenden w\u00fcrden. Betrachten Sie die Papier- und Stoff-Voreinstellungen im Tool oben als Ausgangspunkt und passen Sie sie nach dem ersten Testdruck mit blo\u00dfem Auge an.<\/p>\n\n    <h2>Problembehebung beim Cyanotypie-Negativ<\/h2>\n    <div class=\"faq\">\n      <details>\n        <summary>Warum wird meine Cyanotypie nicht blau, oder verblasst sie gleich nach dem Waschen?<\/summary>\n        <p>Das liegt fast immer an Unterbelichtung, alter oder schlecht angesetzter Chemie, oder Papier, das schlecht mit dem Sensibilisator reagiert. Versuchen Sie zun\u00e4chst eine l\u00e4ngere Belichtungszeit, da Cyanotypie \u00dcberbelichtung weit besser vertr\u00e4gt als Unterbelichtung, und pr\u00fcfen Sie, dass Ihr Papier keine Carbonatpufferung enth\u00e4lt, die der chemischen Reaktion entgegenwirkt.<\/p>\n      <\/details>\n      <details>\n        <summary>Warum wirkt mein Druck grau oder blass statt tiefblau?<\/summary>\n        <p>Graue, schwache Ergebnisse deuten meist auf Unterbelichtung hin, auf ein zu dichtes Negativ (zu wenig Kontrast, sodass zu wenig UV durchdringt), oder auf ein zu grobes oder zu langes Waschen. Erh\u00f6hen Sie die Belichtungszeit, verst\u00e4rken Sie den Kontrast Ihres Negativs mit dem Tool oben, und waschen Sie vorsichtig in einer Wanne statt unter flie\u00dfendem Wasser direkt aus dem Hahn.<\/p>\n      <\/details>\n      <details>\n        <summary>Muss ich mein Cyanotypie-Negativ vor dem Drucken spiegeln?<\/summary>\n        <p>Ja. Tintenstrahl-Transparentfolien werden mit der Tintenseite nach unten gegen die beschichtete Oberfl\u00e4che belichtet, wodurch das Bild gespiegelt wird. Der Negativgenerator oben erledigt dies automatisch f\u00fcr Sie, standardm\u00e4\u00dfig aktiviert, wenn Sie exportieren oder drucken.<\/p>\n      <\/details>\n      <details>\n        <summary>Wie lange sollte ich einen Cyanotypie-Druck belichten?<\/summary>\n        <p>Das h\u00e4ngt von Ihrer Lichtquelle, dem Papier und der Dichte Ihres Negativs ab, weshalb sich ein einmaliger Graukeil-Test lohnt. Drucken Sie den Graukeil, belichten und entwickeln Sie ihn wie einen echten Druck, und lesen Sie die Belichtungszeit ab, bei der sowohl Licht- als auch Schattendetails erhalten bleiben. Verwenden Sie diese Zeit f\u00fcr den Rest der Auflage weiter.<\/p>\n      <\/details>\n      <details>\n        <summary>Welches Papierformat und welche Aufl\u00f6sung sollte ich f\u00fcr ein digitales Negativ verwenden?<\/summary>\n        <p>300 DPI sind der Standard f\u00fcr ein scharfes Cyanotypie-Negativ auf einem Heimdrucker, im Format Ihres beschichteten Papiers oder Stoffs, \u00fcblicherweise A4 f\u00fcr einen ersten Testdruck.<\/p>\n      <\/details>\n      <details>\n        <summary>Brauche ich spezielle Transparentfolie, und macht es einen Unterschied, ob Laser oder Tintenstrahl?<\/summary>\n        <p>Es geht nicht darum, dass ein Druckertyp besser ist, sondern dass OHP-Transparentfolie druckerspezifisch ist, und die falsche zu verwenden ist der h\u00e4ufigste Grund, warum Negative im Drucker schmelzen oder sich verklemmen. Laserdrucker fixieren Toner mit Hitze und ben\u00f6tigen daher eine f\u00fcr Laser geeignete Folie; eine f\u00fcr Tintenstrahl gedachte Folie in einem Laserdrucker kann sich verziehen, schmelzen oder eine Beschichtung im Druckmechanismus abl\u00f6sen. Tintenstrahldrucker ben\u00f6tigen eine Folie mit einer Beschichtung, die fl\u00fcssige Tinte ohne Perlenbildung aufnimmt; eine reine Laserfolie in einem Tintenstrahldrucker zu bedrucken ergibt lediglich Tinte, die nicht richtig trocknet oder einzieht. Pr\u00fcfen Sie vor dem Kauf auf der Verpackung oder in der Produktbeschreibung die Angabe &#8222;Tintenstrahl&#8220; oder &#8222;Laser&#8220; und passen Sie sie an den Drucker an, den Sie tats\u00e4chlich besitzen.<br><br>Wirkt ein einzelner Druck nach dem Umkehren und Pr\u00fcfen nicht dicht genug (Licht dringt noch sichtbar durch die dunkelsten Bereiche), besteht die \u00fcbliche L\u00f6sung nicht in einem anderen Drucker, sondern darin, dasselbe Negativ zweimal zu drucken und beide Bl\u00e4tter passgenau \u00fcbereinanderzulegen. Das verdoppelt die Dichte in den Schatten, ohne dass Sie gegen die Tintengrenzen Ihres Druckers ank\u00e4mpfen m\u00fcssen.<\/p>\n      <\/details>\n    <\/div>\n\n    <div class=\"product-cta\">\n      <p>Bereit f\u00fcr Ihren ersten Druck? Unser Cyanotypie-Set enth\u00e4lt die Chemie, das Papier und die Anleitung dazu, und unsere vollst\u00e4ndige Anleitung behandelt den gesamten Prozess ausf\u00fchrlicher.<\/p>\n      <div class=\"btn-row\">\n        <a class=\"btn\" href=\"https:\/\/www.botanopia.com\/de\/shop\/geschenkboxen-und-bastelsets\/cyanotypie-diy-set-blaudrucke-erstellen\/\">Cyanotypie-Set entdecken<\/a>\n        <a class=\"btn secondary\" href=\"https:\/\/www.botanopia.com\/de\/cyanotypie\/\">Vollst\u00e4ndige Anleitung lesen<\/a>\n      <\/div>\n    <\/div>\n\n    <a class=\"back-to-top\" href=\"#top\">\u2191 Zur\u00fcck zum Negativgenerator<\/a>\n  <\/div>\n<\/section>\n\n<\/body>\n<\/html>\n\n\n\n<script>\n(function(){\n\"use strict\";\nfunction initNegativeMaker(){\n\/* ===========================================================\n   STATE\n   =========================================================== *\/\nconst state = {\n  hasImage:false,\n  fullCanvas:null,      \/\/ full-resolution, color, as-uploaded (capped at MAX_DIM)\n  sourceCanvas:null,    \/\/ fullCanvas after rotate\/flip\/crop, still full-res color\n  previewSource:null,   \/\/ downscaled sourceCanvas for fast on-screen preview\n  rotation:0,\n  flipH:false,\n  flipV:false,\n  crop:null,            \/\/ {x,y,w,h} fractions of fullCanvas, in *current rotated* space, or null = full\n  viewMode:'original',\n  substrate:'paper',\n  shirt:{color:'#FFFFFF', size:'medium'},\n  wand:{active:false, seeds:[], tolerance:30, feather:2, contiguous:true, maskAlpha:null},\n  bgUndoSnapshot:null,\n  tone:{black:0, white:255, gamma:1, brightness:0, contrast:0, saturate:100},\n  print:{\n    paper:'A4', customW:210, customH:297, orientation:'portrait',\n    dpi:300, fit:'fit', marginMm:10, cropMarks:true, stepWedge:false,\n    mirror:true, poster:false, posterWcm:60, posterHcm:80, posterOverlapMm:10\n  }\n};\nconst MAX_DIM = 4500; \/\/ cap megapixel photos so the browser stays responsive\nconst PREVIEW_DIM = 1100;\nconst PAPER_MM = {\n  A4:[210,297], A3:[297,420], LETTER:[215.9,279.4], LEGAL:[215.9,355.6], SQUARE:[200,200]\n};\n\/* ===========================================================\n   DOM SHORTCUTS\n   =========================================================== *\/\nconst $ = sel => document.querySelector(sel);\nconst $$ = sel => Array.from(document.querySelectorAll(sel));\nconst stage = $('#stage');\nconst stageCtx = stage.getContext('2d');\nconst stageWrap = $('#stageWrap');\nconst dropzone = $('#dropzone');\nconst fileInput = $('#fileInput');\nconst histCanvas = $('#histCanvas');\nconst toast = $('#toast');\nconst veil = $('#veil');\nconst veilText = $('#veilText');\nfunction showToast(msg){\n  toast.textContent = msg;\n  toast.classList.add('show');\n  clearTimeout(showToast._t);\n  showToast._t = setTimeout(()=>toast.classList.remove('show'), 2600);\n}\nfunction showVeil(msg){ veilText.textContent = msg; veil.classList.add('show'); }\nfunction hideVeil(){ veil.classList.remove('show'); }\n\/* ===========================================================\n   FILE LOADING\n   =========================================================== *\/\n['dragover','dragenter'].forEach(evt=>{\n  stageWrap.addEventListener(evt, e=>{ e.preventDefault(); stageWrap.classList.add('dragover'); });\n});\n['dragleave','drop'].forEach(evt=>{\n  stageWrap.addEventListener(evt, e=>{ e.preventDefault(); stageWrap.classList.remove('dragover'); });\n});\nstageWrap.addEventListener('drop', e=>{\n  const f = e.dataTransfer.files ? e.dataTransfer.files[0] : null;\n  if(f) loadFile(f);\n});\ndropzone.addEventListener('click', ()=>fileInput.click());\nfileInput.addEventListener('change', e=>{\n  if(e.target.files[0]) loadFile(e.target.files[0]);\n});\nfunction loadFile(file){\n  if(!file.type.startsWith('image\/')){ showToast('That file isn\\'t an image.'); return; }\n  const img = new Image();\n  const url = URL.createObjectURL(file);\n  img.onload = ()=>{\n    URL.revokeObjectURL(url);\n    initFromImage(img);\n  };\n  img.onerror = ()=>{ showToast('Couldn\\'t read that image.'); };\n  img.src = url;\n}\nfunction initFromImage(img){\n  let w = img.naturalWidth, h = img.naturalHeight;\n  const scale = Math.min(1, MAX_DIM \/ Math.max(w,h));\n  w = Math.round(w*scale); h = Math.round(h*scale);\n  const c = document.createElement('canvas');\n  c.width = w; c.height = h;\n  const ctx = c.getContext('2d');\n  ctx.fillStyle = '#ffffff';   \/\/ transparency-safe: composite onto white paper first\n  ctx.fillRect(0,0,w,h);\n  ctx.drawImage(img,0,0,w,h);\n  state.fullCanvas = c;\n  state.rotation = 0; state.flipH = false; state.flipV = false; state.crop = null;\n  state.hasImage = true;\n  rebuildSourceCanvas();\n  dropzone.style.display = 'none';\n  stage.style.display = 'block';\n  setControlsEnabled(true);\n  showToast('Photo loaded. Starting in Original view.');\n}\nfunction setControlsEnabled(on){\n  ['btnRotate','btnFlipH','btnFlipV','btnCrop','btnWand','btnReset','btnRemove','btnPrint','btnDownloadPNG','btnDownloadPage']\n    .forEach(id=>{ const el = $('#'+id); if(el) el.disabled = !on; });\n  if(!on){ $('#btnUndoWand').style.display = 'none'; state.bgUndoSnapshot = null; }\n}\n\/* ===========================================================\n   TRANSFORM PIPELINE  (rotate \/ flip \/ crop -> sourceCanvas)\n   =========================================================== *\/\nfunction rebuildSourceCanvas(){\n  const src = state.fullCanvas;\n  const rot = state.rotation;\n  const swap = (rot===90 || rot===270);\n  const w = swap ? src.height : src.width;\n  const h = swap ? src.width : src.height;\n  const rc = document.createElement('canvas');\n  rc.width = w; rc.height = h;\n  const ctx = rc.getContext('2d');\n  ctx.save();\n  ctx.translate(w\/2, h\/2);\n  ctx.rotate(rot*Math.PI\/180);\n  ctx.scale(state.flipH ? -1:1, state.flipV ? -1:1);\n  ctx.drawImage(src, -src.width\/2, -src.height\/2);\n  ctx.restore();\n  let finalCanvas = rc;\n  if(state.crop){\n    const cx = Math.round(state.crop.x * rc.width);\n    const cy = Math.round(state.crop.y * rc.height);\n    const cw = Math.max(1, Math.round(state.crop.w * rc.width));\n    const ch = Math.max(1, Math.round(state.crop.h * rc.height));\n    const cc = document.createElement('canvas');\n    cc.width = cw; cc.height = ch;\n    cc.getContext('2d').drawImage(rc, cx, cy, cw, ch, 0, 0, cw, ch);\n    finalCanvas = cc;\n  }\n  state.sourceCanvas = finalCanvas;\n  \/\/ build a downscaled preview copy for fast slider interaction\n  const pScale = Math.min(1, PREVIEW_DIM \/ Math.max(finalCanvas.width, finalCanvas.height));\n  const pw = Math.max(1, Math.round(finalCanvas.width*pScale));\n  const ph = Math.max(1, Math.round(finalCanvas.height*pScale));\n  const pc = document.createElement('canvas');\n  pc.width = pw; pc.height = ph;\n  pc.getContext('2d').drawImage(finalCanvas, 0, 0, pw, ph);\n  state.previewSource = pc;\n  requestRender();\n}\n\/* ===========================================================\n   TONE PIPELINE\n   Builds a 256-entry LUT for black\/white\/gamma\/brightness\/contrast,\n   then applies it (plus optional desaturate \/ invert) to an ImageData.\n   =========================================================== *\/\nfunction buildLUT(t){\n  const lut = new Uint8ClampedArray(256);\n  const {black, white, gamma, brightness, contrast} = t;\n  const range = Math.max(1, white - black);\n  const c = (contrast\/100); \/\/ -1..1\n  const contrastFactor = Math.tan((c + 1) * Math.PI\/4); \/\/ smooth contrast curve\n  for(let i=0;i<256;i++){\n    let v = (i - black) \/ range;      \/\/ levels\n    v = Math.max(0, Math.min(1, v));\n    v = Math.pow(v, 1\/gamma);         \/\/ gamma\n    v = v*255 + brightness*1.2;       \/\/ brightness\n    v = (v-127.5)*contrastFactor + 127.5; \/\/ contrast\n    lut[i] = v;\n  }\n  return lut;\n}\nfunction applyTone(srcCanvas, opts){\n  const w = srcCanvas.width, h = srcCanvas.height;\n  const out = document.createElement('canvas');\n  out.width = w; out.height = h;\n  const octx = out.getContext('2d');\n  octx.drawImage(srcCanvas,0,0);\n  const imgData = octx.getImageData(0,0,w,h);\n  const d = imgData.data;\n  const lut = buildLUT(opts.tone);\n  const sat = opts.tone.saturate\/100;\n  const invert = opts.invert;\n  const cyanotype = opts.cyanotype;\n  for(let i=0;i<d.length;i+=4){\n    let r=d[i], g=d[i+1], b=d[i+2];\n    \/\/ desaturate toward luminosity\n    const lum = 0.299*r + 0.587*g + 0.114*b;\n    r = lum + (r-lum)*sat;\n    g = lum + (g-lum)*sat;\n    b = lum + (b-lum)*sat;\n    \/\/ grayscale value used for the negative\/tone\/cyanotype paths\n    let gray = 0.299*r + 0.587*g + 0.114*b;\n    gray = lut[Math.max(0,Math.min(255,Math.round(gray)))];\n    if(invert) gray = 255-gray;\n    if(cyanotype){\n      \/\/ map density to an approximate Prussian-blue print colour\n      const t2 = gray\/255;\n      const rr = 17 + t2*(252-17);\n      const gg = 14 + t2*(252-14);\n      const bb = 158 + t2*(252-158);\n      d[i]=rr; d[i+1]=gg; d[i+2]=bb;\n    } else if(opts.mode==='color'){\n      \/\/ keep colour but with tone curve applied to each channel via lut, sat already applied\n      d[i]  = lut[Math.max(0,Math.min(255,Math.round(r)))];\n      d[i+1]= lut[Math.max(0,Math.min(255,Math.round(g)))];\n      d[i+2]= lut[Math.max(0,Math.min(255,Math.round(b)))];\n      if(invert){ d[i]=255-d[i]; d[i+1]=255-d[i+1]; d[i+2]=255-d[i+2]; }\n    } else {\n      d[i]=gray; d[i+1]=gray; d[i+2]=gray;\n    }\n  }\n  octx.putImageData(imgData,0,0);\n  return out;\n}\nfunction currentToneOptions(mode){\n  const base = { tone: state.tone, mode:'gray', invert:false, cyanotype:false };\n  if(mode==='original'){ return { tone:{black:0,white:255,gamma:1,brightness:0,contrast:0,saturate:100}, mode:'color', invert:false, cyanotype:false }; }\n  if(mode==='tone'){ return { tone:state.tone, mode:'gray', invert:false, cyanotype:false }; }\n  if(mode==='negative'){ return { tone:state.tone, mode:'gray', invert:true, cyanotype:false }; }\n  if(mode==='cyanotype'){ return { tone:state.tone, mode:'gray', invert:false, cyanotype:true }; }\n  return base;\n}\n\/* ===========================================================\n   RENDER (preview canvas + histogram)\n   =========================================================== *\/\nlet renderQueued = false;\nfunction requestRender(){\n  if(renderQueued) return;\n  renderQueued = true;\n  requestAnimationFrame(()=>{ renderQueued = false; render(); });\n}\nfunction render(){\n  if(!state.hasImage) return;\n  if(state.wand.active){\n    renderWandPreview();\n    return;\n  }\n  if(state.viewMode==='shirt'){\n    renderShirtMockup();\n    return;\n  }\n  const opts = currentToneOptions(state.viewMode);\n  const processed = applyTone(state.previewSource, opts);\n  if(state.print.stepWedge){\n    const wedgeH = Math.round(processed.height*0.09);\n    stage.width = processed.width;\n    stage.height = processed.height + wedgeH;\n    stageCtx.clearRect(0,0,stage.width,stage.height);\n    stageCtx.fillStyle = '#fff';\n    stageCtx.fillRect(0,0,stage.width,stage.height);\n    stageCtx.drawImage(processed,0,0);\n    drawStepWedge(stageCtx, 0, processed.height, processed.width, wedgeH, wedgeH*0.35);\n  } else {\n    stage.width = processed.width;\n    stage.height = processed.height;\n    stageCtx.drawImage(processed,0,0);\n  }\n  drawHistogram(processed);\n}\n\/* ---- \"On a shirt\" mockup ----\n   Uses the Feather Icons \"shirt\" glyph (MIT licensed, viewBox 0 0 24 24) as a\n   simple flat-lay silhouette, filled with the chosen garment colour, with the\n   cyanotype-preview image clipped into a chest-sized rectangle on top. *\/\nconst SHIRT_PATH = \"M20.38 3.46L16 2a4 4 0 0 1-8 0L3.62 3.46a2 2 0 0 0-1.34 2.23l.58 3.47a1 1 0 0 0 .99.84H6v10a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V10h2.15a1 1 0 0 0 .99-.84l.58-3.47a2 2 0 0 0-1.34-2.23z\";\nconst SHIRT_PRINT_AREAS = {\n  small:  {x:9.3, y:12.4, w:5.4, h:4.6},\n  medium: {x:8.4, y:11.4, w:7.2, h:6.4},\n  large:  {x:7.4, y:10.6, w:9.2, h:8.4}\n};\nlet shirtNoiseCanvas = null;\nfunction getShirtNoise(){\n  if(shirtNoiseCanvas) return shirtNoiseCanvas;\n  const n = document.createElement('canvas');\n  n.width = 160; n.height = 160;\n  const nctx = n.getContext('2d');\n  const id = nctx.createImageData(160,160);\n  for(let i=0;i<id.data.length;i+=4){\n    const v = 128 + (Math.random()*40-20);\n    id.data[i]=v; id.data[i+1]=v; id.data[i+2]=v; id.data[i+3]=255;\n  }\n  nctx.putImageData(id,0,0);\n  shirtNoiseCanvas = n;\n  return n;\n}\nfunction renderShirtMockup(){\n  const SIZE = 900;\n  stage.width = SIZE; stage.height = SIZE;\n  const ctx = stageCtx;\n  ctx.clearRect(0,0,SIZE,SIZE);\n  \/\/ fit the 24x24 icon into the canvas with some padding\n  const pad = SIZE*0.14;\n  const scale = (SIZE - pad*2) \/ 24;\n  const offX = (SIZE - 24*scale)\/2, offY = (SIZE - 24*scale)\/2;\n  ctx.save();\n  ctx.translate(offX, offY);\n  ctx.scale(scale, scale);\n  const shirtPath = new Path2D(SHIRT_PATH);\n  \/\/ soft drop shadow\n  ctx.save();\n  ctx.shadowColor = 'rgba(20,24,31,0.18)';\n  ctx.shadowBlur = 1.2;\n  ctx.shadowOffsetY = 0.5;\n  ctx.fillStyle = state.shirt.color;\n  ctx.fill(shirtPath);\n  ctx.restore();\n  \/\/ subtle fabric shading (light top-left to darker bottom-right)\n  ctx.save();\n  ctx.clip(shirtPath);\n  const grad = ctx.createLinearGradient(0,0,24,24);\n  grad.addColorStop(0,'rgba(255,255,255,0.16)');\n  grad.addColorStop(0.5,'rgba(255,255,255,0)');\n  grad.addColorStop(1,'rgba(0,0,0,0.14)');\n  ctx.fillStyle = grad;\n  ctx.fillRect(0,0,24,24);\n  ctx.restore();\n  \/\/ printed cyanotype area\n  const area = SHIRT_PRINT_AREAS[state.shirt.size] || SHIRT_PRINT_AREAS.medium;\n  ctx.save();\n  ctx.beginPath();\n  ctx.rect(area.x, area.y, area.w, area.h);\n  ctx.clip();\n  const cyOpts = { tone: state.tone, mode:'gray', invert:false, cyanotype:true };\n  const printed = applyTone(state.previewSource, cyOpts);\n  \/\/ cover-fit the printed image into the chest rectangle\n  const srcAR = printed.width\/printed.height, dstAR = area.w\/area.h;\n  let sx=0, sy=0, sw=printed.width, sh=printed.height;\n  if(srcAR > dstAR){ sw = sh*dstAR; sx=(printed.width-sw)\/2; }\n  else { sh = sw\/dstAR; sy=(printed.height-sh)\/2; }\n  ctx.globalCompositeOperation = 'multiply';\n  ctx.globalAlpha = 0.92;\n  ctx.drawImage(printed, sx,sy,sw,sh, area.x, area.y, area.w, area.h);\n  ctx.globalAlpha = 1; ctx.globalCompositeOperation = 'source-over';\n  \/\/ fine fabric grain over the print for a woven, printed-not-pasted feel\n  ctx.globalCompositeOperation = 'overlay';\n  ctx.globalAlpha = 0.10;\n  ctx.drawImage(getShirtNoise(), area.x, area.y, area.w, area.h);\n  ctx.globalAlpha = 1; ctx.globalCompositeOperation = 'source-over';\n  ctx.restore();\n  \/\/ crisp outline on top\n  ctx.lineWidth = 2\/scale;\n  ctx.strokeStyle = 'rgba(20,24,31,0.28)';\n  ctx.stroke(shirtPath);\n  ctx.restore();\n  drawHistogram(applyTone(state.previewSource, {tone:state.tone, mode:'gray', invert:true, cyanotype:false}));\n}\nfunction drawHistogram(canvas){\n  const ctx = canvas.getContext('2d');\n  const {data} = ctx.getImageData(0,0,canvas.width,canvas.height);\n  const bins = new Array(64).fill(0);\n  for(let i=0;i<data.length;i+=4){\n    const l = Math.round(0.299*data[i]+0.587*data[i+1]+0.114*data[i+2]);\n    bins[Math.min(63, l>>2)]++;\n  }\n  const max = Math.max(...bins);\n  const hctx = histCanvas.getContext('2d');\n  const W = histCanvas.width, H = histCanvas.height;\n  hctx.clearRect(0,0,W,H);\n  hctx.fillStyle = '#EEF3FA';\n  hctx.fillRect(0,0,W,H);\n  hctx.fillStyle = '#1B4C8C';\n  const bw = W\/bins.length;\n  bins.forEach((v,i)=>{\n    const bh = max? (v\/max)*(H-4) : 0;\n    hctx.fillRect(i*bw, H-bh, bw-0.5, bh);\n  });\n}\n\/* ===========================================================\n   MAGIC WAND \/ BACKGROUND REMOVAL\n   Selection is computed at previewSource resolution for speed, then\n   upscaled at Apply time. Matched pixels get painted flat white\n   (not made transparent) because that's what actually matters for a\n   cyanotype negative: a pure white area inverts to solid black, which\n   blocks UV and prints as clean paper.\n   =========================================================== *\/\nconst wandOverlayEl = $('#wandControls');\nfunction enterWandTool(){\n  if(!state.hasImage) return;\n  state.wand.active = true;\n  state.wand.seeds = [];\n  state.wand.maskAlpha = null;\n  $('#wandApply').disabled = true;\n  wandOverlayEl.style.display = 'flex';\n  stageWrap.classList.add('wand-active');\n  cropOverlay.style.display = 'none';\n  state.viewMode = 'original';\n  $$('#viewModeSeg button').forEach(b=>b.classList.toggle('active', b.dataset.mode==='original'));\n  $('#shirtControls').style.display = 'none';\n  requestRender();\n}\nfunction exitWandTool(){\n  state.wand.active = false;\n  wandOverlayEl.style.display = 'none';\n  stageWrap.classList.remove('wand-active');\n  requestRender();\n}\nfunction sampleColorAt(canvas, x, y){\n  const ctx = canvas.getContext('2d');\n  const d = ctx.getImageData(x,y,1,1).data;\n  return [d[0], d[1], d[2]];\n}\nfunction colorDist(r1,g1,b1,r2,g2,b2){\n  const dr=r1-r2, dg=g1-g2, db=b1-b2;\n  return Math.sqrt(dr*dr+dg*dg+db*db);\n}\nfunction recomputeWandMask(){\n  const src = state.previewSource;\n  if(!src || state.wand.seeds.length===0){ state.wand.maskAlpha = null; $('#wandApply').disabled = true; requestRender(); return; }\n  const w = src.width, h = src.height;\n  const ctx = src.getContext('2d');\n  const {data} = ctx.getImageData(0,0,w,h);\n  const maxDist = (state.wand.tolerance\/100) * 441.7;\n  const mask = new Uint8ClampedArray(w*h); \/\/ 0 or 255\n  if(state.wand.contiguous){\n    \/\/ BFS flood fill from each seed independently, using that seed's own colour\n    const visited = new Uint8Array(w*h);\n    for(const seed of state.wand.seeds){\n      const idx0 = seed.y*w+seed.x;\n      if(visited[idx0]) continue;\n      const [sr,sg,sb] = [seed.r, seed.g, seed.b];\n      const stack = [idx0];\n      visited[idx0] = 1;\n      while(stack.length){\n        const idx = stack.pop();\n        const px = idx%w, py=(idx\/w)|0;\n        const o = idx*4;\n        const d = colorDist(data[o],data[o+1],data[o+2], sr,sg,sb);\n        if(d>maxDist) continue;\n        mask[idx] = 255;\n        if(px>0){ if(!visited[idx-1]){ visited[idx-1]=1; stack.push(idx-1); } }\n        if(px<w-1){ if(!visited[idx+1]){ visited[idx+1]=1; stack.push(idx+1); } }\n        if(py>0){ if(!visited[idx-w]){ visited[idx-w]=1; stack.push(idx-w); } }\n        if(py<h-1){ if(!visited[idx+w]){ visited[idx+w]=1; stack.push(idx+w); } }\n      }\n    }\n  } else {\n    \/\/ global match: any pixel close to ANY seed colour, anywhere in the image\n    for(let i=0;i<w*h;i++){\n      const o = i*4;\n      for(const seed of state.wand.seeds){\n        if(colorDist(data[o],data[o+1],data[o+2], seed.r,seed.g,seed.b) <= maxDist){ mask[i]=255; break; }\n      }\n    }\n  }\n  state.wand.maskAlpha = featherMask(mask, w, h, state.wand.feather);\n  $('#wandApply').disabled = false;\n  requestRender();\n}\nfunction featherMask(mask, w, h, radius){\n  if(radius<=0) return mask;\n  \/\/ cheap box blur, a couple of passes, gives a soft-ish edge without heavy cost\n  let src = new Uint8ClampedArray(mask);\n  let dst = new Uint8ClampedArray(mask.length);\n  const passes = 2;\n  for(let p=0;p<passes;p++){\n    for(let y=0;y<h;y++){\n      for(let x=0;x<w;x++){\n        let sum=0, count=0;\n        for(let dy=-radius; dy<=radius; dy+=Math.max(1,Math.round(radius\/3))){\n          const yy=y+dy; if(yy<0||yy>=h) continue;\n          for(let dx=-radius; dx<=radius; dx+=Math.max(1,Math.round(radius\/3))){\n            const xx=x+dx; if(xx<0||xx>=w) continue;\n            sum += src[yy*w+xx]; count++;\n          }\n        }\n        dst[y*w+x] = count? sum\/count : src[y*w+x];\n      }\n    }\n    [src,dst] = [dst,src];\n  }\n  return src;\n}\nfunction renderWandPreview(){\n  const src = state.previewSource;\n  stage.width = src.width; stage.height = src.height;\n  stageCtx.clearRect(0,0,src.width,src.height);\n  stageCtx.drawImage(src,0,0);\n  if(state.wand.maskAlpha){\n    const overlay = document.createElement('canvas');\n    overlay.width = src.width; overlay.height = src.height;\n    const octx = overlay.getContext('2d');\n    const id = octx.createImageData(src.width, src.height);\n    const mask = state.wand.maskAlpha;\n    for(let i=0;i<mask.length;i++){\n      const o = i*4;\n      id.data[o]=255; id.data[o+1]=0; id.data[o+2]=150; id.data[o+3]=mask[i]*0.55;\n    }\n    octx.putImageData(id,0,0);\n    stageCtx.drawImage(overlay,0,0);\n  }\n  drawHistogram(src);\n}\nfunction stageEventToPreviewPixel(e){\n  const rect = stage.getBoundingClientRect();\n  const relX = (e.clientX-rect.left)\/rect.width;\n  const relY = (e.clientY-rect.top)\/rect.height;\n  const x = Math.max(0, Math.min(state.previewSource.width-1, Math.round(relX*state.previewSource.width)));\n  const y = Math.max(0, Math.min(state.previewSource.height-1, Math.round(relY*state.previewSource.height)));\n  return {x,y};\n}\nstage.addEventListener('click', e=>{\n  if(!state.wand.active || !state.hasImage) return;\n  const {x,y} = stageEventToPreviewPixel(e);\n  const [r,g,b] = sampleColorAt(state.previewSource, x, y);\n  const addMode = e.shiftKey || $('#wandAddMode').checked;\n  if(!addMode) state.wand.seeds = [];\n  state.wand.seeds.push({x,y,r,g,b});\n  recomputeWandMask();\n});\n$('#btnWand').addEventListener('click', ()=> enterWandTool());\n$('#wandCancel').addEventListener('click', ()=> exitWandTool());\n$('#wandClear').addEventListener('click', ()=>{\n  state.wand.seeds = [];\n  state.wand.maskAlpha = null;\n  $('#wandApply').disabled = true;\n  requestRender();\n});\n$('#wandContiguous').addEventListener('change', e=>{\n  state.wand.contiguous = e.target.checked;\n  if(state.wand.seeds.length) recomputeWandMask();\n});\n$('#wandTolerance').addEventListener('input', e=>{\n  state.wand.tolerance = parseInt(e.target.value);\n  $('#wandToleranceOut').textContent = e.target.value;\n  if(state.wand.seeds.length) recomputeWandMask();\n});\n$('#wandFeather').addEventListener('input', e=>{\n  state.wand.feather = parseInt(e.target.value);\n  $('#wandFeatherOut').textContent = e.target.value;\n  if(state.wand.seeds.length) recomputeWandMask();\n});\n$('#wandApply').addEventListener('click', async ()=>{\n  if(!state.wand.maskAlpha) return;\n  showVeil('Filling background with white\u2026');\n  await new Promise(r=>setTimeout(r,20));\n  \/\/ snapshot everything needed to undo this one destructive step\n  state.bgUndoSnapshot = {\n    fullCanvas: state.fullCanvas,\n    rotation: state.rotation, flipH: state.flipH, flipV: state.flipV,\n    crop: state.crop ? {...state.crop} : null\n  };\n  const full = state.sourceCanvas; \/\/ current, post-transform, full-res\n  const fw = full.width, fh = full.height;\n  \/\/ upscale the small mask onto a full-res canvas using the browser's own\n  \/\/ (bilinear) image scaling, then read it back as an alpha map\n  const maskSmall = document.createElement('canvas');\n  maskSmall.width = state.previewSource.width; maskSmall.height = state.previewSource.height;\n  const msctx = maskSmall.getContext('2d');\n  const mid = msctx.createImageData(maskSmall.width, maskSmall.height);\n  for(let i=0;i<state.wand.maskAlpha.length;i++){\n    const o=i*4; const v = state.wand.maskAlpha[i];\n    mid.data[o]=v; mid.data[o+1]=v; mid.data[o+2]=v; mid.data[o+3]=255;\n  }\n  msctx.putImageData(mid,0,0);\n  const maskFull = document.createElement('canvas');\n  maskFull.width = fw; maskFull.height = fh;\n  maskFull.getContext('2d').drawImage(maskSmall, 0,0, fw, fh);\n  const maskData = maskFull.getContext('2d').getImageData(0,0,fw,fh).data;\n  const edited = document.createElement('canvas');\n  edited.width = fw; edited.height = fh;\n  const ectx = edited.getContext('2d');\n  ectx.drawImage(full,0,0);\n  const imgData = ectx.getImageData(0,0,fw,fh);\n  const d = imgData.data;\n  for(let i=0;i<d.length;i+=4){\n    const a = maskData[i]\/255; \/\/ luminance of mask canvas = alpha we want\n    if(a>0){\n      d[i]   = d[i]  *(1-a) + 255*a;\n      d[i+1] = d[i+1]*(1-a) + 255*a;\n      d[i+2] = d[i+2]*(1-a) + 255*a;\n    }\n  }\n  ectx.putImageData(imgData,0,0);\n  \/\/ re-base: this flattened result becomes the new \"original\" photo\n  state.fullCanvas = edited;\n  state.rotation = 0; state.flipH = false; state.flipV = false; state.crop = null;\n  rebuildSourceCanvas();\n  $('#btnUndoWand').style.display = 'inline-flex';\n  exitWandTool();\n  hideVeil();\n  showToast('Background filled with white. Rotate, flip and crop have been flattened into the photo.');\n});\n$('#btnUndoWand').addEventListener('click', ()=>{\n  if(!state.bgUndoSnapshot) return;\n  const s = state.bgUndoSnapshot;\n  state.fullCanvas = s.fullCanvas;\n  state.rotation = s.rotation; state.flipH = s.flipH; state.flipV = s.flipV; state.crop = s.crop;\n  state.bgUndoSnapshot = null;\n  $('#btnUndoWand').style.display = 'none';\n  rebuildSourceCanvas();\n  showToast('Background removal undone.');\n});\n$$('#viewModeSeg button').forEach(btn=>{\n  btn.addEventListener('click', ()=>{\n    if(state.wand.active) exitWandTool();\n    $$('#viewModeSeg button').forEach(b=>b.classList.remove('active'));\n    btn.classList.add('active');\n    state.viewMode = btn.dataset.mode;\n    $('#shirtControls').style.display = (state.viewMode==='shirt') ? 'flex' : 'none';\n    requestRender();\n  });\n});\n$$('#shirtSizeSeg button').forEach(btn=>{\n  btn.addEventListener('click', ()=>{\n    $$('#shirtSizeSeg button').forEach(b=>b.classList.remove('active'));\n    btn.classList.add('active');\n    state.shirt.size = btn.dataset.size;\n    requestRender();\n  });\n});\n$('#btnRotate').addEventListener('click', ()=>{\n  if(!state.hasImage) return;\n  if(state.wand.active) exitWandTool();\n  state.rotation = (state.rotation+90)%360;\n  state.crop = null;\n  rebuildSourceCanvas();\n});\n$('#btnFlipH').addEventListener('click', ()=>{\n  if(!state.hasImage) return;\n  if(state.wand.active) exitWandTool();\n  state.flipH = !state.flipH; state.crop = null; rebuildSourceCanvas();\n});\n$('#btnFlipV').addEventListener('click', ()=>{\n  if(!state.hasImage) return;\n  if(state.wand.active) exitWandTool();\n  state.flipV = !state.flipV; state.crop = null; rebuildSourceCanvas();\n});\n$('#btnReset').addEventListener('click', ()=>{\n  if(!state.hasImage) return;\n  if(state.wand.active) exitWandTool();\n  state.rotation=0; state.flipH=false; state.flipV=false; state.crop=null;\n  state.tone = {black:0, white:255, gamma:1, brightness:0, contrast:0, saturate:100};\n  syncToneInputs();\n  rebuildSourceCanvas();\n  showToast('Reverted to the original photo.');\n});\n$('#btnRemove').addEventListener('click', ()=>{\n  if(!state.hasImage) return;\n  if(state.wand.active) exitWandTool();\n  state.hasImage = false;\n  state.fullCanvas = null;\n  state.sourceCanvas = null;\n  state.previewSource = null;\n  state.rotation = 0; state.flipH = false; state.flipV = false; state.crop = null;\n  state.tone = {black:0, white:255, gamma:1, brightness:0, contrast:0, saturate:100};\n  state.bgUndoSnapshot = null;\n  $('#btnUndoWand').style.display = 'none';\n  syncToneInputs();\n  stageCtx.clearRect(0,0,stage.width,stage.height);\n  stage.style.display = 'none';\n  dropzone.style.display = 'block';\n  cropOverlay.style.display = 'none';\n  fileInput.value = '';\n  setControlsEnabled(false);\n  const hctx = histCanvas.getContext('2d');\n  hctx.clearRect(0,0,histCanvas.width,histCanvas.height);\n  showToast('Photo removed. Drop in a new one to start over.');\n});\nfunction bindSlider(id, key, outId, format){\n  const el = $('#'+id), out = $('#'+outId);\n  el.addEventListener('input', ()=>{\n    let v = parseFloat(el.value);\n    if(key==='gamma') v = v\/100;\n    state.tone[key] = v;\n    out.textContent = format ? format(v) : v;\n    requestRender();\n  });\n}\nbindSlider('black','black','outBlack');\nbindSlider('white','white','outWhite');\nbindSlider('gamma','gamma','outGamma', v=>v.toFixed(2));\nbindSlider('brightness','brightness','outBrightness');\nbindSlider('contrast','contrast','outContrast');\nbindSlider('saturate','saturate','outSaturate');\nfunction syncToneInputs(){\n  $('#black').value = state.tone.black; $('#outBlack').textContent = state.tone.black;\n  $('#white').value = state.tone.white; $('#outWhite').textContent = state.tone.white;\n  $('#gamma').value = Math.round(state.tone.gamma*100); $('#outGamma').textContent = state.tone.gamma.toFixed(2);\n  $('#brightness').value = state.tone.brightness; $('#outBrightness').textContent = state.tone.brightness;\n  $('#contrast').value = state.tone.contrast; $('#outContrast').textContent = state.tone.contrast;\n  $('#saturate').value = state.tone.saturate; $('#outSaturate').textContent = state.tone.saturate;\n}\n$('#btnAutoLevels').addEventListener('click', ()=>{\n  if(!state.hasImage) return;\n  const ctx = state.previewSource.getContext('2d');\n  const {data} = ctx.getImageData(0,0,state.previewSource.width, state.previewSource.height);\n  let lo=255, hi=0;\n  for(let i=0;i<data.length;i+=4){\n    const l = 0.299*data[i]+0.587*data[i+1]+0.114*data[i+2];\n    if(l<lo) lo=l; if(l>hi) hi=l;\n  }\n  state.tone.black = Math.max(0, Math.round(lo));\n  state.tone.white = Math.min(255, Math.round(hi));\n  syncToneInputs();\n  requestRender();\n  showToast('Levels set from the image\\'s actual range.');\n});\n\/* substrate presets, sensible starting points, still fully editable *\/\n$('#substratePaper').addEventListener('click', ()=>setSubstrate('paper'));\n$('#substrateFabric').addEventListener('click', ()=>setSubstrate('fabric'));\nfunction setSubstrate(which){\n  state.substrate = which;\n  $('#substratePaper').classList.toggle('active', which==='paper');\n  $('#substrateFabric').classList.toggle('active', which==='fabric');\n  if(which==='fabric'){\n    state.tone.contrast = 15; state.tone.black = 10;\n  } else {\n    state.tone.contrast = 0; state.tone.black = 0;\n  }\n  syncToneInputs();\n  requestRender();\n}\n\/* ===========================================================\n   CROP TOOL\n   =========================================================== *\/\nconst cropOverlay = $('#cropOverlay');\nconst cropRect = $('#cropRect');\nlet cropState = null; \/\/ {x,y,w,h} in fraction of displayed stage\nlet lockAR = null;\n$('#btnCrop').addEventListener('click', ()=>{\n  if(!state.hasImage) return;\n  if(state.wand.active) exitWandTool();\n  cropState = {x:0.05,y:0.05,w:0.9,h:0.9};\n  drawCropRect();\n  cropOverlay.style.display = 'block';\n});\n$('#cropCancel').addEventListener('click', ()=>{ cropOverlay.style.display='none'; });\n$('#cropApply').addEventListener('click', ()=>{\n  if(!cropState) return;\n  const prevCrop = state.crop || {x:0,y:0,w:1,h:1};\n  \/\/ combine with any existing crop (crop applies to current sourceCanvas fractions)\n  state.crop = {\n    x: prevCrop.x + cropState.x*prevCrop.w,\n    y: prevCrop.y + cropState.y*prevCrop.h,\n    w: cropState.w*prevCrop.w,\n    h: cropState.h*prevCrop.h\n  };\n  cropOverlay.style.display='none';\n  rebuildSourceCanvas();\n  showToast('Crop applied.');\n});\n$$('.crop-toolbar [data-ar]').forEach(b=>{\n  b.addEventListener('click', ()=>{\n    const ar = b.dataset.ar;\n    lockAR = ar==='free' ? null : ar.split(':').map(Number);\n    if(lockAR){\n      const stageRect = stage.getBoundingClientRect();\n      const targetAR = lockAR[0]\/lockAR[1];\n      let w = cropState.w, h = cropState.w * (stageRect.width\/stageRect.height) \/ targetAR;\n      if(h>1){ h=0.9; w = h*targetAR*(stageRect.height\/stageRect.width); }\n      cropState.w=w; cropState.h=h;\n      drawCropRect();\n    }\n  });\n});\nfunction drawCropRect(){\n  const wrapRect = stage.getBoundingClientRect();\n  const parentRect = stageWrap.getBoundingClientRect();\n  const left = wrapRect.left-parentRect.left, top = wrapRect.top-parentRect.top;\n  cropRect.style.left = (left + cropState.x*wrapRect.width)+'px';\n  cropRect.style.top = (top + cropState.y*wrapRect.height)+'px';\n  cropRect.style.width = (cropState.w*wrapRect.width)+'px';\n  cropRect.style.height = (cropState.h*wrapRect.height)+'px';\n}\n\/\/ dragging: move rect body, resize via corner handles\nlet dragMode=null, dragStart=null;\ncropRect.addEventListener('pointerdown', e=>{\n  const isHandle = e.target.classList.contains('crop-handle');\n  dragMode = isHandle ? [...e.target.classList].find(c=>c!=='crop-handle') : 'move';\n  dragStart = {x:e.clientX, y:e.clientY, crop:{...cropState}};\n  e.target.setPointerCapture(e.pointerId);\n});\ncropRect.addEventListener('pointermove', e=>{\n  if(!dragMode) return;\n  const wrapRect = stage.getBoundingClientRect();\n  const dx = (e.clientX-dragStart.x)\/wrapRect.width;\n  const dy = (e.clientY-dragStart.y)\/wrapRect.height;\n  const c0 = dragStart.crop;\n  let c = {...c0};\n  if(dragMode==='move'){\n    c.x = Math.min(1-c0.w, Math.max(0, c0.x+dx));\n    c.y = Math.min(1-c0.h, Math.max(0, c0.y+dy));\n  } else if(dragMode==='se'){\n    c.w = Math.min(1-c0.x, Math.max(0.05, c0.w+dx));\n    c.h = Math.min(1-c0.y, Math.max(0.05, c0.h+dy));\n  } else if(dragMode==='nw'){\n    const nx = Math.max(0, Math.min(c0.x+c0.w-0.05, c0.x+dx));\n    const ny = Math.max(0, Math.min(c0.y+c0.h-0.05, c0.y+dy));\n    c.w = c0.w + (c0.x-nx); c.h = c0.h + (c0.y-ny); c.x=nx; c.y=ny;\n  } else if(dragMode==='ne'){\n    const ny = Math.max(0, Math.min(c0.y+c0.h-0.05, c0.y+dy));\n    c.w = Math.min(1-c0.x, Math.max(0.05, c0.w+dx));\n    c.h = c0.h + (c0.y-ny); c.y=ny;\n  } else if(dragMode==='sw'){\n    const nx = Math.max(0, Math.min(c0.x+c0.w-0.05, c0.x+dx));\n    c.w = c0.w + (c0.x-nx); c.x = nx;\n    c.h = Math.min(1-c0.y, Math.max(0.05, c0.h+dy));\n  }\n  cropState = c;\n  drawCropRect();\n});\ncropRect.addEventListener('pointerup', ()=>{ dragMode=null; });\n\/* ===========================================================\n   PANEL TABS\n   =========================================================== *\/\n$$('.panel-tabs button').forEach(btn=>{\n  btn.addEventListener('click', ()=>{\n    $$('.panel-tabs button').forEach(b=>b.classList.remove('active'));\n    $$('.panel-section').forEach(s=>s.classList.remove('active'));\n    btn.classList.add('active');\n    $(`.panel-section[data-tab=\"${btn.dataset.tab}\"]`).classList.add('active');\n  });\n});\n\/* ===========================================================\n   PRINT AND EXPORT SETTINGS\n   =========================================================== *\/\n$('#mirrorPrint').addEventListener('change', e=> state.print.mirror = e.target.checked);\n$('#paperSize').addEventListener('change', e=>{\n  state.print.paper = e.target.value;\n  $('#customSizeRow').style.display = e.target.value==='CUSTOM' ? 'grid' : 'none';\n  updateTileCount();\n});\n$('#customW').addEventListener('input', e=>{ state.print.customW = parseFloat(e.target.value)||210; updateTileCount(); });\n$('#customH').addEventListener('input', e=>{ state.print.customH = parseFloat(e.target.value)||297; updateTileCount(); });\n$('#orientation').addEventListener('change', e=> state.print.orientation = e.target.value);\n$('#dpi').addEventListener('change', e=> state.print.dpi = parseInt(e.target.value));\n$('#fitMode').addEventListener('change', e=> state.print.fit = e.target.value);\n$('#margin').addEventListener('input', e=>{\n  state.print.marginMm = parseFloat(e.target.value);\n  $('#outMargin').textContent = state.print.marginMm;\n});\n$('#cropMarks').addEventListener('change', e=> state.print.cropMarks = e.target.checked);\n$('#stepWedge').addEventListener('change', e=>{\n  state.print.stepWedge = e.target.checked;\n  renderWedgePreview();\n});\n$('#posterMode').addEventListener('change', e=>{\n  state.print.poster = e.target.checked;\n  $('#posterOptions').style.display = e.target.checked ? 'block' : 'none';\n  updateTileCount();\n});\n['posterW','posterH','posterOverlap'].forEach(id=>{\n  $('#'+id).addEventListener('input', e=>{\n    if(id==='posterW') state.print.posterWcm = parseFloat(e.target.value)||60;\n    if(id==='posterH') state.print.posterHcm = parseFloat(e.target.value)||80;\n    if(id==='posterOverlap') state.print.posterOverlapMm = parseFloat(e.target.value)||10;\n    updateTileCount();\n  });\n});\nfunction getPaperMM(){\n  let [w,h] = state.print.paper==='CUSTOM' ? [state.print.customW, state.print.customH] : PAPER_MM[state.print.paper];\n  if(state.print.orientation==='landscape'){ [w,h] = [Math.max(w,h), Math.min(w,h)]; }\n  else { [w,h] = [Math.min(w,h), Math.max(w,h)]; }\n  return [w,h];\n}\nfunction updateTileCount(){\n  if(!state.print.poster){ $('#tileCount').textContent=''; return; }\n  const [pw,ph] = getPaperMM();\n  const ov = state.print.posterOverlapMm;\n  const usableW = pw - ov, usableH = ph - ov;\n  const totalWmm = state.print.posterWcm*10, totalHmm = state.print.posterHcm*10;\n  const cols = Math.max(1, Math.ceil(totalWmm\/usableW));\n  const rows = Math.max(1, Math.ceil(totalHmm\/usableH));\n  $('#tileCount').textContent = `${cols * rows} sheets (${cols} across \u00d7 ${rows} down) at this paper size.`;\n}\nfunction renderWedgePreview(){\n  const wrap = $('#wedgePreview');\n  wrap.innerHTML='';\n  if(!state.print.stepWedge) return;\n  for(let i=0;i<11;i++){\n    const v = Math.round((i\/10)*255);\n    const d = document.createElement('div');\n    d.style.background = `rgb(${v},${v},${v})`;\n    wrap.appendChild(d);\n  }\n}\n\/* ===========================================================\n   EXPORT: build a full-resolution negative canvas\n   =========================================================== *\/\nfunction buildFullNegative(){\n  const opts = { tone: state.tone, mode:'gray', invert:true, cyanotype:false };\n  return applyTone(state.sourceCanvas, opts);\n}\nfunction mmToPx(mm, dpi){ return Math.round(mm\/25.4*dpi); }\nfunction drawRegistrationMarks(ctx, x, y, w, h, len){\n  ctx.save();\n  ctx.strokeStyle = '#000'; ctx.lineWidth = Math.max(1, len*0.08);\n  const corners = [[x,y,1,1],[x+w,y,-1,1],[x,y+h,1,-1],[x+w,y+h,-1,-1]];\n  corners.forEach(([cx,cy,sx,sy])=>{\n    ctx.beginPath();\n    ctx.moveTo(cx, cy+len*sy); ctx.lineTo(cx,cy); ctx.lineTo(cx+len*sx, cy);\n    ctx.stroke();\n  });\n  ctx.restore();\n}\n\/**\n * Draws an 11-step grey scale (0 = black \u2026 10 = white) inside the box (x,y,w,h).\n * labelSpace reserves room below the swatches for the numbers, and must already\n * be included by the caller in whatever it measured \"h\" against.\n *\/\nfunction drawStepWedge(ctx, x, y, w, h, labelSpace){\n  const steps = 11;\n  const stepW = w\/steps;\n  const swatchH = h - labelSpace;\n  ctx.save();\n  for(let i=0;i<steps;i++){\n    const v = Math.round((i\/(steps-1))*255);\n    ctx.fillStyle = `rgb(${v},${v},${v})`;\n    ctx.fillRect(x+i*stepW, y, stepW+0.5, swatchH);\n  }\n  ctx.strokeStyle = '#999'; ctx.lineWidth = 1;\n  ctx.strokeRect(x,y,w,swatchH);\n  const fontSize = Math.max(9, labelSpace*0.6);\n  ctx.fillStyle = '#000'; ctx.font = `${fontSize}px sans-serif`;\n  ctx.textAlign = 'center'; ctx.textBaseline = 'middle';\n  for(let i=0;i<steps;i++){\n    ctx.fillText(String(i), x+i*stepW+stepW\/2, y+swatchH+labelSpace\/2);\n  }\n  ctx.restore();\n}\n\/**\n * Renders one full page canvas for a (possibly cropped) portion of the negative.\n * srcRectMm\/srcSizeMm describe, in mm of the *final artwork*, which slice this tile covers;\n * used only by the poster tiler; for a single-sheet export pass null.\n *\/\nfunction renderPageCanvas({negativeCanvas, tile}){\n  const dpi = state.print.dpi;\n  const [pwMm, phMm] = getPaperMM();\n  const pageW = mmToPx(pwMm, dpi), pageH = mmToPx(phMm, dpi);\n  const marginPx = mmToPx(state.print.marginMm, dpi);\n  const page = document.createElement('canvas');\n  page.width = pageW; page.height = pageH;\n  const ctx = page.getContext('2d');\n  ctx.fillStyle = '#ffffff'; ctx.fillRect(0,0,pageW,pageH);\n  const contentW = pageW - marginPx*2;\n  let contentH = pageH - marginPx*2;\n  if(state.print.stepWedge){ if(!tile){ contentH -= mmToPx(14 + 4, dpi); } } \/\/ wedge height + gap above it\n  let drawW, drawH, sx=0, sy=0, sw=negativeCanvas.width, sh=negativeCanvas.height;\n  if(tile){\n    \/\/ poster tile: negativeCanvas here is already the full-res negative;\n    \/\/ sx\/sy\/sw\/sh select the pixel slice for this tile, scaled 1:1 to the page content box\n    sx = tile.sx; sy = tile.sy; sw = tile.sw; sh = tile.sh;\n    drawW = contentW; drawH = contentH;\n  } else if(state.print.fit==='fit'){\n    const scale = Math.min(contentW\/sw, contentH\/sh);\n    drawW = sw*scale; drawH = sh*scale;\n  } else {\n    const scale = Math.max(contentW\/sw, contentH\/sh);\n    drawW = sw*scale; drawH = sh*scale;\n    \/\/ center-crop source to content aspect\n    const cropW = contentW\/scale, cropH = contentH\/scale;\n    sx = (sw-cropW)\/2; sy = (sh-cropH)\/2; sw = cropW; sh = cropH;\n    drawW = contentW; drawH = contentH;\n  }\n  const dx = marginPx + (contentW-drawW)\/2;\n  const dy = marginPx + (contentH-drawH)\/2;\n  ctx.save();\n  if(state.print.mirror){\n    ctx.translate(pageW,0); ctx.scale(-1,1);\n    ctx.drawImage(negativeCanvas, sx,sy,sw,sh, pageW-dx-drawW, dy, drawW, drawH);\n  } else {\n    ctx.drawImage(negativeCanvas, sx,sy,sw,sh, dx, dy, drawW, drawH);\n  }\n  ctx.restore();\n  if(state.print.cropMarks){\n    drawRegistrationMarks(ctx, marginPx*0.4, marginPx*0.4, pageW-marginPx*0.8, pageH-marginPx*0.8, mmToPx(4,dpi));\n  }\n  if(state.print.stepWedge){\n   if(!tile){\n    const wedgeH = mmToPx(14, dpi);\n    drawStepWedge(ctx, marginPx, pageH-marginPx-wedgeH, contentW, wedgeH, wedgeH*0.3);\n   }\n  }\n  return page;\n}\nfunction canvasToBlob(canvas){\n  return new Promise(res=> canvas.toBlob(res, 'image\/png'));\n}\nfunction downloadBlob(blob, filename){\n  const a = document.createElement('a');\n  a.href = URL.createObjectURL(blob);\n  a.download = filename;\n  document.body.appendChild(a); a.click(); a.remove();\n}\n\/* ---- Download PNG (just the negative, native resolution) ---- *\/\n$('#btnDownloadPNG').addEventListener('click', async ()=>{\n  if(!state.hasImage) return;\n  showVeil('Rendering full-resolution negative\u2026');\n  await new Promise(r=>setTimeout(r,20));\n  let neg = buildFullNegative();\n  if(state.print.mirror){\n    const m = document.createElement('canvas'); m.width=neg.width; m.height=neg.height;\n    const mctx = m.getContext('2d'); mctx.translate(neg.width,0); mctx.scale(-1,1); mctx.drawImage(neg,0,0);\n    neg = m;\n  }\n  if(state.print.stepWedge){\n    const wedgeH = Math.round(neg.height*0.09);\n    const withWedge = document.createElement('canvas');\n    withWedge.width = neg.width; withWedge.height = neg.height + wedgeH;\n    const wctx = withWedge.getContext('2d');\n    wctx.fillStyle = '#fff'; wctx.fillRect(0,0,withWedge.width,withWedge.height);\n    wctx.drawImage(neg,0,0);\n    drawStepWedge(wctx, 0, neg.height, neg.width, wedgeH, wedgeH*0.35);\n    neg = withWedge;\n  }\n  const blob = await canvasToBlob(neg);\n  downloadBlob(blob, 'botanopia-cyanotype-negative.png');\n  hideVeil();\n  showToast('Negative downloaded.');\n});\n\/* ---- Download print-ready page(s) ---- *\/\n$('#btnDownloadPage').addEventListener('click', async ()=>{\n  if(!state.hasImage) return;\n  if(state.print.poster){\n    await exportPoster();\n    return;\n  }\n  showVeil('Laying out your print-ready page\u2026');\n  await new Promise(r=>setTimeout(r,20));\n  const neg = buildFullNegative();\n  const page = renderPageCanvas({negativeCanvas:neg, tile:null});\n  const blob = await canvasToBlob(page);\n  downloadBlob(blob, `botanopia-cyanotype-${state.print.paper}-${state.print.dpi}dpi.png`);\n  hideVeil();\n  showToast('Print-ready page downloaded.');\n});\n\/* ---- Print directly ----\n   Built with DOM methods rather than a raw HTML string, so there is no\n   literal tag text sitting inside this script anywhere. Some page\n   builder or editor content scanners misread that kind of text even\n   when it is safely inside a JS string. *\/\n$('#btnPrint').addEventListener('click', async ()=>{\n  if(!state.hasImage) return;\n  showVeil('Preparing print preview\u2026');\n  await new Promise(r=>setTimeout(r,20));\n  const neg = buildFullNegative();\n  const page = renderPageCanvas({negativeCanvas:neg, tile:null});\n  const dataUrl = page.toDataURL('image\/png');\n  const [pwMm, phMm] = getPaperMM();\n  const w = window.open('', '_blank');\n  const doc = w.document;\n  doc.title = 'Print negative';\n  const styleEl = doc.createElement('style');\n  styleEl.textContent =\n    '@page{ size:' + pwMm + 'mm ' + phMm + 'mm; margin:0; } ' +\n    'html,body{margin:0;padding:0;} ' +\n    'img{width:' + pwMm + 'mm;height:' + phMm + 'mm;display:block;}';\n  doc.head.appendChild(styleEl);\n  const imgEl = doc.createElement('img');\n  imgEl.src = dataUrl;\n  imgEl.addEventListener('load', ()=>{ setTimeout(()=>w.print(), 150); });\n  doc.body.appendChild(imgEl);\n  hideVeil();\n});\n\/* ---- Poster \/ tiling export (zipped via JSZip, loaded on demand) ---- *\/\nlet jszipLoading = null;\nfunction ensureJSZip(){\n  if(window.JSZip) return Promise.resolve();\n  if(jszipLoading) return jszipLoading;\n  jszipLoading = new Promise((resolve,reject)=>{\n    const s = document.createElement('script');\n    s.src = 'https:\/\/cdnjs.cloudflare.com\/ajax\/libs\/jszip\/3.10.1\/jszip.min.js';\n    s.onload = resolve; s.onerror = reject;\n    document.head.appendChild(s);\n  });\n  return jszipLoading;\n}\nasync function exportPoster(){\n  showVeil('Tiling your poster, this can take a moment\u2026');\n  try{\n    await ensureJSZip();\n    await new Promise(r=>setTimeout(r,20));\n    const neg = buildFullNegative();\n    const dpi = state.print.dpi;\n    const [pwMm, phMm] = getPaperMM();\n    const ov = state.print.posterOverlapMm;\n    const usableWmm = pwMm - ov, usableHmm = phMm - ov;\n    const totalWmm = state.print.posterWcm*10, totalHmm = state.print.posterHcm*10;\n    const cols = Math.max(1, Math.ceil(totalWmm\/usableWmm));\n    const rows = Math.max(1, Math.ceil(totalHmm\/usableHmm));\n    \/\/ scale full negative so that totalWmm\/totalHmm maps to the full negative's pixel size\n    const pxPerMmX = neg.width\/totalWmm, pxPerMmY = neg.height\/totalHmm;\n    const zip = new JSZip();\n    for(let r=0;r<rows;r++){\n      for(let c=0;c<cols;c++){\n        const sxMm = c*usableWmm, syMm = r*usableHmm;\n        const sx = Math.round(sxMm*pxPerMmX), sy = Math.round(syMm*pxPerMmY);\n        const sw = Math.min(neg.width-sx, Math.round(pwMm*pxPerMmX));\n        const sh = Math.min(neg.height-sy, Math.round(phMm*pxPerMmY));\n        const page = renderPageCanvas({negativeCanvas:neg, tile:{sx,sy,sw,sh}});\n        const blob = await canvasToBlob(page);\n        zip.file(`row${r+1}-col${c+1}.png`, blob);\n      }\n    }\n    const content = await zip.generateAsync({type:'blob'});\n    downloadBlob(content, 'botanopia-cyanotype-poster-tiles.zip');\n    showToast(`Exported ${rows*cols} tiles.`);\n  }catch(err){\n    console.error(err);\n    showToast('Could not build the poster tiles. Check your connection and try again.');\n  }\n  hideVeil();\n}\nconst mobileNoteCloseBtn = $('#mobileNoteClose');\nif(mobileNoteCloseBtn){\n  mobileNoteCloseBtn.addEventListener('click', ()=>{\n    $('#mobileNote').style.display = 'none';\n  });\n}\n\/* tap-to-open info tooltips (\":hover\" doesn't exist on touch devices) *\/\n$$('.pop').forEach(pop=>{\n  pop.addEventListener('click', e=>{\n    e.stopPropagation();\n    const wasOpen = pop.classList.contains('open');\n    $$('.pop.open').forEach(p=>p.classList.remove('open'));\n    if(!wasOpen) pop.classList.add('open');\n  });\n});\ndocument.addEventListener('click', ()=> $$('.pop.open').forEach(p=>p.classList.remove('open')));\n\/* init *\/\nrenderWedgePreview();\nupdateTileCount();\n} \/\/ end initNegativeMaker\nif(document.readyState === 'loading'){\n  document.addEventListener('DOMContentLoaded', initNegativeMaker);\n} else {\n  initNegativeMaker();\n}\n})();\n<\/script>\n","protected":false},"excerpt":{"rendered":"<p>Kostenloser Cyanotypie-Negativgenerator, verwandeln Sie Fotos in ein digitales Negativ | Botanopia Kostenloser Cyanotypie-Negativgenerator Verwandeln Sie jedes Foto direkt in Ihrem Browser in ein druckfertiges Cyanotypie-Negativ. Passen Sie Tonwerte und Kontrast an, kehren Sie das Bild um und spiegeln Sie es, f\u00fcgen Sie einen Graukeil zur Kalibrierung hinzu und laden Sie ein Negativ im richtigen Format&hellip;<\/p>\n","protected":false},"author":32002033,"featured_media":0,"parent":0,"menu_order":0,"comment_status":"closed","ping_status":"closed","template":"","meta":{"_acf_changed":false,"footnotes":""},"class_list":["post-45120840","page","type-page","status-publish","hentry"],"acf":[],"_links":{"self":[{"href":"https:\/\/www.botanopia.com\/de\/wp-json\/wp\/v2\/pages\/45120840","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.botanopia.com\/de\/wp-json\/wp\/v2\/pages"}],"about":[{"href":"https:\/\/www.botanopia.com\/de\/wp-json\/wp\/v2\/types\/page"}],"author":[{"embeddable":true,"href":"https:\/\/www.botanopia.com\/de\/wp-json\/wp\/v2\/users\/32002033"}],"replies":[{"embeddable":true,"href":"https:\/\/www.botanopia.com\/de\/wp-json\/wp\/v2\/comments?post=45120840"}],"version-history":[{"count":0,"href":"https:\/\/www.botanopia.com\/de\/wp-json\/wp\/v2\/pages\/45120840\/revisions"}],"wp:attachment":[{"href":"https:\/\/www.botanopia.com\/de\/wp-json\/wp\/v2\/media?parent=45120840"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}