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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-01-24 15:09:46 +01:00
parent 9960a861ee
commit ad135ed75e
2 changed files with 198 additions and 34 deletions

View File

@ -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<string>} - 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 <path> Output file path (default: auto-generated with timestamp)
--name, -n <name> 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;
}

View File

@ -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,
});