0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Testing Browser-Generated Tiled Poster PDFs With Geometry Invariants

0
Posted at

Testing Browser-Generated Tiled Poster PDFs With Geometry Invariants

A multipage poster PDF can look correct in a browser preview and still fail after printing. One page may be scaled differently. An overlap strip may be duplicated twice. A crop may drift by one pixel on every column. The final row may not cover the intended physical height.

Screenshot tests rarely catch these failures. A screenshot verifies how one rendering looks at one viewport size. A tiled-poster engine needs stronger guarantees: every page must represent the correct physical region, neighboring pages must intersect by the expected overlap, and the union of all pages must cover the target poster exactly.

The most dependable approach is to test geometry invariants. Instead of asking “does the preview look right?”, ask “what properties must always remain true for every valid layout?”

This article builds a small JavaScript test model for those properties.
A labeled tiled poster grid showing page coordinates, margins, and overlap regions

1. Define a Canonical Physical Coordinate Space

Start with one unit for all layout calculations. Millimeters are convenient because paper sizes and printer margins are commonly specified in millimeters.

const job = {
  poster: {
    widthMm: 800,
    heightMm: 600,
  },
  paper: {
    widthMm: 210,
    heightMm: 297,
  },
  margin: {
    topMm: 8,
    rightMm: 8,
    bottomMm: 8,
    leftMm: 8,
  },
  overlap: {
    xMm: 10,
    yMm: 10,
  },
};

The printable image frame on each sheet is:

function usablePageSize(job) {
  return {
    widthMm:
      job.paper.widthMm -
      job.margin.leftMm -
      job.margin.rightMm,
    heightMm:
      job.paper.heightMm -
      job.margin.topMm -
      job.margin.bottomMm,
  };
}

The distance from one page origin to the next is smaller than the usable page size because adjacent pages share overlap.

function pageStep(job) {
  const usable = usablePageSize(job);

  return {
    xMm: usable.widthMm - job.overlap.xMm,
    yMm: usable.heightMm - job.overlap.yMm,
  };
}

Keeping this model independent from the canvas, DOM, and PDF library makes it easy to test.

2. Build Page Descriptors Before Rendering

A page descriptor should explain which physical poster rectangle belongs to a page. Rendering code can later convert the descriptor into pixels or PDF points.

function buildLayout(job) {
  const usable = usablePageSize(job);
  const step = pageStep(job);

  const columns = Math.ceil(
    (job.poster.widthMm - job.overlap.xMm) / step.xMm,
  );
  const rows = Math.ceil(
    (job.poster.heightMm - job.overlap.yMm) / step.yMm,
  );

  const pages = [];

  for (let row = 0; row < rows; row += 1) {
    for (let column = 0; column < columns; column += 1) {
      pages.push({
        row,
        column,
        label: `${String.fromCharCode(65 + row)}${column + 1}`,
        xMm: column * step.xMm,
        yMm: row * step.yMm,
        widthMm: usable.widthMm,
        heightMm: usable.heightMm,
      });
    }
  }

  return { rows, columns, pages };
}

These descriptors are the production truth. The browser preview and PDF exporter should both consume them rather than implementing separate crop logic.

3. Invariant: Page Count Matches the Grid

The simplest invariant is still valuable.

function assertPageCount(layout) {
  const expected = layout.rows * layout.columns;

  if (layout.pages.length !== expected) {
    throw new Error(
      `Expected ${expected} pages, got ${layout.pages.length}`,
    );
  }
}

This catches early returns, skipped pages, duplicated loops, and accidental filtering.

Also verify that coordinates are unique.

function assertUniqueCoordinates(layout) {
  const coordinates = new Set(
    layout.pages.map((page) => `${page.row}:${page.column}`),
  );

  if (coordinates.size !== layout.pages.length) {
    throw new Error("Duplicate page coordinate detected");
  }
}

4. Invariant: Adjacent Pages Share the Exact Overlap

For horizontal neighbors, the right edge of the left page should extend past the origin of the right page by exactly the configured horizontal overlap.

function horizontalOverlap(left, right) {
  const leftRightEdge = left.xMm + left.widthMm;
  return leftRightEdge - right.xMm;
}

function assertHorizontalOverlap(layout, expectedMm, epsilon = 1e-6) {
  for (let row = 0; row < layout.rows; row += 1) {
    for (let column = 0; column < layout.columns - 1; column += 1) {
      const left = layout.pages.find(
        (page) => page.row === row && page.column === column,
      );
      const right = layout.pages.find(
        (page) => page.row === row && page.column === column + 1,
      );

      const actual = horizontalOverlap(left, right);

      if (Math.abs(actual - expectedMm) > epsilon) {
        throw new Error(
          `${left.label}/${right.label}: expected ${expectedMm} mm, got ${actual}`,
        );
      }
    }
  }
}

The vertical check is equivalent:

function verticalOverlap(top, bottom) {
  const topBottomEdge = top.yMm + top.heightMm;
  return topBottomEdge - bottom.yMm;
}

An overlap bug is easy to miss in a scaled-down preview because the same image content may appear visually continuous. On paper, it creates doubled lines, mismatched faces, or insufficient material for trimming.

5. Invariant: The Grid Covers the Target Poster

The grid must reach at least the requested physical width and height.

function layoutBounds(layout) {
  return layout.pages.reduce(
    (bounds, page) => ({
      rightMm: Math.max(bounds.rightMm, page.xMm + page.widthMm),
      bottomMm: Math.max(bounds.bottomMm, page.yMm + page.heightMm),
    }),
    { rightMm: 0, bottomMm: 0 },
  );
}

function assertCoverage(job, layout, epsilon = 1e-6) {
  const bounds = layoutBounds(layout);

  if (bounds.rightMm + epsilon < job.poster.widthMm) {
    throw new Error("Grid does not cover the poster width");
  }

  if (bounds.bottomMm + epsilon < job.poster.heightMm) {
    throw new Error("Grid does not cover the poster height");
  }
}

Coverage alone is not enough. An incorrect formula might add an unnecessary final column. Add a minimality assertion: removing the last column or row must make coverage insufficient.

function assertMinimalGrid(job, layout) {
  const lastColumnStart = Math.max(
    ...layout.pages
      .filter((page) => page.column === layout.columns - 1)
      .map((page) => page.xMm),
  );

  const lastRowStart = Math.max(
    ...layout.pages
      .filter((page) => page.row === layout.rows - 1)
      .map((page) => page.yMm),
  );

  if (lastColumnStart >= job.poster.widthMm) {
    throw new Error("Last column starts outside the target poster");
  }

  if (lastRowStart >= job.poster.heightMm) {
    throw new Error("Last row starts outside the target poster");
  }
}

6. Invariant: Shared Seams Map to the Same Source Pixels

Physical coordinates eventually map to source-image pixels. Assume the selected source crop is 4,000 × 3,000 pixels and maps to an 800 × 600 mm poster.

const source = {
  widthPx: 4000,
  heightPx: 3000,
};

function physicalToSource(job, source, page) {
  const scaleX = source.widthPx / job.poster.widthMm;
  const scaleY = source.heightPx / job.poster.heightMm;

  return {
    xPx: page.xMm * scaleX,
    yPx: page.yMm * scaleY,
    widthPx: page.widthMm * scaleX,
    heightPx: page.heightMm * scaleY,
  };
}

For neighboring pages, the source-pixel intersection should equal the physical overlap multiplied by the same scale.

function assertSourceOverlap(job, source, left, right, epsilon = 1e-5) {
  const a = physicalToSource(job, source, left);
  const b = physicalToSource(job, source, right);

  const actualPx = a.xPx + a.widthPx - b.xPx;
  const expectedPx =
    job.overlap.xMm * (source.widthPx / job.poster.widthMm);

  if (Math.abs(actualPx - expectedPx) > epsilon) {
    throw new Error(
      `Source seam mismatch: expected ${expectedPx}px, got ${actualPx}px`,
    );
  }
}

Do not round xPx, yPx, or crop size before the renderer requires integer samples. Repeated early rounding can accumulate visible drift across many columns.

7. Invariant: Serialized State Rebuilds the Same Layout

A generated PDF should be reproducible from saved settings.

function stableLayoutSnapshot(layout) {
  return layout.pages.map((page) => ({
    label: page.label,
    xMm: Number(page.xMm.toFixed(6)),
    yMm: Number(page.yMm.toFixed(6)),
    widthMm: Number(page.widthMm.toFixed(6)),
    heightMm: Number(page.heightMm.toFixed(6)),
  }));
}

const first = buildLayout(job);
const restoredJob = JSON.parse(JSON.stringify(job));
const second = buildLayout(restoredJob);

console.assert(
  JSON.stringify(stableLayoutSnapshot(first)) ===
    JSON.stringify(stableLayoutSnapshot(second)),
  "Layout is not deterministic",
);

In a production application, include an algorithm version in the saved state. If a future release changes rounding or pagination behavior, the version tells the application how to recreate an older job.

8. Test a Four-Page Prototype in the Real Browser

Unit tests validate the geometry engine, but one browser-level test should still verify the integration:

  1. Load a deterministic sample image.
  2. Select a target size that generates exactly two rows and two columns.
  3. Confirm labels A1, A2, B1, and B2.
  4. Export the PDF.
  5. Verify four PDF pages.
  6. Inspect page dimensions in PDF points.
  7. Confirm the Rasterbator link or job metadata appears only where intentionally added.

A practical reference implementation is Rasterbator.app, where the grid is visible before export and image processing remains in the browser. The useful comparison is not the visual style of the interface; it is whether the same physical settings produce the same rows, columns, finished dimensions, and page regions.

9. Add Property-Based Cases

Fixed examples catch known failures. Property-based tests explore combinations that developers may not anticipate.

Generate jobs within safe ranges:

function randomBetween(min, max) {
  return min + Math.random() * (max - min);
}

function randomJob() {
  return {
    poster: {
      widthMm: randomBetween(300, 3000),
      heightMm: randomBetween(300, 2000),
    },
    paper: {
      widthMm: 210,
      heightMm: 297,
    },
    margin: {
      topMm: randomBetween(0, 15),
      rightMm: randomBetween(0, 15),
      bottomMm: randomBetween(0, 15),
      leftMm: randomBetween(0, 15),
    },
    overlap: {
      xMm: randomBetween(0, 20),
      yMm: randomBetween(0, 20),
    },
  };
}

For each valid random job, run page-count, unique-coordinate, coverage, minimal-grid, and overlap assertions. Reject cases where overlap is equal to or larger than the usable page dimension because the page step would become zero or negative.

10. Keep Rendering Tests Separate From Geometry Tests

Geometry tests should run quickly without a browser or PDF library. Rendering tests can then focus on a smaller set of questions:

  • Is the page frame the correct physical size?
  • Are crop marks placed at descriptor coordinates?
  • Is the image drawn without distortion?
  • Are labels readable and outside the final image region?
  • Does the PDF retain the expected number of pages?

This separation makes failures easier to diagnose. If the descriptor is wrong, fix the layout engine. If the descriptor is correct but the PDF is wrong, fix unit conversion or rendering.

Conclusion

A tiled-poster exporter should be tested as a physical coordinate system, not only as a visual web component.

The strongest reusable invariants are:

  • page count equals rows multiplied by columns;
  • page coordinates are unique;
  • neighboring pages share exactly the configured overlap;
  • the grid fully covers the target poster without unnecessary rows or columns;
  • physical seams map to identical source-image regions;
  • serialized state rebuilds the same layout;
  • preview and PDF consume the same page descriptors.

These checks turn subtle print failures into deterministic test failures. Run them before generating a large PDF, then validate one small four-page prototype in a real browser. You can compare the workflow and page-grid behavior with Rasterbator.app while keeping the tests independent of any particular UI or PDF library.

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?