From ad135ed75e7908e1a3add013603ff00e231db746 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sat, 24 Jan 2026 15:09:46 +0100 Subject: [PATCH] feat: edge-to-edge layout with inner gutters + B&W mode - Margins now only between panels (0.125" gutters), not on page edges - Content fills to outer edges of 8.5x11" page - Added --bw flag for high-contrast black & white output - Added --threshold option for B&W conversion (0-255) - Optimized for zero-ink thermal printing Co-Authored-By: Claude Opus 4.5 --- src/layout.mjs | 137 ++++++++++++++++++++++++++++++++++++++++-------- web/lib/zine.ts | 95 ++++++++++++++++++++++++++++----- 2 files changed, 198 insertions(+), 34 deletions(-) diff --git a/src/layout.mjs b/src/layout.mjs index 9cd78fa..a2b888c 100644 --- a/src/layout.mjs +++ b/src/layout.mjs @@ -6,7 +6,8 @@ * * Paper: Landscape 11" x 8.5" (US Letter rotated) * Layout: 4 columns x 2 rows - * Panel size: 7cm x 10.8cm (~2.76" x 4.25") + * Panel size: 2.75" x 4.25" (825 x 1275 pixels at 300 DPI) + * Content margin: 0.125" inside each panel (no margin on outer edge of aggregated zine) * * Page arrangement for proper folding: * Top row (upside down): 1, 8, 7, 6 @@ -28,10 +29,16 @@ const PAGE_HEIGHT = 2550; const COLS = 4; const ROWS = 2; -// Panel size: 7cm x 10.8cm at 300 DPI -// 7cm = 2.756" = 827 pixels, 10.8cm = 4.252" = 1275 pixels -const PANEL_WIDTH = Math.floor(PAGE_WIDTH / COLS); // 825 pixels (~7cm) -const PANEL_HEIGHT = Math.floor(PAGE_HEIGHT / ROWS); // 1275 pixels (~10.8cm) +// Panel size: 2.75" x 4.25" at 300 DPI +const PANEL_WIDTH = Math.floor(PAGE_WIDTH / COLS); // 825 pixels (2.75") +const PANEL_HEIGHT = Math.floor(PAGE_HEIGHT / ROWS); // 1275 pixels (4.25") + +// Margin: 0.125" inside each panel (around content, not on outer edges of aggregated zine) +const MARGIN = Math.round(0.125 * 300); // 38 pixels at 300 DPI + +// Content area within each panel (excluding margin) +const CONTENT_WIDTH = PANEL_WIDTH - (2 * MARGIN); // 749 pixels +const CONTENT_HEIGHT = PANEL_HEIGHT - (2 * MARGIN); // 1199 pixels // Page order for traditional mini-zine folding // Top row (rotated 180°): pages 1, 8, 7, 6 (left to right) @@ -61,13 +68,17 @@ function generatePrintFilename(zineName = 'mycrozine') { * @param {string} [options.outputPath] - Output file path (auto-generated with timestamp if not provided) * @param {string} [options.zineName] - Zine name for generated filename (default: 'mycrozine') * @param {string} [options.background] - Background color (default: '#ffffff') + * @param {boolean} [options.blackAndWhite] - Convert to high-contrast B&W for zero-ink printing (default: false) + * @param {number} [options.bwThreshold] - Threshold for B&W conversion 0-255 (default: 128) * @returns {Promise} - Path to generated print layout */ export async function createPrintLayout(options) { const { pages, zineName = 'mycrozine', - background = '#ffffff' + background = '#ffffff', + blackAndWhite = false, + bwThreshold = 128 } = options; // Use provided outputPath or generate timestamped filename @@ -82,22 +93,88 @@ export async function createPrintLayout(options) { // Ensure output directory exists await fs.mkdir(path.dirname(outputPath), { recursive: true }); - // Load and resize all pages to panel size (7cm x 10.8cm) - const resizedPages = await Promise.all( + // Load all pages as buffers, optionally converting to high-contrast B&W + const pageBuffers = await Promise.all( pages.map(async (pagePath) => { - return sharp(pagePath) - .resize(PANEL_WIDTH, PANEL_HEIGHT, { - fit: 'contain', - background - }) - .toBuffer(); + let image = sharp(pagePath); + + if (blackAndWhite) { + // Convert to grayscale then apply threshold for pure B&W + image = image + .grayscale() + .threshold(bwThreshold); + } + + return image.toBuffer(); + }) + ); + + // Helper to create a panel with position-aware margins + // Margins only on inner edges (between panels), not on outer page edges + const createPanel = async (pageBuffer, marginLeft, marginTop, marginRight, marginBottom) => { + const contentW = PANEL_WIDTH - marginLeft - marginRight; + const contentH = PANEL_HEIGHT - marginTop - marginBottom; + + const contentBuffer = await sharp(pageBuffer) + .resize(contentW, contentH, { + fit: 'contain', + background + }) + .toBuffer(); + + return sharp({ + create: { + width: PANEL_WIDTH, + height: PANEL_HEIGHT, + channels: 3, + background + } + }) + .composite([{ + input: contentBuffer, + left: marginLeft, + top: marginTop + }]) + .toBuffer(); + }; + + // Top row panels (will be rotated 180°) + // After rotation: top↔bottom, left↔right swap + // In final layout: row 0 is at top of page, so top edge = page edge (no margin), bottom edge = between rows (margin) + // Before rotation, we apply margins that will end up in the right place after rotation + const topRowPanels = await Promise.all( + TOP_ROW_PAGES.map(async (pageIndex, col) => { + // After 180° rotation, these become the margins in final layout: + // - marginLeft (before) → marginRight (after/final) → margin if not rightmost column + // - marginRight (before) → marginLeft (after/final) → no margin if leftmost column, else margin + // - marginTop (before) → marginBottom (after/final) → margin (between rows) + // - marginBottom (before) → marginTop (after/final) → no margin (page top edge) + const marginLeft = col < COLS - 1 ? MARGIN : 0; // becomes right margin: margin if has column to right + const marginRight = col > 0 ? MARGIN : 0; // becomes left margin: margin if has column to left + const marginTop = MARGIN; // becomes bottom margin: always (between rows) + const marginBottom = 0; // becomes top margin: never (page edge) + return createPanel(pageBuffers[pageIndex], marginLeft, marginTop, marginRight, marginBottom); + }) + ); + + // Bottom row panels (normal orientation) + // In final layout: row 1 is at bottom of page + // - Top edge = between rows (margin) + // - Bottom edge = page edge (no margin) + const bottomRowPanels = await Promise.all( + BOTTOM_ROW_PAGES.map(async (pageIndex, col) => { + const marginLeft = col > 0 ? MARGIN : 0; // margin if has column to left + const marginRight = col < COLS - 1 ? MARGIN : 0; // margin if has column to right + const marginTop = MARGIN; // between rows + const marginBottom = 0; // page bottom edge + return createPanel(pageBuffers[pageIndex], marginLeft, marginTop, marginRight, marginBottom); }) ); // Rotate top row pages 180 degrees (they need to be upside down) - const rotatedTopPages = await Promise.all( - TOP_ROW_PAGES.map(async (pageIndex) => { - return sharp(resizedPages[pageIndex]) + const rotatedTopPanels = await Promise.all( + topRowPanels.map(async (panelBuffer) => { + return sharp(panelBuffer) .rotate(180) .toBuffer(); }) @@ -109,7 +186,7 @@ export async function createPrintLayout(options) { // Top row: pages 1, 8, 7, 6 (rotated 180°) for (let col = 0; col < COLS; col++) { compositeImages.push({ - input: rotatedTopPages[col], + input: rotatedTopPanels[col], left: col * PANEL_WIDTH, top: 0 }); @@ -117,9 +194,8 @@ export async function createPrintLayout(options) { // Bottom row: pages 2, 3, 4, 5 (normal orientation) for (let col = 0; col < COLS; col++) { - const pageIndex = BOTTOM_ROW_PAGES[col]; compositeImages.push({ - input: resizedPages[pageIndex], + input: bottomRowPanels[col], left: col * PANEL_WIDTH, top: PANEL_HEIGHT }); @@ -140,8 +216,12 @@ export async function createPrintLayout(options) { console.log(`Created print layout: ${outputPath}`); console.log(` Dimensions: ${PAGE_WIDTH}x${PAGE_HEIGHT} pixels (11"x8.5" landscape @ 300 DPI)`); - console.log(` Panel size: ${PANEL_WIDTH}x${PANEL_HEIGHT} pixels (7cm x 10.8cm)`); + console.log(` Panel size: ${PANEL_WIDTH}x${PANEL_HEIGHT} pixels (2.75" x 4.25")`); + console.log(` Margins: 0.125" between panels, edge-to-edge on page borders`); console.log(` Layout: Top row [1↺, 8↺, 7↺, 6↺] | Bottom row [2, 3, 4, 5]`); + if (blackAndWhite) { + console.log(` Mode: High-contrast B&W (threshold: ${bwThreshold}) - optimized for zero-ink printing`); + } return outputPath; } @@ -165,11 +245,14 @@ Usage: Options: --output, -o Output file path (default: auto-generated with timestamp) --name, -n Zine name for auto-generated filename (default: mycrozine) + --bw Convert to high-contrast black & white (zero-ink printing) + --threshold <0-255> B&W threshold (default: 128, higher = more white) --help, -h Show this help message Examples: node layout.mjs p1.png p2.png p3.png p4.png p5.png p6.png p7.png p8.png node layout.mjs p*.png --name "undernet" + node layout.mjs p*.png --bw --threshold 140 node layout.mjs p*.png --output my_zine_print.png `); process.exit(0); @@ -179,12 +262,22 @@ Examples: let pages = []; let outputPath = null; let zineName = 'mycrozine'; + let blackAndWhite = false; + let bwThreshold = 128; for (let i = 0; i < args.length; i++) { if (args[i] === '--output' || args[i] === '-o') { outputPath = args[++i]; } else if (args[i] === '--name' || args[i] === '-n') { zineName = args[++i]; + } else if (args[i] === '--bw') { + blackAndWhite = true; + } else if (args[i] === '--threshold') { + bwThreshold = parseInt(args[++i], 10); + if (isNaN(bwThreshold) || bwThreshold < 0 || bwThreshold > 255) { + console.error('Error: --threshold must be a number between 0 and 255'); + process.exit(1); + } } else if (!args[i].startsWith('-')) { pages.push(args[i]); } @@ -222,7 +315,7 @@ Examples: process.exit(1); } - const options = { pages, zineName }; + const options = { pages, zineName, blackAndWhite, bwThreshold }; if (outputPath) { options.outputPath = outputPath; } diff --git a/web/lib/zine.ts b/web/lib/zine.ts index ac6e388..8a74bd4 100644 --- a/web/lib/zine.ts +++ b/web/lib/zine.ts @@ -57,10 +57,29 @@ export async function createPrintLayoutForZine( // Alternative: Create print layout directly with Sharp if library doesn't support buffer return import sharp from "sharp"; +export interface DirectPrintOptions { + zineId: string; + zineName?: string; + blackAndWhite?: boolean; + bwThreshold?: number; +} + export async function createPrintLayoutDirect( - zineId: string, + options: DirectPrintOptions | string, zineName: string = "mycrozine" ): Promise<{ filepath: string; buffer: Buffer }> { + // Support both old signature (string) and new (options object) + const opts: DirectPrintOptions = typeof options === "string" + ? { zineId: options, zineName } + : options; + + const { + zineId, + zineName: name = "mycrozine", + blackAndWhite = false, + bwThreshold = 128, + } = opts; + const pagePaths = await getAllPagePaths(zineId); if (pagePaths.length !== 8) { @@ -70,8 +89,13 @@ export async function createPrintLayoutDirect( // Print layout dimensions (300 DPI, 11" x 8.5") const PRINT_WIDTH = 3300; const PRINT_HEIGHT = 2550; - const PANEL_WIDTH = 825; - const PANEL_HEIGHT = 1275; + const PANEL_WIDTH = 825; // 2.75" at 300 DPI + const PANEL_HEIGHT = 1275; // 4.25" at 300 DPI + + // Margin: 0.125" inside each panel (around content, not on outer edges) + const MARGIN = Math.round(0.125 * 300); // 38 pixels at 300 DPI + const CONTENT_WIDTH = PANEL_WIDTH - 2 * MARGIN; // 749 pixels + const CONTENT_HEIGHT = PANEL_HEIGHT - 2 * MARGIN; // 1199 pixels // Page arrangement for proper folding: // Top row (rotated 180°): P1, P8, P7, P6 @@ -102,24 +126,71 @@ export async function createPrintLayoutDirect( // Prepare composites const composites: sharp.OverlayOptions[] = []; + // Helper to create a panel with position-aware margins + const createPanel = async ( + pageBuffer: Buffer, + marginLeft: number, + marginTop: number, + marginRight: number, + marginBottom: number + ) => { + const contentW = PANEL_WIDTH - marginLeft - marginRight; + const contentH = PANEL_HEIGHT - marginTop - marginBottom; + + let imageProcessor = sharp(pageBuffer); + + // Apply B&W conversion if enabled + if (blackAndWhite) { + imageProcessor = imageProcessor.grayscale().threshold(bwThreshold); + } + + const contentData = await imageProcessor + .resize(contentW, contentH, { + fit: "cover", + position: "center", + }) + .toBuffer(); + + return sharp({ + create: { + width: PANEL_WIDTH, + height: PANEL_HEIGHT, + channels: 4, + background: { r: 255, g: 255, b: 255, alpha: 1 }, + }, + }) + .composite([{ input: contentData, left: marginLeft, top: marginTop }]) + .toBuffer(); + }; + for (const { page, col, row, rotate } of pageArrangement) { const pageBuffer = await readFileAsBuffer(pagePaths[page - 1]); - // Resize page to panel size, maintaining aspect ratio - let processedPage = sharp(pageBuffer).resize(PANEL_WIDTH, PANEL_HEIGHT, { - fit: "cover", - position: "center", - }); + let marginLeft: number, marginTop: number, marginRight: number, marginBottom: number; + + if (rotate === 180) { + // Top row (rotated 180°) - margins flip after rotation + marginLeft = col < 3 ? MARGIN : 0; // becomes right margin after rotation + marginRight = col > 0 ? MARGIN : 0; // becomes left margin after rotation + marginTop = MARGIN; // becomes bottom margin (between rows) + marginBottom = 0; // becomes top margin (page edge) + } else { + // Bottom row (normal orientation) + marginLeft = col > 0 ? MARGIN : 0; + marginRight = col < 3 ? MARGIN : 0; + marginTop = MARGIN; // between rows + marginBottom = 0; // page edge + } + + let panelData = await createPanel(pageBuffer, marginLeft, marginTop, marginRight, marginBottom); // Rotate if needed if (rotate !== 0) { - processedPage = processedPage.rotate(rotate); + panelData = await sharp(panelData).rotate(rotate).toBuffer(); } - const pageData = await processedPage.toBuffer(); - composites.push({ - input: pageData, + input: panelData, left: col * PANEL_WIDTH, top: row * PANEL_HEIGHT, });