Make Riso postcards from p5.js

Rina Chen’s living notebook on digital craft and design.


Context

When making art using Riso, I like to print any design on a postcard size, since it’s an easy format to host a design (unlike a packaging), it can be one off (as compared to zine), it is semi-functional, and it always felt more accessible than a poster or an art piece that may require a frame or even a space on the wall of some sort.

So when I printed this poster for my program’s Open Show, I was also tempting to create a postcard version of it.

Poster (Cute illustration by Melanie)

![[WhatsApp Image 2026-01-15 at 11.18.11 PM.jpeg]] More than 50 posters were put around canvas, thanks to classmates and professors spreading them.

Postcards!

![[WhatsApp Image 2026-01-15 at 11.19.14 PM.jpeg]] ![[WhatsApp Image 2026-01-15 at 11.19.14 PM (1).jpeg]] ![[WhatsApp Image 2026-01-15 at 11.19.14 PM (3).jpeg]] ![[WhatsApp Image 2026-01-15 at 11.19.14 PM (2).jpeg]]

While the posters are gone, it’s a pleasure to see that many students have kept their postcards displayed on their desks.

How to Prepare a Postcard File (Front)

I prepare data in p5.js, since my art work is mostly coded, sometimes using p5.riso library. And p5.js helps to freely change the paper size and resolution for my original artwork using buffer. Some useful concepts and functions to include:

DPI PPI

How do I make an artwork at 300 DPI?

The ratio of the conversion from PPI to DPI is usually 1:1. This means that if an image is ten inches wide by ten inches high at 300 DPI, the pixel dimensions must be 3000px x 3000px. To obtain the proper pixel dimenbjecrsions (e.g., 3000 x 3000 px) to offer 300 DPI, you must multiply the print size (width and height in inches) by 300.

Print Size (Inches) Pixel Dimensions
4 x 6” 1200px x 1800px
8 x 10” 2400px x 3000px
12 x 18 3600px x 5400px

Change Canvas Preview Size

Set up a canvas for preview, keeping aspect ratio

  let viewW = min(windowWidth, PX_W / 2);
  let viewH = (viewW * PX_H) / PX_W;
  createCanvas(viewW, viewH);
  pixelDensity(CANVAS_PIXEL_DENSITY);

OR, the canvas can be of smaller resolution, and create high-res buffers for export only. But the design can be easily replaced if the scale/random seed is not managed properly. It’s worth trying when the rendering is too slow.

Using Buffer

Using p5.js buffer methods to draw design on a separate buffer, and then render on to the final postcard format (canvas).

  • ✅ createGraphics() – create buffers
  • ✅ buffer.background() – fill background
  • ✅ buffer.fill()buffer.stroke() – set colors
  • ✅ buffer.text()buffer.circle()buffer.rect() – draw shapes
  • ✅ buffer.image() – place postcards on letter
  • ✅ buffer.save() – export PNG

(buffer. can also be expressed as g.)

Convert Buffer to JPEG

Using piexifjs library, a tool that lets you add “information labels” to JPEG files. So that DPI and canvas size set in p5.js will be correctly reflected in JPEG and Photoshop etc.

Turning to jpeg is good for later manipulation in Illustrator. There is a way to export in PDF [[Riso_3D|as well]].

Steps:

┌─────────────────────────────────────────────────┐
│ 1. p5.Graphics Buffer (1800 × 1200 pixels)      │
└─────────────┬───────────────────────────────────┘
              │
              ↓
┌────────────��────────────────────────────────────┐
│ 2. canvas.toDataURL("image/jpeg", 0.95)         │
│    → JPEG with default 96 DPI                   │
└─────────────┬───────────────────────────────────┘
              │
              ↓
┌─────────────────────────────────────────────────┐
│ 3. setJPEGDPIInDataURL_JFIF(dataURL, 300)       │
│    → Edit JFIF bytes: 96 DPI → 300 DPI         │
└─────────────┬───────────────────────────────────┘
              │
              ↓
┌─────────────────────────────────────────────────┐
│ 4. piexif.insert(exifBytes, dataURL)            │
│    → Add EXIF metadata: 300 DPI, inches         │
└─────────────┬───────────────────────────────────┘
              │
              ↓
┌─────────────────────────────────────────────────┐
│ 5. downloadDataURL(filename, dataURL)           │
│    → Download JPEG with 300 DPI in BOTH places  │
└─────────────────────────────────────────────────┘
saveGraphicsAsJPEGWithDPI(buffer, "postcard.jpg", 300, 0.95);
//                        ↑       ↑               ↑    ↑
//                        |       |               |    Quality (95%)
//                        |       |               DPI to embed
//                        |       Filename
//                        Buffer to save

// This function:
// 1. Converts buffer → JPEG
// 2. Adds 300 DPI to JFIF header (manual binary edit)
// 3. Adds 300 DPI to EXIF metadata (piexifjs library)
// 4. Downloads the file

// Result: JPEG that prints at exactly 6" × 4"!

Example

![[treering_letter.svg]] ![[treering.png]]

Code:

index.html

Add

<script src="https://unpkg.com/piexifjs"></script>

To manipulates EXIF metadata in JPEG images Used in saveGraphicsAsJPEGWithDPI() function to embed 300 DPI info

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>p5.js Postcard Generator</title>
    
    <!-- p5.js Core -->
    <script src="https://cdn.jsdelivr.net/npm/p5@1.11.10/lib/p5.js"></script>
    
    <!-- EXIF Metadata Library (for DPI embedding) -->
    <script src="https://unpkg.com/piexifjs"></script>
    
    <!-- Styles -->
    <link rel="stylesheet" type="text/css" href="style.css">
  </head>
  <body>
    <main></main>
    <script src="sketch.js"></script>
  </body>
</html>

sketch.js

Create 3 buffer layers, one line, another filled, the third is combined:

let filledLayer = createGraphics(PX_W, PX_H);
let linedLayer = createGraphics(PX_W, PX_H);
//      ↑ These are buffers (p5.Graphics objects)

Then pass buffers to functions

// Pass the buffer as the first parameter
drawFilledRings(filledLayer, cx, cy, r);
//              ↑ This buffer becomes 'g' inside the function

drawLinedRings(linedLayer, cx, cy, r);
//             ��� This buffer becomes 'g' inside the function

Then use g inside functions

function drawFilledRings(g, cx, cy, radius) {
  //                     ↑ 'g' is whatever buffer you passed in
  
  g.push();           // Same as filledLayer.push()
  g.noStroke();       // Same as filledLayer.noStroke()
  g.fill(180, 160);   // Same as filledLayer.fill(180, 160)
  g.beginShape();     // Same as filledLayer.beginShape()
  g.curveVertex(x, y);// Same as filledLayer.curveVertex(x, y)
  g.endShape(CLOSE);  // Same as filledLayer.endShape(CLOSE)
  g.pop();            // Same as filledLayer.pop()
}
// In setup():
let filledLayer = createGraphics(1800, 1200);
let linedLayer = createGraphics(1800, 1200);

// Call function:
drawFilledRings(filledLayer, 100, 100, 50);
//              ↓
//              This becomes 'g'

// Inside function:
function drawFilledRings(g, cx, cy, radius) {
  //                     ↑
  //                     g = filledLayer
  
  g.fill(255); // Actually drawing on filledLayer
}

The entire code (needs more cleaning after Copilot):

// Letter size at 300dpi
const DPI = 300;
const INCHES_W = 6;
const INCHES_H = 4;
const PX_W = INCHES_W * DPI;
const PX_H = INCHES_H * DPI;
const CANVAS_PIXEL_DENSITY = 1;
const BUFF_PIXEL_DENSITY = 1;

let hiResGraphics;   // composite preview/export (PNG)
let filledLayer;     // filled rings only (transparent background)
let linedLayer;      // lined rings only (transparent background)

function setup() {
  // Set up a canvas for preview, keeping aspect ratio
  let viewW = min(windowWidth, PX_W / 2);
  let viewH = (viewW * PX_H) / PX_W;
  createCanvas(viewW, viewH);
  pixelDensity(CANVAS_PIXEL_DENSITY);

  // Create the high-res graphics buffers
  hiResGraphics = createGraphics(PX_W, PX_H);
  hiResGraphics.pixelDensity(BUFF_PIXEL_DENSITY);
  hiResGraphics.clear(); // transparent composite

  filledLayer = createGraphics(PX_W, PX_H);
  filledLayer.pixelDensity(BUFF_PIXEL_DENSITY);
  filledLayer.clear();

  linedLayer = createGraphics(PX_W, PX_H);
  linedLayer.pixelDensity(BUFF_PIXEL_DENSITY);
  linedLayer.clear();

  // Draw a 3 (cols) x 2 (rows) grid of motifs (each one different: no seeding)
  const GRID_COLS = 3;
  const GRID_ROWS = 2;
  const cellW = PX_W / GRID_COLS;
  const cellH = PX_H / GRID_ROWS;
  const baseRadius = min(cellW, cellH) / 2 - 70;

  for (let row = 0; row < GRID_ROWS; row++) {
    for (let col = 0; col < GRID_COLS; col++) {
      const cx = col * cellW + cellW / 2;
      const cy = row * cellH + cellH / 2;
      const r = baseRadius * random(0.65, 1); // slight variation

      // Draw onto separate layers
      drawFilledRings(filledLayer, cx, cy, r);
      drawLinedRings(linedLayer, cx, cy, r);
    }
  }

  // Compose layers into the composite for preview/export
  hiResGraphics.image(filledLayer, 0, 0);
  hiResGraphics.image(linedLayer, 0, 0);

  // Render once
  noLoop();
}

function draw() {
  // Show a scaled-down preview of the hi-res composite image
  background(220);
  image(hiResGraphics, 0, 0, width, height);
}

// Save the image at full resolution when 's' is pressed
function keyPressed() {
  if (key === "s" || key === "S") {
    // Save combined composite as PNG (preserves transparency)
    hiResGraphics.save("postcard-6x4.png");

    // Save filled rings layer as JPEG on white background with 300 DPI in JFIF + EXIF
    let tmpFilled = createGraphics(PX_W, PX_H);
    tmpFilled.pixelDensity(BUFF_PIXEL_DENSITY);
    tmpFilled.background(255); // white background for JPEG
    tmpFilled.image(filledLayer, 0, 0);
    saveGraphicsAsJPEGWithDPI(tmpFilled, "postcard-6x4-filled-rings.jpg", 300, 0.95);

    // Save lined rings layer as JPEG on white background with 300 DPI in JFIF + EXIF
    let tmpLined = createGraphics(PX_W, PX_H);
    tmpLined.pixelDensity(BUFF_PIXEL_DENSITY);
    tmpLined.background(255); // white background for JPEG
    tmpLined.image(linedLayer, 0, 0);
    saveGraphicsAsJPEGWithDPI(tmpLined, "postcard-6x4-lined-rings.jpg", 300, 0.95);
  }
}

// Draw only the filled noisy rings (concentric, each randomly smaller)
function drawFilledRings(g, cx, cy, radius) {
  const colorRings = 24;
  const noiseScale = 50;
  const resolution = 0.002;
  const numPoints = 500;

  g.push();
  g.noStroke();
  let r = radius * random(0.98, 1.02); // start from the outer radius
  for (let c = 0; c < colorRings && r > 5; c++) {
    // Add transparency so overlaps show through
    if (c % 3 === 1) {
      g.fill(180, 160); // gray with alpha
    } else {
      g.fill(220, 160);
    }

    g.beginShape();
    for (let a = 0; a < TAU; a += TAU / numPoints) {
      const x = cx + r * cos(a);
      const y = cy + r * sin(a);
      const n = map(
        noise(x * resolution, y * resolution),
        0,
        1,
        -noiseScale,
        noiseScale
      );
      g.curveVertex(x + n, y + n);
    }
    g.endShape(CLOSE);

    // Randomly shrink radius for this ring (monotonic decrease)
    const ringR = r * random(0.86, 0.97);

    g.beginShape();
    for (let a = 0; a < TAU; a += TAU / numPoints) {
      const x = cx + ringR * cos(a);
      const y = cy + ringR * sin(a);
      const n = map(
        noise(x * resolution, y * resolution),
        0,
        1,
        -noiseScale,
        noiseScale
      );
      g.curveVertex(x + n, y + n);
    }
    g.endShape(CLOSE);

    // Next ring starts from the current ring's radius
    r = ringR;
  }
  g.pop();
}

// Draw only the stroked noisy rings
function drawLinedRings(g, cx, cy, radius) {
  const noiseScale = 50;
  const resolution = 0.002;
  const numPoints = 500;
  const numRings = 80;

  g.push();
  g.stroke(0);
  g.strokeWeight(1);
  g.noFill();
  for (let rr = 0; rr < radius; rr += radius / numRings) {
    if (random() > 0.45 + rr / 1000 - noise(rr - 0.001, rr)) {
      g.beginShape();
      for (let a = 0; a <= TAU; a += TAU / numPoints) {
        const ox = cx + rr * cos(a);
        const oy = cy + rr * sin(a);
        const q = map(
          noise(ox * resolution, oy * resolution),
          0,
          1,
          -noiseScale,
          noiseScale
        );
        g.curveVertex(ox + q, oy + q);

        if (random() > 0.75 - 0.25 * sin(a) * cos(a)) {
          g.endShape();
          g.beginShape();
        }
      }
      g.endShape();
    }
  }
  g.pop();
}

/**
 * Save a p5.Graphics as JPEG with explicit 300 DPI in BOTH:
 * - JFIF header (what Windows Explorer reads)
 * - EXIF XResolution/YResolution (what many editors read)
 *
 * Optionally include piexifjs on your page:
 * <script src="https://unpkg.com/piexifjs"></script>
 */
function saveGraphicsAsJPEGWithDPI(g, filename, dpi = 300, quality = 0.95) {
  const canvas = g.elt || g.canvas || g;
  // Encode to JPEG
  let dataURL = canvas.toDataURL("image/jpeg", quality);

  // Patch JFIF density to DPI so Windows Explorer shows 300 DPI
  dataURL = setJPEGDPIInDataURL_JFIF(dataURL, dpi);

  // Also add EXIF X/YResolution if piexif is available (nice-to-have)
  if (typeof piexif !== "undefined") {
    const exifObj = { "0th": {} };
    exifObj["0th"][piexif.ImageIFD.XResolution] = [dpi, 1];
    exifObj["0th"][piexif.ImageIFD.YResolution] = [dpi, 1];
    exifObj["0th"][piexif.ImageIFD.ResolutionUnit] = 2; // inches
    const exifBytes = piexif.dump(exifObj);
    dataURL = piexif.insert(exifBytes, dataURL);
  }

  downloadDataURL(filename, dataURL);
}

// Ensure JFIF APP0 has Units=1 (dpi) and X/Ydensity=dpi; insert if missing
function setJPEGDPIInDataURL_JFIF(dataURL, dpi) {
  const bytes = base64ToBytes(dataURL.split(",")[1]);
  if (bytes[0] !== 0xFF || bytes[1] !== 0xD8) {
    // Not a JPEG; return original
    return dataURL;
  }

  // Find existing APP0 JFIF
  let i = 2;
  let foundJFIF = false;
  while (i + 4 < bytes.length && bytes[i] === 0xFF) {
    const marker = bytes[i + 1];
    if (marker === 0xDA) break; // SOS - stop scanning headers
    const segLen = (bytes[i + 2] << 8) | bytes[i + 3];
    const segStart = i + 4;
    const segEnd = segStart + segLen;

    if (marker === 0xE0 && segStart + 5 <= bytes.length) {
      // APP0 - check for "JFIF\0"
      if (
        segStart + 5 <= bytes.length &&
        bytes[segStart] === 0x4A && // J
        bytes[segStart + 1] === 0x46 && // F
        bytes[segStart + 2] === 0x49 && // I
        bytes[segStart + 3] === 0x46 && // F
        bytes[segStart + 4] === 0x00
      ) {
        // Payload layout after length:
        // "JFIF\0"(5), ver(2), units(1), Xden(2), Yden(2), Xthumb(1), Ythumb(1) ...
        const payload = segStart;
        const unitsOffset = payload + 7;
        const xDenOffset = payload + 8;
        const yDenOffset = payload + 10;

        bytes[unitsOffset] = 0x01; // 1 = dpi
        bytes[xDenOffset] = (dpi >> 8) & 0xff;
        bytes[xDenOffset + 1] = dpi & 0xff;
        bytes[yDenOffset] = (dpi >> 8) & 0xff;
        bytes[yDenOffset + 1] = dpi & 0xff;

        foundJFIF = true;
        break;
      }
    }

    i = segEnd;
  }

  // If no JFIF found, insert one right after SOI (FFD8)
  let outBytes = bytes;
  if (!foundJFIF) {
    const app0 = new Uint8Array(20);
    app0[0] = 0xff; app0[1] = 0xe0; // APP0
    app0[2] = 0x00; app0[3] = 0x10; // length = 16 bytes of payload
    // "JFIF\0"
    app0[4] = 0x4a; app0[5] = 0x46; app0[6] = 0x49; app0[7] = 0x46; app0[8] = 0x00;
    // version 1.01
    app0[9] = 0x01; app0[10] = 0x01;
    // units = 1 (dpi)
    app0[11] = 0x01;
    // Xdensity
    app0[12] = (dpi >> 8) & 0xff; app0[13] = dpi & 0xff;
    // Ydensity
    app0[14] = (dpi >> 8) & 0xff; app0[15] = dpi & 0xff;
    // Xthumbnail, Ythumbnail
    app0[16] = 0x00; app0[17] = 0x00;
    // Note: payload length 16 matches standard JFIF header without thumbnail; the last two
    // bytes of the 20-byte block are marker+len; the 16 payload bytes end at offset 19.

    outBytes = new Uint8Array(bytes.length + app0.length);
    // SOI
    outBytes.set(bytes.subarray(0, 2), 0);
    // APP0 JFIF
    outBytes.set(app0, 2);
    // rest of original JPEG
    outBytes.set(bytes.subarray(2), 2 + app0.length);
  }

  const outBase64 = bytesToBase64(outBytes);
  return "data:image/jpeg;base64," + outBase64;
}

// Utilities
function base64ToBytes(b64) {
  const bin = atob(b64);
  const len = bin.length;
  const bytes = new Uint8Array(len);
  for (let i = 0; i < len; i++) bytes[i] = bin.charCodeAt(i);
  return bytes;
}
function bytesToBase64(bytes) {
  let bin = "";
  const chunk = 0x8000;
  for (let i = 0; i < bytes.length; i += chunk) {
    bin += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk));
  }
  return btoa(bin);
}

// Trigger a download from a data URL
function downloadDataURL(name, url) {
  const a = document.createElement("a");
  a.href = url;
  a.download = name;
  document.body.appendChild(a);
  a.click();
  a.remove();
}

Detailed Explanation (by Copilot)

The notes are quite helpful.

What This Code Does

Code

1. Creates 3 BUFFERS (offscreen graphics)
   ├─ hiResGraphics: Combined preview
   ├─ filledLayer: Gray filled rings only
   └─ linedLayer: Black line rings only

2. Draws 3×2 grid of organic ring motifs
   ├─ Each motif centered in grid cell
   ├─ Filled rings → filledLayer
   └─ Lined rings → linedLayer

3. Composites layers for preview
   └─ hiResGraphics = filledLayer + linedLayer

4. Exports 3 print-ready files (press 'S')
   ├─ postcard-6x4.png (composite)
   ├─ postcard-6x4-filled-rings.jpg (300 DPI)
   └─ postcard-6x4-lined-rings.jpg (300 DPI)

Architecture Breakdown

1. Print Dimensions (300 DPI)

JavaScript

const DPI = 300;
const INCHES_W = 6;   // 6 inches wide
const INCHES_H = 4;   // 4 inches tall
const PX_W = INCHES_W * DPI;  // 1800 pixels
const PX_H = INCHES_H * DPI;  // 1200 pixels

Why 300 DPI? Professional print standard for sharp output.


2. Buffer Setup (Layer Separation)

JavaScript

// THREE separate buffers for color separation
hiResGraphics = createGraphics(PX_W, PX_H);  // Composite
filledLayer = createGraphics(PX_W, PX_H);    // Layer 1: Fills
linedLayer = createGraphics(PX_W, PX_H);     // Layer 2: Lines

Color Separation = Printing Layers

  • Each layer can be a different ink color (Risograph, screen printing)
  • Or different printing passes (embossing, foil stamping)

3. Grid Layout System

JavaScript

const GRID_COLS = 3;  // 3 columns
const GRID_ROWS = 2;  // 2 rows
const cellW = PX_W / GRID_COLS;  // 600px per cell
const cellH = PX_H / GRID_ROWS;  // 600px per cell

// Calculate center of each cell
for (let row = 0; row < GRID_ROWS; row++) {
  for (let col = 0; col < GRID_COLS; col++) {
    const cx = col * cellW + cellW / 2;  // Cell center X
    const cy = row * cellH + cellH / 2;  // Cell center Y
    const r = baseRadius * random(0.65, 1); // Vary size
    
    // Draw on SEPARATE layers
    drawFilledRings(filledLayer, cx, cy, r);
    drawLinedRings(linedLayer, cx, cy, r);
  }
}

Result: 6 unique motifs (no seeding = different every time)


4. Drawing Functions (Generative Art)

A. Filled Rings (Organic Blobs)

JavaScript

function drawFilledRings(g, cx, cy, radius) {
  const colorRings = 24;      // Number of concentric rings
  const noiseScale = 50;      // How wavy the edges are
  const resolution = 0.002;   // Perlin noise detail
  const numPoints = 500;      // Smoothness of curves
  
  let r = radius;
  for (let c = 0; c < colorRings && r > 5; c++) {
    // Alternate gray shades with transparency
    if (c % 3 === 1) {
      g.fill(180, 160); // Medium gray, 160 alpha
    } else {
      g.fill(220, 160); // Light gray, 160 alpha
    }
    
    g.beginShape();
    for (let a = 0; a < TAU; a += TAU / numPoints) {
      // Position on circle
      const x = cx + r * cos(a);
      const y = cy + r * sin(a);
      
      // Add Perlin noise distortion
      const n = map(
        noise(x * resolution, y * resolution),
        0, 1,
        -noiseScale, noiseScale  // ±50 pixel wobble
      );
      
      g.curveVertex(x + n, y + n);  // Smooth curve point
    }
    g.endShape(CLOSE);
    
    // Shrink radius randomly for next ring
    r = r * random(0.86, 0.97);  // 86-97% of previous size
  }
}

Effect: Organic, hand-drawn looking concentric shapes


B. Lined Rings (Broken Circles)

JavaScript

function drawLinedRings(g, cx, cy, radius) {
  const numRings = 80;  // Many rings from center to edge
  
  g.stroke(0);        // Black lines
  g.strokeWeight(1);  // Thin lines
  g.noFill();
  
  for (let rr = 0; rr < radius; rr += radius / numRings) {
    // Randomly skip rings (adds variation)
    if (random() > 0.45 + rr / 1000 - noise(rr - 0.001, rr)) {
      g.beginShape();
      
      for (let a = 0; a <= TAU; a += TAU / numPoints) {
        const ox = cx + rr * cos(a);
        const oy = cy + rr * sin(a);
        
        // Add noise distortion
        const q = map(
          noise(ox * resolution, oy * resolution),
          0, 1,
          -noiseScale, noiseScale
        );
        
        g.curveVertex(ox + q, oy + q);
        
        // Randomly break the line (creates gaps)
        if (random() > 0.75 - 0.25 * sin(a) * cos(a)) {
          g.endShape();
          g.beginShape();  // Start new segment
        }
      }
      g.endShape();
    }
  }
}

Effect: Sketchy, hand-drawn concentric circles with gaps


5. Layer Compositing

JavaScript

// Stack layers (like Photoshop layers)
hiResGraphics.image(filledLayer, 0, 0);  // Bottom layer
hiResGraphics.image(linedLayer, 0, 0);   // Top layer

6. Export with DPI Metadata

JavaScript

function keyPressed() {
  if (key === "s" || key === "S") {
    // 1. PNG composite (preserves transparency)
    hiResGraphics.save("postcard-6x4.png");
    
    // 2. Filled layer as JPEG with white background
    let tmpFilled = createGraphics(PX_W, PX_H);
    tmpFilled.background(255);  // White (JPEGs can't be transparent)
    tmpFilled.image(filledLayer, 0, 0);
    saveGraphicsAsJPEGWithDPI(tmpFilled, "postcard-6x4-filled-rings.jpg", 300, 0.95);
    
    // 3. Lined layer as JPEG with white background
    let tmpLined = createGraphics(PX_W, PX_H);
    tmpLined.background(255);
    tmpLined.image(linedLayer, 0, 0);
    saveGraphicsAsJPEGWithDPI(tmpLined, "postcard-6x4-lined-rings.jpg", 300, 0.95);
  }
}

Why temporary buffers?

  • Original layers have transparent backgrounds
  • JPEGs don’t support transparency
  • Create temp buffer → add white background → export

7. DPI Embedding

JavaScript

function saveGraphicsAsJPEGWithDPI(g, filename, dpi = 300, quality = 0.95) {
  // 1. Convert buffer to JPEG data URL
  let dataURL = canvas.toDataURL("image/jpeg", quality);
  
  // 2. Manually edit JPEG binary to set DPI in JFIF header
  dataURL = setJPEGDPIInDataURL_JFIF(dataURL, dpi);
  
  // 3. Add DPI to EXIF metadata (if piexifjs is loaded)
  if (typeof piexif !== "undefined") {
    const exifObj = { "0th": {} };
    exifObj["0th"][piexif.ImageIFD.XResolution] = [dpi, 1];  // X DPI
    exifObj["0th"][piexif.ImageIFD.YResolution] = [dpi, 1];  // Y DPI
    exifObj["0th"][piexif.ImageIFD.ResolutionUnit] = 2;      // 2 = inches
    const exifBytes = piexif.dump(exifObj);
    dataURL = piexif.insert(exifBytes, dataURL);
  }
  
  downloadDataURL(filename, dataURL);
}

Two metadata locations:

  • JFIF header (Windows reads this)
  • EXIF tags (Photoshop/printers read this)

How EXIF Data Structure (Simplified) is modified:

JavaScript

{
  "0th": {           // Main image info
    XResolution: [300, 1],      // 300 DPI horizontal
    YResolution: [300, 1],      // 300 DPI vertical
    ResolutionUnit: 2           // Units = inches
  },
  "Exif": {},        // Camera settings (we don't use)
  "GPS": {}          // Location data (we don't use)
}

Appendix: Using Low Resolution Canvas

I tried to keep the canvas resolution low, and only render high resolution when saving the image, but the result is not satisfying as you can see the fine details/lines are mostly lost. ![[postcard-composite-300dpi.png]] ![[postcard-filled-300dpi.jpg]]

// ========================================
// CONFIGURATION
// ========================================
const DPI = 300;
const INCHES_W = 6;
const INCHES_H = 4;

// Dual resolution setup
const SCALE_FACTOR = 3;  // Print is 3× larger
const SCREEN_W = 600;
const SCREEN_H = 400;
const PRINT_W = SCREEN_W * SCALE_FACTOR;  // 1800
const PRINT_H = SCREEN_H * SCALE_FACTOR;  // 1200

// Design seed (for consistent random generation)
const DESIGN_SEED = 42;

let screenComposite, screenFilled, screenLined;

// ========================================
// SETUP
// ========================================
function setup() {
  // Canvas for preview
  let viewW = min(windowWidth, SCREEN_W);
  let viewH = (viewW * SCREEN_H) / SCREEN_W;
  createCanvas(viewW, viewH);
  pixelDensity(1);

  // Create SCREEN-RESOLUTION buffers
  screenComposite = createGraphics(SCREEN_W, SCREEN_H);
  screenFilled = createGraphics(SCREEN_W, SCREEN_H);
  screenLined = createGraphics(SCREEN_W, SCREEN_H);

  // Design at screen resolution
  designPostcard(screenFilled, screenLined, 1);  // scale = 1

  // Composite
  screenComposite.clear();
  screenComposite.image(screenFilled, 0, 0);
  screenComposite.image(screenLined, 0, 0);

  noLoop();
  
  console.log("Screen buffers: " + SCREEN_W + "×" + SCREEN_H);
  console.log("Memory: ~" + ((SCREEN_W * SCREEN_H * 4 * 3) / 1024 / 1024).toFixed(1) + " MB");
}

// ========================================
// PREVIEW
// ========================================
function draw() {
  background(220);
  image(screenComposite, 0, 0, width, height);
  
  // Show info
  fill(0);
  noStroke();
  textSize(10);
  text("Preview (press 'S' to export at 300 DPI)", 10, height - 10);
}

// ========================================
// DESIGN FUNCTION (Scale-Independent)
// ========================================
function designPostcard(filledLayer, linedLayer, scale) {
  // Grid setup
  const GRID_COLS = 3;
  const GRID_ROWS = 2;
  const cellW = (SCREEN_W * scale) / GRID_COLS;
  const cellH = (SCREEN_H * scale) / GRID_ROWS;
  const baseRadius = min(cellW, cellH) / 2 - (70 * scale);

  // Use seed for consistent randomness
  randomSeed(DESIGN_SEED);
  noiseSeed(DESIGN_SEED);

  for (let row = 0; row < GRID_ROWS; row++) {
    for (let col = 0; col < GRID_COLS; col++) {
      const cx = col * cellW + cellW / 2;
      const cy = row * cellH + cellH / 2;
      const r = baseRadius * random(0.65, 1);

      drawFilledRings(filledLayer, cx, cy, r, scale);
      drawLinedRings(linedLayer, cx, cy, r, scale);
    }
  }
}

// ========================================
// DRAWING FUNCTIONS (Scale-Aware)
// ========================================
function drawFilledRings(g, cx, cy, radius, scale) {
  const colorRings = 24;
  const noiseScale = 50 * scale;
  const resolution = 0.002 / scale;
  const numPoints = 500;

  g.push();
  g.noStroke();
  let r = radius * random(0.98, 1.02);
  
  for (let c = 0; c < colorRings && r > 5 * scale; c++) {
    if (c % 3 === 1) {
      g.fill(180, 160);
    } else {
      g.fill(220, 160);
    }

    g.beginShape();
    for (let a = 0; a < TAU; a += TAU / numPoints) {
      const x = cx + r * cos(a);
      const y = cy + r * sin(a);
      const n = map(
        noise(x * resolution, y * resolution),
        0, 1,
        -noiseScale, noiseScale
      );
      g.curveVertex(x + n, y + n);
    }
    g.endShape(CLOSE);

    const ringR = r * random(0.86, 0.97);

    g.beginShape();
    for (let a = 0; a < TAU; a += TAU / numPoints) {
      const x = cx + ringR * cos(a);
      const y = cy + ringR * sin(a);
      const n = map(
        noise(x * resolution, y * resolution),
        0, 1,
        -noiseScale, noiseScale
      );
      g.curveVertex(x + n, y + n);
    }
    g.endShape(CLOSE);

    r = ringR;
  }
  g.pop();
}

function drawLinedRings(g, cx, cy, radius, scale) {
  const noiseScale = 50 * scale;
  const resolution = 0.002 / scale;
  const numPoints = 500;
  const numRings = 80;

  g.push();
  g.stroke(0);
  g.strokeWeight(1 * scale);
  g.noFill();
  
  for (let rr = 0; rr < radius; rr += radius / numRings) {
    if (random() > 0.45 + rr / (1000 * scale) - noise(rr - 0.001, rr)) {
      g.beginShape();
      for (let a = 0; a <= TAU; a += TAU / numPoints) {
        const ox = cx + rr * cos(a);
        const oy = cy + rr * sin(a);
        const q = map(
          noise(ox * resolution, oy * resolution),
          0, 1,
          -noiseScale, noiseScale
        );
        g.curveVertex(ox + q, oy + q);

        if (random() > 0.75 - 0.25 * sin(a) * cos(a)) {
          g.endShape();
          g.beginShape();
        }
      }
      g.endShape();
    }
  }
  g.pop();
}

// ========================================
// EXPORT (Create High-Res on Demand)
// ========================================
function keyPressed() {
  if (key === "s" || key === "S") {
    console.log("Generating high-resolution images...");
    
    // Create PRINT-RESOLUTION buffers (temporarily!)
    let printComposite = createGraphics(PRINT_W, PRINT_H);
    let printFilled = createGraphics(PRINT_W, PRINT_H);
    let printLined = createGraphics(PRINT_W, PRINT_H);
    
    printComposite.clear();
    printFilled.clear();
    printLined.clear();

    // Re-render at PRINT scale
    designPostcard(printFilled, printLined, SCALE_FACTOR);  // scale = 3

    // Composite
    printComposite.image(printFilled, 0, 0);
    printComposite.image(printLined, 0, 0);

    // Export
    printComposite.save("postcard-composite-300dpi.png");
    
    let tmpFilled = createGraphics(PRINT_W, PRINT_H);
    tmpFilled.background(255);
    tmpFilled.image(printFilled, 0, 0);
    saveGraphicsAsJPEGWithDPI(tmpFilled, "postcard-filled-300dpi.jpg", 300, 0.95);

    let tmpLined = createGraphics(PRINT_W, PRINT_H);
    tmpLined.background(255);
    tmpLined.image(printLined, 0, 0);
    saveGraphicsAsJPEGWithDPI(tmpLined, "postcard-lined-300dpi.jpg", 300, 0.95);

    console.log("Exported at " + PRINT_W + "×" + PRINT_H + " (300 DPI)");
    alert("Export complete!");
  }
}

// ========================================
// DPI EMBEDDING FUNCTIONS
// ========================================

function saveGraphicsAsJPEGWithDPI(g, filename, dpi = 300, quality = 0.95) {
  const canvas = g.elt || g.canvas || g;
  // Encode to JPEG
  let dataURL = canvas.toDataURL("image/jpeg", quality);

  // Patch JFIF density to DPI so Windows Explorer shows 300 DPI
  dataURL = setJPEGDPIInDataURL_JFIF(dataURL, dpi);

  // Also add EXIF X/YResolution if piexif is available (nice-to-have)
  if (typeof piexif !== "undefined") {
    const exifObj = { "0th": {} };
    exifObj["0th"][piexif.ImageIFD.XResolution] = [dpi, 1];
    exifObj["0th"][piexif.ImageIFD.YResolution] = [dpi, 1];
    exifObj["0th"][piexif.ImageIFD.ResolutionUnit] = 2; // inches
    const exifBytes = piexif.dump(exifObj);
    dataURL = piexif.insert(exifBytes, dataURL);
  }

  downloadDataURL(filename, dataURL);
}

// Ensure JFIF APP0 has Units=1 (dpi) and X/Ydensity=dpi; insert if missing
function setJPEGDPIInDataURL_JFIF(dataURL, dpi) {
  const bytes = base64ToBytes(dataURL.split(",")[1]);
  if (bytes[0] !== 0xFF || bytes[1] !== 0xD8) {
    // Not a JPEG; return original
    return dataURL;
  }

  // Find existing APP0 JFIF
  let i = 2;
  let foundJFIF = false;
  while (i + 4 < bytes.length && bytes[i] === 0xFF) {
    const marker = bytes[i + 1];
    if (marker === 0xDA) break; // SOS - stop scanning headers
    const segLen = (bytes[i + 2] << 8) | bytes[i + 3];
    const segStart = i + 4;
    const segEnd = segStart + segLen;

    if (marker === 0xE0 && segStart + 5 <= bytes.length) {
      // APP0 - check for "JFIF\0"
      if (
        segStart + 5 <= bytes.length &&
        bytes[segStart] === 0x4A && // J
        bytes[segStart + 1] === 0x46 && // F
        bytes[segStart + 2] === 0x49 && // I
        bytes[segStart + 3] === 0x46 && // F
        bytes[segStart + 4] === 0x00
      ) {
        // Payload layout after length:
        // "JFIF\0"(5), ver(2), units(1), Xden(2), Yden(2), Xthumb(1), Ythumb(1) ...
        const payload = segStart;
        const unitsOffset = payload + 7;
        const xDenOffset = payload + 8;
        const yDenOffset = payload + 10;

        bytes[unitsOffset] = 0x01; // 1 = dpi
        bytes[xDenOffset] = (dpi >> 8) & 0xff;
        bytes[xDenOffset + 1] = dpi & 0xff;
        bytes[yDenOffset] = (dpi >> 8) & 0xff;
        bytes[yDenOffset + 1] = dpi & 0xff;

        foundJFIF = true;
        break;
      }
    }

    i = segEnd;
  }

  // If no JFIF found, insert one right after SOI (FFD8)
  let outBytes = bytes;
  if (!foundJFIF) {
    const app0 = new Uint8Array(20);
    app0[0] = 0xff; app0[1] = 0xe0; // APP0
    app0[2] = 0x00; app0[3] = 0x10; // length = 16 bytes of payload
    // "JFIF\0"
    app0[4] = 0x4a; app0[5] = 0x46; app0[6] = 0x49; app0[7] = 0x46; app0[8] = 0x00;
    // version 1.01
    app0[9] = 0x01; app0[10] = 0x01;
    // units = 1 (dpi)
    app0[11] = 0x01;
    // Xdensity
    app0[12] = (dpi >> 8) & 0xff; app0[13] = dpi & 0xff;
    // Ydensity
    app0[14] = (dpi >> 8) & 0xff; app0[15] = dpi & 0xff;
    // Xthumbnail, Ythumbnail
    app0[16] = 0x00; app0[17] = 0x00;

    outBytes = new Uint8Array(bytes.length + app0.length);
    // SOI
    outBytes.set(bytes.subarray(0, 2), 0);
    // APP0 JFIF
    outBytes.set(app0, 2);
    // rest of original JPEG
    outBytes.set(bytes.subarray(2), 2 + app0.length);
  }

  const outBase64 = bytesToBase64(outBytes);
  return "data:image/jpeg;base64," + outBase64;
}

// Utilities
function base64ToBytes(b64) {
  const bin = atob(b64);
  const len = bin.length;
  const bytes = new Uint8Array(len);
  for (let i = 0; i < len; i++) bytes[i] = bin.charCodeAt(i);
  return bytes;
}

function bytesToBase64(bytes) {
  let bin = "";
  const chunk = 0x8000;
  for (let i = 0; i < bytes.length; i += chunk) {
    bin += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk));
  }
  return btoa(bin);
}

// Trigger a download from a data URL
function downloadDataURL(name, url) {
  const a = document.createElement("a");
  a.href = url;
  a.download = name;
  document.body.appendChild(a);
  a.click();
  a.remove();
}

Important changes:

1. Scale Factor System

JavaScript

const SCALE_FACTOR = 3;
const SCREEN_W = 600;
const PRINT_W = SCREEN_W * SCALE_FACTOR;  // 1800

2. Scale-Independent Design Function

JavaScript

// Works at ANY scale!
function designPostcard(filledLayer, linedLayer, scale) {
  // All sizes multiplied by scale
  const cellW = (SCREEN_W * scale) / GRID_COLS;
  const baseRadius = min(cellW, cellH) / 2 - (70 * scale);
}

3. Consistent Random Sequence

JavaScript

randomSeed(DESIGN_SEED);  // ← Same sequence every time
noiseSeed(DESIGN_SEED);

4. Scale-Aware Parameters

JavaScript

const noiseScale = 50 * scale;         // Bigger at high-res
const resolution = 0.002 / scale;      // Finer at high-res
g.strokeWeight(1 * scale);             // Thicker at high-res