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:
parent
9960a861ee
commit
ad135ed75e
137
src/layout.mjs
137
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<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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue