DEV Community

utilvo
utilvo

Posted on

PDF N-Up Imposition and Form XObjects: 2D Affine Matrices in JavaScript

When printing lecture slides, architectural proof sheets, or booklet signatures, placing multiple logical pages onto a single physical sheet—known in print production as N-Up Imposition (2-up, 4-up, 6-up, 9-up, 16-up)—requires more than scaling down bitmap thumbnails.

If you rasterize PDF pages to images and tile them onto a new page, vector text loses its crispness, file size explodes, and text searchability disappears. Conversely, in the ISO 32000-1 PDF specification, a page (<obj> /Type /Page) cannot simply be inserted as a child of another /Page node.

To embed multiple vector pages onto a single target sheet without rasterization, a PDF imposition engine must convert each source page into a PDF Form XObject (/Type /XObject /Subtype /Form), compute an exact 2D Affine Transformation Matrix (cm operator) that scales, rotates, and translates each page's /CropBox or /MediaBox into its target grid cell, and emit a content stream that invokes each Form XObject via the Do operator.

In this technical guide, we will cover:

  1. How PDF Form XObjects (/Subtype /Form) encapsulate a page's content stream, resources, and bounding box (/BBox).
  2. The mathematics of fitting arbitrary source page aspect ratios (Portrait vs. Landscape) into an R × C sheet grid with margins and gutters.
  3. Writing a complete vector N-Up imposition algorithm in JavaScript (pdf-lib).

1. Why Form XObjects Are Required for Vector Page Nesting

In a PDF document's page tree, a /Page dictionary holds:

  • /MediaBox [x0 y0 x1 y1]: The coordinate boundaries of the page in PostScript points (1/72 inch).
  • /Resources << ... >>: Fonts, images, color spaces, and graphics states (ExtGState) referenced by the page.
  • /Contents: One or more compressed streams containing PostScript-like drawing operators (BT, Tf, Tj, m, l, c, S, f).

Because two pages may both define a font named /F1 pointing to completely different typefaces, concatenating their raw /Contents streams onto a single page causes resource name collisions and coordinate origin conflicts.

Wrapping each source page into a Form XObject solves both problems simultaneously:

Target Sheet (/Type /Page, /MediaBox [0 0 841.89 595.28])
 ├── /Resources
 │    └── /XObject << /PageCell0 15 0 R
 │                    /PageCell1 16 0 R
 │                    /PageCell2 17 0 R
 │                    /PageCell3 18 0 R >>
 └── /Contents Stream:
      q 0.45 0 0 0.45 24.0 310.0 cm /PageCell0 Do Q
      q 0.45 0 0 0.45 432.0 310.0 cm /PageCell1 Do Q
      q 0.45 0 0 0.45 24.0 20.0 cm /PageCell2 Do Q
      q 0.45 0 0 0.45 432.0 20.0 cm /PageCell3 Do Q
Enter fullscreen mode Exit fullscreen mode

Each Form XObject (15 0 R .. 18 0 R) has its own isolated /Resources dictionary and /BBox clip rectangle, and executing q ... cm /PageCell0 Do Q inside a graphics state save/restore pair (q ... Q) guarantees that transformations and clipping paths from one cell never leak into neighboring cells.


2. Computing Grid Geometry and Aspect-Preserving Scale Factors

Suppose we have a target output sheet of dimensions (W_sheet, H_sheet) in points (for example, A4 Landscape: 841.89 × 595.28 pt), outer sheet margin M, horizontal column gap G_x, vertical row gap G_y, and a grid of R rows by C columns.

Step 1: Cell Dimensions

The available width W_cell and height H_cell of each grid slot are:

W_cell = (W_sheet - 2 * M - (C - 1) * G_x) / C
H_cell = (H_sheet - 2 * M - (R - 1) * G_y) / R
Enter fullscreen mode Exit fullscreen mode

Step 2: Bottom-Left Origin of Cell (r, c)

Because PDF's coordinate origin (0, 0) is at the bottom-left corner of the sheet (with y increasing upward), while reading order places row r = 0 at the top of the sheet, the bottom-left corner (X_rc, Y_rc) of cell (r, c) for r in 0 .. R-1 and c in 0 .. C-1 is:

X_(r,c) = M + c * (W_cell + G_x)
Y_(r,c) = H_sheet - M - (r + 1) * H_cell - r * G_y
Enter fullscreen mode Exit fullscreen mode

$Y_{r,c} = H_{\text{sheet}} - M - (r + 1) \cdot H_{\text{cell}} - r \cdot G_y$

Step 3: Uniform Scale and Centering Offset

For a source page with bounding box width w_src and height h_src, the uniform scale factor s that fits the page inside (W_cell, H_cell) without distortion is:

s = Math.min(W_cell / w_src, H_cell / h_src)
Enter fullscreen mode Exit fullscreen mode

And the centered translation offset (t_x, t_y) inside cell (r, c) is:

t_x = X_(r,c) + (W_cell - s * w_src) / 2
t_y = Y_(r,c) + (H_cell - s * h_src) / 2
Enter fullscreen mode Exit fullscreen mode

3. Complete Vector N-Up Imposition Implementation in JavaScript

Here is a complete, production-ready implementation using pdf-lib (embedPages + drawPage) that embeds arbitrary PDF pages as vector Form XObjects, arranges them in configurable grids (2-up, 4-up, 6-up, 9-up, 16-up), and optionally draws hairline cell borders for print trimming:

import { PDFDocument, rgb } from "pdf-lib";

const GRID_PRESETS = {
  2: { rows: 1, cols: 2, landscape: true },
  4: { rows: 2, cols: 2, landscape: false },
  6: { rows: 2, cols: 3, landscape: true },
  9: { rows: 3, cols: 3, landscape: false },
  16: { rows: 4, cols: 4, landscape: false },
};

/**
 * Composes multiple PDF pages onto each sheet using vector Form XObjects.
 *
 * @param {Uint8Array} inputPdfBytes - Source PDF buffer
 * @param {object} [options]
 * @param {2|4|6|9|16} [options.pagesPerSheet=4] - Number of logical pages per sheet
 * @param {number} [options.marginPt=24] - Outer sheet margin in points (24 pt = 1/3 inch)
 * @param {number} [options.gapPt=12] - Gutter between cells in points
 * @param {boolean} [options.drawBorders=false] - Draw hairline frame around each page cell
 * @returns {Promise<Uint8Array>} Imposed PDF buffer
 */
export async function imposePdfMultiplePagesPerSheet(inputPdfBytes, options = {}) {
  const {
    pagesPerSheet = 4,
    marginPt = 24,
    gapPt = 12,
    drawBorders = false,
  } = options;

  const preset = GRID_PRESETS[pagesPerSheet] || { rows: 2, cols: 2, landscape: false };
  const { rows, cols, landscape } = preset;

  const srcDoc = await PDFDocument.load(inputPdfBytes);
  const outDoc = await PDFDocument.create();
  const srcPages = srcDoc.getPages();

  // Embed all source pages as isolated vector Form XObjects (/Subtype /Form)
  const embeddedPages = await outDoc.embedPages(srcPages);

  // Standard A4 sheet size in PostScript points (595.28 x 841.89 pt)
  const sheetW = landscape ? 841.89 : 595.28;
  const sheetH = landscape ? 595.28 : 841.89;

  const cellW = (sheetW - marginPt * 2 - (cols - 1) * gapPt) / cols;
  const cellH = (sheetH - marginPt * 2 - (rows - 1) * gapPt) / rows;

  for (let i = 0; i < embeddedPages.length; i += pagesPerSheet) {
    const sheet = outDoc.addPage([sheetW, sheetH]);
    const chunk = embeddedPages.slice(i, i + pagesPerSheet);

    for (let idx = 0; idx < chunk.length; idx++) {
      const formXObject = chunk[idx];
      const r = Math.floor(idx / cols);
      const c = idx % cols;

      // Compute bottom-left origin of cell (r, c) in PDF Y-up coordinates
      const cellX = marginPt + c * (cellW + gapPt);
      const cellY = sheetH - marginPt - (r + 1) * cellH - r * gapPt;

      // Compute uniform aspect-preserving scale factor
      const scale = Math.min(cellW / formXObject.width, cellH / formXObject.height);
      const scaledW = formXObject.width * scale;
      const scaledH = formXObject.height * scale;

      const drawX = cellX + (cellW - scaledW) / 2;
      const drawY = cellY + (cellH - scaledH) / 2;

      sheet.drawPage(formXObject, {
        x: drawX,
        y: drawY,
        width: scaledW,
        height: scaledH,
      });

      if (drawBorders) {
        sheet.drawRectangle({
          x: drawX,
          y: drawY,
          width: scaledW,
          height: scaledH,
          borderColor: rgb(0.75, 0.75, 0.75),
          borderWidth: 0.5,
        });
      }
    }
  }

  return await outDoc.save();
}
Enter fullscreen mode Exit fullscreen mode

4. Handling Non-Zero /MediaBox and /CropBox Origins

A subtle bug in many custom imposition scripts occurs when a source PDF page has a shifted /CropBox or /MediaBox origin—for example, /CropBox [36 36 576 756] where (x0, y0) = (36, 36) instead of (0, 0).

If a Form XObject's /Matrix attribute does not translate (-x0, -y0) back to the origin (0, 0), drawing the Form XObject at (drawX, drawY) shifts the visible content by (s * x0, s * y0), pushing the top-right edge of the page outside its cell boundary.

To normalize shifted crop boxes inside a raw Form XObject dictionary, set the Form XObject's internal /Matrix to:

/Matrix [ 1  0  0  1  -x0  -y0 ]
Enter fullscreen mode Exit fullscreen mode

This translates the lower-left corner of the active /CropBox to (0, 0) in Form space before the outer sheet cm matrix scales and positions the cell.

If you want to arrange 2, 4, 6, 9, or 16 PDF pages per sheet with custom margins, reading order, and cell borders directly in your browser while preserving 100% vector text sharpness, you can use the client-side PDF Multiple Pages Per Sheet tool, which executes the Form XObject imposition pipeline locally in memory.


5. Summary: Vector Form XObjects vs. Canvas Raster Tiling

Metric Canvas Raster Tiling (JPEG/PNG) Vector Form XObject Imposition (/Subtype /Form)
Text Sharpness at 600+ DPI Blurry / pixelated when zoomed or printed Infinite vector resolution
Selectable & Searchable Text Lost (flattened to pixels) 100% preserved (Ctrl+F works across all cells)
Output File Size (40-page deck) 15–40 MB Typically identical to original (< 1–2 MB)
Processing Speed in Browser Slow (requires rendering every page) Instant (< 150 ms for 50 pages)

Top comments (0)