2025 12 05 12.44.21 d98a3e44d97a

Ctrl+Alt+Impose: A Lightweight Imposition Calculator for Print Professionals

In professional print environments, efficiency is everything—especially when planning how many document pages can be imposed onto a larger sheet of media. Whether preparing marketing materials, booklets, or multi-up static documents, knowing your maximum yield per sheet directly impacts cost, production time, and waste reduction.

Ctrl+Alt+Impose was built to solve this need. Designed as a lightweight, browser-based imposition estimator, the tool provides fast calculations and a visual diagram of how many pages fit on a chosen sheet—respecting margins, bleeds, gutters, and even rotation. It is ideal for production staff, prepress trainees, and quick shop-floor checks before formal imposition in RIP or imposition software.

This article walks through the architecture of the tool, explains why each part matters, and isolates key logic from the code so developers can follow or extend it.
Source file reference:


1. The Purpose of Ctrl+Alt+Impose

Traditional imposition tools are powerful but can be:

  • Slow to access
  • Locked behind licensing
  • Overkill for quick estimates
  • Challenging for new employees

This tool removes those barriers. Users simply enter document dimensions and sheet dimensions to instantly see:

  • Maximum pieces per sheet (standard vs. rotated orientation)
  • A color-coded layout diagram
  • Rows × columns breakdown
  • Effects of margin and gutter adjustments in real time

Its goal is not to replace prepress software but to bridge the knowledge gap and support fast decision-making.


2. The Interface & User Experience

The tool opens with a branded splash screen and transitions into a crisp Ricoh-styled interface. The layout uses two main columns:

  • Left: Input fields for dimensions and settings
  • Right: Results, orientation breakdown, and a visual layout diagram

Inputs include:

  • Document width & height
  • Media width & height
  • Optional presets such as 8.5×11, 11×17, and 12×18
  • Margins and gutters
  • Unit selection
  • Live dark/light mode switching

This UI is powered entirely by HTML, CSS, and vanilla JavaScript—no frameworks required.


3. Core Calculation Logic

The heart of the tool is the calculate() function. This function determines how many documents fit across and down the sheet, both unrotated and rotated 90 degrees.

Key Snippet: Imposition Calculation Function

function calculate(docW, docH, mediaW, mediaH, marginH, marginV, gutterH, gutterV) {
  function perAxis(mediaSize, docSize, margin, gutter) {
    const effective = mediaSize - margin * 2;
    if (effective <= 0) return 0;
    return Math.max(0,
      Math.floor((effective + gutter) / (docSize + gutter))
    );
  }

  const across = perAxis(mediaW, docW, marginH, gutterH);
  const down   = perAxis(mediaH, docH, marginV, gutterV);
  const normal = across * down;

  const acrossR = perAxis(mediaW, docH, marginH, gutterH);
  const downR   = perAxis(mediaH, docW, marginV, gutterV);
  const rotated = acrossR * downR;

  return { normal, rotated, across, down, acrossR, downR };
}

Why This Matters

This calculation method mirrors the logic used in professional imposition software:

  • Margins are subtracted from total sheet size to form the usable “safe area.”
  • Gutters are factored between documents to account for cut tolerances.
  • Rotation is evaluated separately to ensure the tool always surfaces the best possible yield.
  • The function returns both orientations and the raw grid values, used later in the UI.

This snippet demonstrates clean modularity—each axis calculation is reusable and unit-independent.


4. Input Handling and Validation

User input is cleaned and validated using a helper function that ensures:

  • Values are numeric
  • Zero is only allowed where appropriate
  • Negative or missing inputs are rejected

Key Snippet: Input Validation

function val(id, allowZero = false) {
  const n = parseFloat(document.getElementById(id).value);
  if (isNaN(n)) return null;
  if (!allowZero && n <= 0) return null;
  if (allowZero && n < 0) return null;
  return n;
}

Why This Matters

Print calculations rely on exact numbers—invalid inputs can easily cascade into incorrect imposition counts. This function ensures the tool fails safely and alerts the user.


5. Visual Layout Diagram

One of the most powerful features is the canvas-based visualization showing how many documents fit on the sheet. The tool draws:

  • The full sheet
  • The usable safe area
  • Each imposed document (in rotated or normal orientation)
  • Gutter spacing
  • Margin boundaries

Key Snippet: Drawing the Layout

const scale = Math.min(
  (canvas.width - padding*2) / mediaW,
  (canvas.height - padding*2) / mediaH
);

const sheetW = mediaW * scale;
const sheetH = mediaH * scale;
const startX = (canvas.width - sheetW) / 2;
const startY = (canvas.height - sheetH) / 2;

// Draw sheet and safe area
ctx.fillRect(startX, startY, sheetW, sheetH);
ctx.strokeRect(startX, startY, sheetW, sheetH);

Why This Matters

Scaling ensures any size of media fits cleanly on the fixed-size diagram canvas.
The dynamic rendering creates a real-world mental model of imposition, helping beginners understand:

  • Why rotation sometimes improves yield
  • How margins reduce available rows/columns
  • What gutters actually do in a layout

This transforms a numerical result into intuitive visual feedback.


6. Orientation Summary & Best-Case Selection

After computing both orientations, the tool compares them:

const best = Math.max(data.normal, data.rotated);
resultCount.textContent = best;

const orient = (data.rotated > data.normal) ? "rotated" : "normal";
drawDiagram(orient, data, ...);

Why This Matters

The calculator always surfaces the optimal orientation, saving the user from manual experimentation.


7. User Experience Enhancements

Several thoughtful quality-of-life features set this tool apart:

Dark Mode with LocalStorage

const savedTheme = localStorage.getItem("ctrlAltImposeTheme") || "light";
applyTheme(savedTheme);

Splash Screen & Branding

A floating, animated branded splash establishes polish and professionalism.

About Modal

Quick documentation is available directly inside the app.

Preset Sheet Sizes

Ideal for U.S. commercial print shops.

Clear Reset Logic

Ensures new calculations start cleanly.

These touches enhance usability, adoption, and training value—key in fast-paced production environments.


8. Conclusion

Ctrl+Alt+Impose is a powerful example of how a lightweight, well-designed browser tool can support the specialized needs of print and production environments. By blending clean code, a professional UI, and practical features such as rotation handling and visual diagrams, it bridges the gap between quick estimates and formal prepress workflows.

This tool empowers:

  • Production staff needing quick yield estimates
  • Prepress trainees learning imposition theory
  • Customer service teams validating order feasibility
  • Anyone who needs fast, accurate sheet utilization metrics

And because it’s built entirely in HTML, CSS, and JavaScript, it can be extended, branded, or integrated into larger production tool suites.

<!-- 
====================================================
  TOOL NAME: Ctrl+Alt+Impose
  AUTHOR: James Michael Elmore
  VERSION: 1.6
  LAST UPDATED: 2025-11-24
  DESCRIPTION: Imposition calculator with splash,
  logo integration, dark mode, presets, margins,
  gutters, layout diagram, and about modal.
====================================================
-->

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <title>Ctrl+Alt+Impose | Imposition Calculator</title>
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  
  <style>
    :root {
      --ricoh-red: #d31d3e;
      --ricoh-dark-red: #931036;
      --ricoh-light-bg: #f5f5f5;
      --ricoh-mid-grey: #e0e0e0;
      --ricoh-dark-grey: #333333;
      --ricoh-border: #cccccc;
      --ricoh-focus: #fc3741;

      --bg-body: radial-gradient(circle at top, #ffffff 0, #f2f2f2 40%, #e6e6e6 100%);
      --bg-app: #ffffff;
      --bg-panel: var(--ricoh-light-bg);
      --bg-result: radial-gradient(circle at top left, #ffeef2, #ffffff 40%);
      --bg-footer: transparent;

      --text-main: #333333;
      --text-muted: #555555;
      --text-soft: #777777;

      --border-panel: var(--ricoh-mid-grey);
      --border-result: #ffd6df;
      --shadow-main: 0 12px 30px rgba(0, 0, 0, 0.08);
    }

    /* Dark mode overrides */
    body.dark-mode {
      --bg-body: radial-gradient(circle at top, #121212 0, #121212 40%, #050505 100%);
      --bg-app: #141414;
      --bg-panel: #1f1f1f;
      --bg-result: radial-gradient(circle at top left, #3a101b, #1a1a1a 40%);
      --bg-footer: #000000;

      --text-main: #f0f0f0;
      --text-muted: #d0d0d0;
      --text-soft: #aaaaaa;

      --border-panel: #3a3a3a;
      --border-result: #5b1b2e;
      --shadow-main: 0 18px 38px rgba(0, 0, 0, 0.7);
    }

    body {
      margin: 0;
      font-family: system-ui, sans-serif;
      background: var(--bg-body);
      min-height: 100vh;
      display: flex;
      flex-direction: column;
      color: var(--text-main);
    }

    body.no-scroll {
      overflow: hidden;
    }

    /* ======================================================
       SPLASH SCREEN
    ====================================================== */
    #splash-screen {
      position: fixed;
      inset: 0;
      background: radial-gradient(circle at top, #2a2a2a 0, #000000 70%);
      display: flex;
      align-items: center;
      justify-content: center;
      z-index: 100;
      transition: opacity 0.6s ease, visibility 0.6s ease;
      opacity: 1;
      visibility: visible;
    }
    #splash-screen.hidden {
      opacity: 0;
      visibility: hidden;
    }

    .splash-content {
      text-align: center;
      color: #fff;
      animation: floatUp 0.9s ease-out;
    }

    .splash-logo {
      max-width: 260px;
      width: 60vw;
      margin-bottom: 0.75rem;
      animation: pulseGlow 1.6s ease-in-out infinite alternate;
      filter: drop-shadow(0 10px 22px rgba(0,0,0,0.6));
    }

    .splash-title {
      font-size: 1.1rem;
      letter-spacing: 0.22em;
      text-transform: uppercase;
    }

    @keyframes floatUp {
      from { transform: translateY(18px); opacity: 0; }
      to   { transform: translateY(0); opacity: 1; }
    }

    @keyframes pulseGlow {
      from { transform: scale(1); filter: drop-shadow(0 10px 22px rgba(211,29,62,0.3)); }
      to   { transform: scale(1.04); filter: drop-shadow(0 14px 28px rgba(211,29,62,0.6)); }
    }

    /* ======================================================
       HEADER WITH LOGO + TOGGLES
    ====================================================== */
    header {
      background: linear-gradient(90deg, var(--ricoh-dark-red), var(--ricoh-red));
      color: #fff;
      padding: 1.1rem 1.8rem;
      display: flex;
      align-items: center;
      justify-content: space-between;
      position: sticky;
      top: 0;
      z-index: 10;
      box-shadow: 0 4px 12px rgba(0,0,0,0.25);
    }

    .brand {
      display: flex;
      align-items: center;
      gap: 0.75rem;
    }

    .header-logo {
      height: 32px;
      width: auto;
      display: block;
    }

    .brand-title {
      font-size: 1.25rem;
      font-weight: 700;
      letter-spacing: 0.08em;
      text-transform: uppercase;
    }

    .brand-subtitle {
      font-size: 0.85rem;
      opacity: 0.85;
    }

    .header-actions {
      display: flex;
      gap: 0.6rem;
      align-items: center;
    }

    .about-btn,
    .mode-toggle {
      background: transparent;
      padding: 0.4rem 0.8rem;
      border-radius: 999px;
      color: white;
      cursor: pointer;
      text-transform: uppercase;
      letter-spacing: 0.07em;
      font-size: 0.75rem;
      border: 1px solid rgba(255,255,255,0.7);
      display: inline-flex;
      align-items: center;
      gap: 0.4rem;
    }

    .about-btn:hover,
    .mode-toggle:hover {
      background: rgba(255,255,255,0.15);
    }

    .mode-toggle-icon {
      font-size: 1rem;
      line-height: 1;
    }

    /* ======================================================
       ABOUT MODAL
    ====================================================== */
    .modal-backdrop {
      position: fixed;
      inset: 0;
      background: rgba(0,0,0,0.45);
      display: none;
      align-items: center;
      justify-content: center;
      z-index: 50;
    }
    .modal-backdrop.open { display: flex; }

    .modal {
      background: #fff;
      border-radius: 16px;
      width: 90%;
      max-width: 420px;
      padding: 1.7rem;
      position: relative;
      box-shadow: var(--shadow-main);
      border: 1px solid var(--border-panel);
      color: #333;
    }

    body.dark-mode .modal {
      background: #1f1f1f;
      color: var(--text-main);
    }

    .modal-close {
      position: absolute;
      top: 0.6rem;
      right: 0.9rem;
      background: transparent;
      border: none;
      font-size: 1.2rem;
      cursor: pointer;
      color: #777;
    }

    body.dark-mode .modal-close {
      color: #bbbbbb;
    }

    .about-logo {
      max-width: 120px;
      width: 40%;
      display: block;
      margin: 0 auto 1rem;
      filter: drop-shadow(0 4px 10px rgba(0,0,0,0.25));
    }

    .modal-title {
      text-align: center;
      font-size: 1.1rem;
      letter-spacing: 0.1em;
      text-transform: uppercase;
      color: var(--ricoh-dark-red);
      margin-bottom: 1rem;
    }

    .modal-body,
    .modal-meta {
      text-align: center;
      font-size: 0.9rem;
      line-height: 1.45;
      color: inherit;
    }

    /* ======================================================
       MAIN CONTENT
    ====================================================== */

    main {
      flex: 1;
      display: flex;
      justify-content: center;
      padding: 2rem 1rem 3rem;
    }

    .app-shell {
      width: 100%;
      max-width: 980px;
      background: var(--bg-app);
      border-radius: 18px;
      padding: 2rem;
      display: grid;
      grid-template-columns: 1.4fr 1fr;
      gap: 2rem;
      box-shadow: var(--shadow-main);
      border: 1px solid var(--border-panel);
    }

    @media (max-width: 850px) {
      .app-shell { grid-template-columns: 1fr; }
    }

    h1 {
      margin: 0 0 0.5rem;
      color: var(--ricoh-dark-red);
      text-transform: uppercase;
      letter-spacing: 0.07em;
    }

    body.dark-mode h1 {
      color: #ff9fb7;
    }

    .lead { margin-bottom: 1.3rem; color: var(--text-muted); }

    .panel {
      background: var(--bg-panel);
      border-radius: var(--radius-lg);
      padding: 1.3rem;
      border: 1px solid var(--border-panel);
    }

    .panel-title {
      font-size: 0.95rem;
      text-transform: uppercase;
      font-weight: 600;
      margin-bottom: 0.8rem;
      color: var(--text-main);
    }

    form {
      display: grid;
      grid-template-columns: repeat(2,1fr);
      gap: 1rem 1.2rem;
    }

    @media (max-width: 600px) { form { grid-template-columns: 1fr; } }

    .field-group { display: flex; flex-direction: column; }

    .field-label {
      font-size: 0.8rem;
      text-transform: uppercase;
      color: var(--text-main);
      letter-spacing: 0.05em;
      margin-bottom: 0.15rem;
    }

    input[type="number"], select {
      padding: 0.6rem;
      border-radius: 6px;
      border: 1px solid var(--ricoh-border);
      font-size: 0.9rem;
      background: #ffffff;
      color: var(--text-main);
    }

    body.dark-mode input[type="number"],
    body.dark-mode select {
      background: #191919;
      border-color: #444;
      color: var(--text-main);
    }

    /* Buttons */
    button {
      border-radius: 999px;
      padding: 0.7rem 1.4rem;
      cursor: pointer;
      text-transform: uppercase;
      font-size: 0.9rem;
      font-weight: 600;
    }

    .btn-primary {
      background: linear-gradient(135deg, var(--ricoh-red), var(--ricoh-dark-red));
      border: none;
      color: #fff;
    }

    .btn-secondary {
      background: white;
      border: 1px solid var(--ricoh-border);
      color: var(--text-main);
    }

    body.dark-mode .btn-secondary {
      background: #1f1f1f;
      border-color: #555;
      color: var(--text-main);
    }

    .actions {
      grid-column: 1/-1;
      display: flex;
      gap: 0.75rem;
      margin-top: 0.5rem;
      flex-wrap: wrap;
    }

    /* Results panel */
    .result-panel { display: flex; flex-direction: column; gap: 1.2rem; }

    .result-main {
      background: var(--bg-result);
      padding: 1.2rem 1.3rem;
      border-radius: var(--radius-lg);
      border: 1px solid var(--border-result);
    }

    .result-count { font-size: 2.4rem; font-weight: 800; }

    #layout-canvas {
      width: 100%;
      max-height: 260px;
      border: 1px dashed var(--ricoh-mid-grey);
      border-radius: 10px;
      background: repeating-linear-gradient(
        45deg,
        #fafafa,
        #fafafa 10px,
        #f2f2f2 10px,
        #f2f2f2 20px
      );
    }

    body.dark-mode #layout-canvas {
      background: repeating-linear-gradient(
        45deg,
        #202020,
        #202020 10px,
        #252525 10px,
        #252525 20px
      );
      border-color: #555;
    }

    footer {
      text-align: center;
      padding: 1rem;
      color: var(--text-soft);
      font-size: 0.8rem;
      background: var(--bg-footer);
    }

  </style>
</head>

<body class="no-scroll">

  <!-- ======================================================
       SPLASH SCREEN
  ====================================================== -->
  <div id="splash-screen">
    <div class="splash-content">
      <img src="logo.webp" alt="Ctrl+Alt+Impose Logo" class="splash-logo" />
      <div class="splash-title">CTRL+ALT+IMPOSE</div>
    </div>
  </div>

  <!-- ======================================================
       HEADER
  ====================================================== -->
  <header>
    <div class="brand">
      <img src="logo.webp" class="header-logo" alt="Ctrl+Alt+Impose logo">
      <div>
        <div class="brand-title">Ctrl+Alt+Impose</div>
        <div class="brand-subtitle">Imposition Calculator · James Michael Elmore</div>
      </div>
    </div>

    <div class="header-actions">
      <button class="mode-toggle" id="mode-toggle" aria-pressed="false">
        <span class="mode-toggle-icon" id="mode-toggle-icon">🌙</span>
        <span id="mode-toggle-label">Dark</span>
      </button>
      <button class="about-btn" id="about-btn">About</button>
    </div>
  </header>

  <!-- ======================================================
       MAIN APPLICATION
  ====================================================== -->
  <main>
    <div class="app-shell">

      <!-- Left column -->
      <section>
        <h1>Sheet Utilization</h1>
        <p class="lead">
          Enter your document and sheet/media dimensions to calculate how many documents 
          fit on one sheet — including optional margins, bleeds, gutters, and rotation.
        </p>

        <div class="panel">
          <div class="panel-title">Dimensions</div>

          <form id="imposition-form" novalidate>

            <div class="field-group">
              <label class="field-label" for="doc-width">Document Width</label>
              <input type="number" id="doc-width" step="0.01" value="8.5">
            </div>

            <div class="field-group">
              <label class="field-label" for="doc-height">Document Height</label>
              <input type="number" id="doc-height" step="0.01" value="11">
            </div>

            <div class="field-group">
              <label class="field-label" for="media-width">Media Width</label>
              <input type="number" id="media-width" step="0.01" value="12">
            </div>

            <div class="field-group">
              <label class="field-label" for="media-height">Media Height</label>
              <input type="number" id="media-height" step="0.01" value="18">
            </div>

            <div class="field-group" style="grid-column:1/-1;">
              <label class="field-label" for="media-preset">Media Preset</label>
              <select id="media-preset">
                <option value="custom" selected>Custom</option>
                <option value="8.5x11">8.5 × 11 (Letter)</option>
                <option value="11x17">11 × 17 (Tabloid)</option>
                <option value="12x18">12 × 18</option>
              </select>
            </div>

            <div class="field-group">
              <label class="field-label" for="margin-horizontal">Horizontal Margin</label>
              <input type="number" id="margin-horizontal" step="0.01" value="0">
            </div>

            <div class="field-group">
              <label class="field-label" for="margin-vertical">Vertical Margin</label>
              <input type="number" id="margin-vertical" step="0.01" value="0">
            </div>

            <div class="field-group">
              <label class="field-label" for="gutter-horizontal">Horizontal Gutter</label>
              <input type="number" id="gutter-horizontal" step="0.01" value="0">
            </div>

            <div class="field-group">
              <label class="field-label" for="gutter-vertical">Vertical Gutter</label>
              <input type="number" id="gutter-vertical" step="0.01" value="0">
            </div>

            <div class="field-group" style="grid-column:1/-1;">
              <label class="field-label" for="units">Units</label>
              <select id="units">
                <option value="inches">Inches</option>
                <option value="mm">Millimeters</option>
                <option value="cm">Centimeters</option>
              </select>
            </div>

            <div class="actions">
              <button class="btn-primary" type="submit">Calculate</button>
              <button class="btn-secondary" type="button" id="reset-btn">
                Reset
              </button>
            </div>

            <div id="error-message" style="color:#b00;font-size:0.85rem;"></div>

          </form>
        </div>
      </section>

      <!-- Right column -->
      <section class="result-panel">

        <div class="result-main">
          <div class="result-label">Maximum per sheet</div>
          <div class="result-count" id="result-count">0</div>
          <div id="result-caption">Enter values and press Calculate.</div>
        </div>

        <div class="panel">
          <div class="panel-title">Orientation Details</div>

          <div id="layout-normal-row">
            <strong>No Rotation:</strong>
            <div id="layout-normal-value">0 × 0 = 0</div>
          </div>

          <div id="layout-rotated-row" style="margin-top:0.6rem;">
            <strong>90° Rotation:</strong>
            <div id="layout-rotated-value">0 × 0 = 0</div>
          </div>
        </div>

        <div class="panel">
          <div class="panel-title">Layout Diagram</div>
          <canvas id="layout-canvas" width="480" height="320"></canvas>
        </div>

      </section>

    </div>
  </main>

  <footer>
    Ctrl+Alt+Impose © James Michael Elmore — Not a substitute for full prepress verification.
  </footer>

  <!-- ======================================================
       ABOUT MODAL
  ====================================================== -->
  <div class="modal-backdrop" id="about-modal">
    <div class="modal">
      <button class="modal-close" id="about-close">×</button>

      <img src="logo.webp" class="about-logo" alt="Ctrl+Alt+Impose logo">

      <h2 class="modal-title">About Ctrl+Alt+Impose</h2>

      <div class="modal-body">
        A lightweight imposition estimator designed for fast planning, sanity checks,
        training, and quick shop-floor calculations before formal prepress.
      </div>

      <div class="modal-meta">
        <strong>Author:</strong> James Michael Elmore<br>
        <strong>Version:</strong> 1.6<br>
        <strong>Purpose:</strong> Quickly determine safe imposition counts using basic sheet + document dimensions.
      </div>
    </div>
  </div>

  <!-- ======================================================
       JAVASCRIPT LOGIC
  ====================================================== -->
  <script>
    const splash = document.getElementById("splash-screen");
    const aboutBtn = document.getElementById("about-btn");
    const aboutModal = document.getElementById("about-modal");
    const aboutClose = document.getElementById("about-close");

    const modeToggle = document.getElementById("mode-toggle");
    const modeToggleIcon = document.getElementById("mode-toggle-icon");
    const modeToggleLabel = document.getElementById("mode-toggle-label");

    // ----------------- THEME HANDLING -----------------
    function applyTheme(theme) {
      if (theme === "dark") {
        document.body.classList.add("dark-mode");
        modeToggle.setAttribute("aria-pressed", "true");
        modeToggleIcon.textContent = "☀️";
        modeToggleLabel.textContent = "Light";
      } else {
        document.body.classList.remove("dark-mode");
        modeToggle.setAttribute("aria-pressed", "false");
        modeToggleIcon.textContent = "🌙";
        modeToggleLabel.textContent = "Dark";
      }
    }

    // Load saved theme
    const savedTheme = localStorage.getItem("ctrlAltImposeTheme") || "light";
    applyTheme(savedTheme);

    modeToggle.addEventListener("click", () => {
      const isDark = document.body.classList.contains("dark-mode");
      const newTheme = isDark ? "light" : "dark";
      applyTheme(newTheme);
      localStorage.setItem("ctrlAltImposeTheme", newTheme);
    });

    // ----------------- SPLASH FADE-OUT -----------------
    window.addEventListener("load", () => {
      setTimeout(() => {
        splash.classList.add("hidden");
        document.body.classList.remove("no-scroll");
      }, 1200);
    });

    // Click-to-skip
    splash.addEventListener("click", () => {
      splash.classList.add("hidden");
      document.body.classList.remove("no-scroll");
    });

    // ----------------- ABOUT MODAL -----------------
    aboutBtn.addEventListener("click", () => {
      aboutModal.classList.add("open");
    });

    aboutClose.addEventListener("click", () => {
      aboutModal.classList.remove("open");
    });

    aboutModal.addEventListener("click", (e) => {
      if (e.target === aboutModal) aboutModal.classList.remove("open");
    });

    document.addEventListener("keydown", (e) => {
      if (e.key === "Escape" && aboutModal.classList.contains("open")) {
        aboutModal.classList.remove("open");
      }
    });

    /* ======================================================
       IMPOSITION CALCULATOR LOGIC
    ====================================================== */

    const form = document.getElementById("imposition-form");
    const errorMsg = document.getElementById("error-message");

    const resultCount = document.getElementById("result-count");
    const resultCaption = document.getElementById("result-caption");

    const layoutNormalValue = document.getElementById("layout-normal-value");
    const layoutRotatedValue = document.getElementById("layout-rotated-value");

    const canvas = document.getElementById("layout-canvas");
    const ctx = canvas.getContext("2d");

    function val(id, allowZero = false) {
      const n = parseFloat(document.getElementById(id).value);
      if (isNaN(n)) return null;
      if (!allowZero && n <= 0) return null;
      if (allowZero && n < 0) return null;
      return n;
    }

    function calculate(docW, docH, mediaW, mediaH, marginH, marginV, gutterH, gutterV) {
      function perAxis(mediaSize, docSize, margin, gutter) {
        const effective = mediaSize - margin * 2;
        if (effective <= 0) return 0;
        return Math.max(0,
          Math.floor((effective + gutter) / (docSize + gutter))
        );
      }

      const across = perAxis(mediaW, docW, marginH, gutterH);
      const down   = perAxis(mediaH, docH, marginV, gutterV);
      const normal = across * down;

      const acrossR = perAxis(mediaW, docH, marginH, gutterH);
      const downR   = perAxis(mediaH, docW, marginV, gutterV);
      const rotated = acrossR * downR;

      return {
        normal,
        rotated,
        across, down,
        acrossR, downR
      };
    }

    function drawDiagram(bestOrientation, data, docW, docH, mediaW, mediaH, marginH, marginV, gutterH, gutterV) {
      ctx.clearRect(0,0,canvas.width,canvas.height);

      if (data.normal === 0 && data.rotated === 0) {
        ctx.fillStyle = "#aaa";
        ctx.textAlign = "center";
        ctx.textBaseline = "middle";
        ctx.font = "14px system-ui";
        ctx.fillText("No layout fits.", canvas.width/2, canvas.height/2);
        return;
      }

      const rotate = (bestOrientation === "rotated");
      const dw = rotate ? docH : docW;
      const dh = rotate ? docW : docH;
      const across = rotate ? data.acrossR : data.across;
      const down   = rotate ? data.downR   : data.down;

      const padding = 20;
      const scale = Math.min(
        (canvas.width - padding*2) / mediaW,
        (canvas.height - padding*2) / mediaH
      );

      const sheetW = mediaW * scale;
      const sheetH = mediaH * scale;
      const startX = (canvas.width - sheetW) / 2;
      const startY = (canvas.height - sheetH) / 2;

      // Sheet
      ctx.fillStyle = document.body.classList.contains("dark-mode") ? "#202020" : "#fafafa";
      ctx.strokeStyle = document.body.classList.contains("dark-mode") ? "#555" : "#ccc";
      ctx.lineWidth = 2;
      ctx.fillRect(startX, startY, sheetW, sheetH);
      ctx.strokeRect(startX, startY, sheetW, sheetH);

      // Safe area
      const safeX = startX + marginH * scale;
      const safeY = startY + marginV * scale;
      const safeW = (mediaW - marginH*2) * scale;
      const safeH = (mediaH - marginV*2) * scale;

      ctx.fillStyle = document.body.classList.contains("dark-mode") ? "#101010" : "#ffffff";
      ctx.strokeStyle = document.body.classList.contains("dark-mode") ? "#444" : "#ddd";
      ctx.lineWidth = 1.5;
      ctx.fillRect(safeX, safeY, safeW, safeH);
      ctx.strokeRect(safeX, safeY, safeW, safeH);

      // Docs
      const dwpx = dw * scale;
      const dhpx = dh * scale;
      const ghpx = gutterH * scale;
      const gvpx = gutterV * scale;

      ctx.fillStyle = "rgba(211,29,62,0.35)";
      ctx.strokeStyle = "#d31d3e";
      ctx.lineWidth = 1;

      for (let r = 0; r < down; r++) {
        for (let c = 0; c < across; c++) {
          const x = safeX + c * (dwpx + ghpx);
          const y = safeY + r * (dhpx + gvpx);
          ctx.fillRect(x, y, dwpx, dhpx);
          ctx.strokeRect(x, y, dwpx, dhpx);
        }
      }
    }

    form.addEventListener("submit", (e) => {
      e.preventDefault();
      errorMsg.textContent = "";

      const docW = val("doc-width");
      const docH = val("doc-height");
      const mediaW = val("media-width");
      const mediaH = val("media-height");

      const marginH = val("margin-horizontal", true);
      const marginV = val("margin-vertical", true);
      const gutterH = val("gutter-horizontal", true);
      const gutterV = val("gutter-vertical", true);

      if ([docW,docH,mediaW,mediaH,marginH,marginV,gutterH,gutterV].includes(null)) {
        errorMsg.textContent = "Invalid input detected. Check all fields.";
        return;
      }

      const data = calculate(docW, docH, mediaW, mediaH, marginH, marginV, gutterH, gutterV);

      layoutNormalValue.textContent = `${data.across} × ${data.down} = ${data.normal}`;
      layoutRotatedValue.textContent = `${data.acrossR} × ${data.downR} = ${data.rotated}`;

      const best = Math.max(data.normal, data.rotated);
      resultCount.textContent = best;
      resultCaption.textContent = best === 0
        ? "With these settings, no documents fit. Try adjusting sizes or margins."
        : "Maximum documents per sheet using the better of the two orientations above.";

      const orient = (data.rotated > data.normal) ? "rotated" : "normal";

      drawDiagram(orient, data, docW, docH, mediaW, mediaH, marginH, marginV, gutterH, gutterV);
    });

    document.getElementById("reset-btn").addEventListener("click", () => {
      form.reset();
      document.getElementById("doc-width").value = 8.5;
      document.getElementById("doc-height").value = 11;
      document.getElementById("media-width").value = 12;
      document.getElementById("media-height").value = 18;

      resultCount.textContent = "0";
      resultCaption.textContent = "Enter values and press Calculate.";
      layoutNormalValue.textContent = "0 × 0 = 0";
      layoutRotatedValue.textContent = "0 × 0 = 0";
      ctx.clearRect(0,0,canvas.width,canvas.height);
    });

    // Media presets
    document.getElementById("media-preset").addEventListener("change", function () {
      const mw = document.getElementById("media-width");
      const mh = document.getElementById("media-height");
      if (this.value === "8.5x11") { mw.value=8.5; mh.value=11; }
      if (this.value === "11x17") { mw.value=11; mh.value=17; }
      if (this.value === "12x18") { mw.value=12; mh.value=18; }
    });

  </script>

</body>
</html>

Leave a Reply

“Good design isn’t decoration - it’s intention made visible.”
“The web is the world’s largest canvas and its most rapidly changing one.”
“Entertainment is the only place where truth and fiction can hold hands without apology.”
“LGBTQ+ rights are human rights—no asterisk, no exception.”
“Trans rights aren’t negotiable; they are fundamental.”
“Bodily autonomy is not a debate topic it is a basic human right.”
“Programming is where logic meets creativity and negotiates a truce.”
“Design fails when it whispers. It succeeds when it speaks without shouting.”
“Technology ages fast, but clarity and usability never expire.”
“A story doesn’t need permission to change a life.”
“LGBTQIA+ Visibility is power; authenticity is freedom.”
“A society that protects trans people protects everyone.”
“A woman’s choice is hers alone, not a collective vote.”
This is a demo ticker text for testing purposes
“Clean code ages gracefully; clever code ages instantly.”