feat: IMAP mail sync with thread-to-contact matching (PRD §6.1)
Real IMAP connection via imapflow library: - Connects to IMAP server (IMAPFlow, TLS on port 993) - Fetches recent messages (configurable limit, default 20) - Extracts: from, fromEmail, subject, date, snippet (200 chars) - Thread-to-contact matching (PRD §6.1): 1. Match sender email against persons table 2. If no person match, extract domain and match against company.website 3. Returns matchedCompany + matchedPerson for each email - Audit-logged (action: crm.mailsync:imap) - Zod validated (host, port, user, password, folder, limit) - 502 on IMAP connection failure with detail - 400 on validation failure Tested: invalid creds → 502 'IMAP connection failed', missing host → 400
This commit is contained in:
1 parent
7acc0c5451
commit
e45b68bb8b
1089 files changed
+260614
No files matched your search
Executable
+599
@@ -0,0 +1,599 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* cover_validate.js — Cover page overlap detection via Playwright rendering
|
||||
*
|
||||
* Two detection passes:
|
||||
* Pass 1: Text vs decorative line overlap (original)
|
||||
* Pass 2: Layer 3 text vs text zone overflow (NEW)
|
||||
*
|
||||
* Pass 2 detects foreground text blocks that overlap each other on the
|
||||
* same z-layer (Layer 3). It excludes Layer 1 elements (watermarks,
|
||||
* sidebar decorative text) by checking opacity ≤ 0.10 or z-index ≤ 1.
|
||||
*
|
||||
* Usage:
|
||||
* node cover_validate.js cover.html
|
||||
* node cover_validate.js cover.html --width 210mm --height 297mm
|
||||
* node cover_validate.js cover.html --min-gap 30 # custom min gap in px (default: auto = 5% of width)
|
||||
* node cover_validate.js cover.html --no-text-overlap # skip Pass 2 (text vs text)
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 = no overlap issues found
|
||||
* 1 = overlap detected (prints details to stderr)
|
||||
* 2 = script error (missing file, browser launch failure, etc.)
|
||||
*
|
||||
* This script is ONLY for cover pages. Do NOT use it on:
|
||||
* - Multi-page documents (use html2pdf-next.js pre-render checks)
|
||||
* - Posters (use html2poster.js which handles overflow automatically)
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// ── Playwright import ──
|
||||
|
||||
let playwright;
|
||||
try {
|
||||
playwright = require('playwright');
|
||||
} catch {
|
||||
try {
|
||||
playwright = require('playwright-core');
|
||||
} catch {
|
||||
console.error('✗ Neither playwright nor playwright-core is installed.');
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Chromium resolution (shared logic with html2poster.js) ──
|
||||
|
||||
function resolveChromium(chromiumObj) {
|
||||
let exe;
|
||||
try { exe = chromiumObj.executablePath(); } catch (_) { exe = null; }
|
||||
if (exe && fs.existsSync(exe)) return { status: 'ok', executablePath: exe };
|
||||
|
||||
const candidates = [
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
'/usr/bin/chromium-browser', '/usr/bin/chromium', '/usr/bin/google-chrome',
|
||||
];
|
||||
if (process.env.PLAYWRIGHT_CHROMIUM_PATH) candidates.unshift(process.env.PLAYWRIGHT_CHROMIUM_PATH);
|
||||
|
||||
for (const c of candidates) {
|
||||
if (fs.existsSync(c)) return { status: 'fallback', executablePath: c };
|
||||
}
|
||||
return { status: 'missing', executablePath: exe || '' };
|
||||
}
|
||||
|
||||
// ── CLI parsing ──
|
||||
|
||||
function parseArgs(argv) {
|
||||
const tokens = argv.slice(2);
|
||||
let input = null, width = '210mm', height = '297mm', minGap = null, noTextOverlap = false;
|
||||
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
const t = tokens[i];
|
||||
if (t === '--width') width = tokens[++i];
|
||||
else if (t === '--height') height = tokens[++i];
|
||||
else if (t === '--min-gap') minGap = parseFloat(tokens[++i]);
|
||||
else if (t === '--no-text-overlap') noTextOverlap = true;
|
||||
else if (t === '--help' || t === '-h') {
|
||||
console.log(`Usage: node cover_validate.js <cover.html> [options]
|
||||
|
||||
Options:
|
||||
--width <val> Page width (default: 210mm)
|
||||
--height <val> Page height (default: 297mm)
|
||||
--min-gap <px> Minimum gap between text and decorative lines (default: 5% of width)
|
||||
--no-text-overlap Skip Pass 2 (text vs text zone overlap detection)
|
||||
--help Show this help`);
|
||||
process.exit(0);
|
||||
} else if (!t.startsWith('-') && !input) {
|
||||
input = t;
|
||||
}
|
||||
}
|
||||
return { input, width, height, minGap, noTextOverlap };
|
||||
}
|
||||
|
||||
// ── Convert CSS dimension string to px for viewport ──
|
||||
|
||||
function dimToPx(dim) {
|
||||
if (!dim) return null;
|
||||
const s = String(dim).trim();
|
||||
const num = parseFloat(s);
|
||||
if (s.endsWith('mm')) return Math.round(num * 3.7795); // 1mm ≈ 3.7795px at 96dpi
|
||||
if (s.endsWith('cm')) return Math.round(num * 37.795);
|
||||
if (s.endsWith('in')) return Math.round(num * 96);
|
||||
if (s.endsWith('px') || !isNaN(num)) return Math.round(num);
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Decorative line detection heuristics ──
|
||||
// A decorative line is an element that:
|
||||
// - Is very thin in one dimension (height ≤ 5px or width ≤ 5px)
|
||||
// - OR is an <hr> element
|
||||
// - OR has a large aspect ratio (> 10:1 or < 1:10)
|
||||
// - AND is not inside a text element
|
||||
|
||||
const DECORATIVE_LINE_DETECTION = `
|
||||
(function detectOverlaps(minGapPx) {
|
||||
// Collect all elements
|
||||
const allElements = document.querySelectorAll('*');
|
||||
|
||||
const textElements = [];
|
||||
const lineElements = [];
|
||||
|
||||
// Classify elements
|
||||
for (const el of allElements) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width === 0 || rect.height === 0) continue;
|
||||
|
||||
const tag = el.tagName.toLowerCase();
|
||||
const style = getComputedStyle(el);
|
||||
|
||||
// Skip invisible elements
|
||||
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') continue;
|
||||
|
||||
// Detect decorative lines
|
||||
const isHR = tag === 'hr';
|
||||
const isThinH = rect.height <= 5 && rect.width > 20; // thin horizontal line
|
||||
const isThinV = rect.width <= 5 && rect.height > 20; // thin vertical line
|
||||
const aspectH = rect.width / rect.height;
|
||||
const aspectV = rect.height / rect.width;
|
||||
const isWideRatio = aspectH > 15 && rect.height <= 8; // very wide, very thin
|
||||
const isTallRatio = aspectV > 15 && rect.width <= 8; // very tall, very thin
|
||||
|
||||
// Check if element has only border (no text content, no background image)
|
||||
const hasOnlyBorder = (
|
||||
el.textContent.trim() === '' &&
|
||||
style.backgroundImage === 'none' &&
|
||||
(style.borderTopWidth !== '0px' || style.borderBottomWidth !== '0px' ||
|
||||
style.borderLeftWidth !== '0px' || style.borderRightWidth !== '0px')
|
||||
);
|
||||
const isBorderLine = hasOnlyBorder && (rect.height <= 8 || rect.width <= 8);
|
||||
|
||||
if (isHR || isThinH || isThinV || isWideRatio || isTallRatio || isBorderLine) {
|
||||
lineElements.push({
|
||||
tag: tag,
|
||||
class: el.className || '',
|
||||
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
||||
type: isThinH || isWideRatio ? 'horizontal' : (isThinV || isTallRatio ? 'vertical' : (rect.width >= rect.height ? 'horizontal' : 'vertical')),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Detect text elements (has direct text content or is a heading/paragraph)
|
||||
const textTags = ['h1','h2','h3','h4','h5','h6','p','span','a','li','td','th','label','summary'];
|
||||
const hasDirectText = Array.from(el.childNodes).some(n => n.nodeType === 3 && n.textContent.trim());
|
||||
|
||||
if (textTags.includes(tag) || hasDirectText) {
|
||||
// Skip if this is inside a decorative element
|
||||
if (rect.height < 3) continue;
|
||||
|
||||
// Determine layer: Layer 1 = background decoration (low opacity, low z-index)
|
||||
// Layer 3 = foreground text (normal opacity, higher z-index)
|
||||
const opacity = parseFloat(style.opacity);
|
||||
const zIndex = parseInt(style.zIndex, 10) || 0;
|
||||
const parentOpacity = el.parentElement ? parseFloat(getComputedStyle(el.parentElement).opacity) : 1;
|
||||
const effectiveOpacity = opacity * parentOpacity;
|
||||
const isPointerNone = style.pointerEvents === 'none';
|
||||
|
||||
// Parse color alpha from rgba(r, g, b, a) using string split (regex unreliable in evaluate)
|
||||
let colorAlpha = 1;
|
||||
const colorStr = style.color;
|
||||
if (colorStr.indexOf('rgba') === 0) {
|
||||
const parts = colorStr.replace('rgba(', '').replace(')', '').split(',');
|
||||
if (parts.length >= 4) {
|
||||
const a = parseFloat(parts[3].trim());
|
||||
if (!isNaN(a)) colorAlpha = a;
|
||||
}
|
||||
}
|
||||
const visualOpacity = effectiveOpacity * colorAlpha;
|
||||
|
||||
// Layer 1 heuristic: very low visual opacity (CSS opacity * color alpha)
|
||||
// These are watermarks, sidebar decoration text, etc.
|
||||
const isLayer1 = (
|
||||
visualOpacity <= 0.10 ||
|
||||
(zIndex <= 1 && visualOpacity <= 0.15) ||
|
||||
(isPointerNone && visualOpacity <= 0.20)
|
||||
);
|
||||
|
||||
textElements.push({
|
||||
tag: tag,
|
||||
class: el.className || '',
|
||||
text: el.textContent.trim().substring(0, 60),
|
||||
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
||||
layer: isLayer1 ? 1 : 3,
|
||||
opacity: Math.round(visualOpacity * 1000) / 1000,
|
||||
zIndex: zIndex,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// De-duplicate: if a parent and child text element both overlap the same line,
|
||||
// only keep the more specific (smaller) one to avoid duplicate reports.
|
||||
// Sort text elements by area (smallest first) so we can skip parents.
|
||||
textElements.sort((a, b) => (a.rect.width * a.rect.height) - (b.rect.width * b.rect.height));
|
||||
|
||||
// Check overlaps between text elements and line elements
|
||||
const overlaps = [];
|
||||
const reportedPairs = new Set(); // track "lineIndex:textContent" to deduplicate
|
||||
|
||||
for (const text of textElements) {
|
||||
for (const line of lineElements) {
|
||||
const tr = text.rect;
|
||||
const lr = line.rect;
|
||||
|
||||
if (line.type === 'horizontal') {
|
||||
// Check vertical overlap/proximity
|
||||
const textTop = tr.y;
|
||||
const textBottom = tr.y + tr.height;
|
||||
const lineTop = lr.y;
|
||||
const lineBottom = lr.y + lr.height;
|
||||
|
||||
// Check horizontal overlap (they must share some X range)
|
||||
const xOverlap = !(tr.x + tr.width < lr.x || lr.x + lr.width < tr.x);
|
||||
if (!xOverlap) continue;
|
||||
|
||||
// Calculate vertical gap
|
||||
let vGap;
|
||||
if (lineTop >= textBottom) {
|
||||
vGap = lineTop - textBottom; // line is below text
|
||||
} else if (textTop >= lineBottom) {
|
||||
vGap = textTop - lineBottom; // line is above text
|
||||
} else {
|
||||
vGap = 0; // overlapping
|
||||
}
|
||||
|
||||
if (vGap < minGapPx) {
|
||||
// De-dup: same line region, only report the smallest (most specific) text element
|
||||
const lineKey = 'h:' + Math.round(lr.x) + ',' + Math.round(lr.y);
|
||||
if (!reportedPairs.has(lineKey)) {
|
||||
reportedPairs.add(lineKey);
|
||||
overlaps.push({
|
||||
text: text.text,
|
||||
textTag: text.tag,
|
||||
textClass: text.class,
|
||||
textRect: tr,
|
||||
lineTag: line.tag,
|
||||
lineClass: line.class,
|
||||
lineRect: lr,
|
||||
lineType: line.type,
|
||||
gap: Math.round(vGap * 10) / 10,
|
||||
required: minGapPx,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (line.type === 'vertical') {
|
||||
// Check horizontal overlap/proximity
|
||||
const textLeft = tr.x;
|
||||
const textRight = tr.x + tr.width;
|
||||
const lineLeft = lr.x;
|
||||
const lineRight = lr.x + lr.width;
|
||||
|
||||
// Check vertical overlap (they must share some Y range)
|
||||
const yOverlap = !(tr.y + tr.height < lr.y || lr.y + lr.height < tr.y);
|
||||
if (!yOverlap) continue;
|
||||
|
||||
// Calculate horizontal gap
|
||||
let hGap;
|
||||
if (lineLeft >= textRight) {
|
||||
hGap = lineLeft - textRight;
|
||||
} else if (textLeft >= lineRight) {
|
||||
hGap = textLeft - lineRight;
|
||||
} else {
|
||||
hGap = 0;
|
||||
}
|
||||
|
||||
if (hGap < minGapPx) {
|
||||
const lineKey = 'v:' + Math.round(lr.x) + ',' + Math.round(lr.y);
|
||||
if (!reportedPairs.has(lineKey)) {
|
||||
reportedPairs.add(lineKey);
|
||||
overlaps.push({
|
||||
text: text.text,
|
||||
textTag: text.tag,
|
||||
textClass: text.class,
|
||||
textRect: tr,
|
||||
lineTag: line.tag,
|
||||
lineClass: line.class,
|
||||
lineRect: lr,
|
||||
lineType: line.type,
|
||||
gap: Math.round(hGap * 10) / 10,
|
||||
required: minGapPx,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
textElements: textElements.length,
|
||||
textElements_by_layer: {
|
||||
layer3: textElements.filter(t => t.layer === 3).length,
|
||||
layer1: textElements.filter(t => t.layer === 1).length,
|
||||
},
|
||||
lineElements: lineElements.length,
|
||||
overlaps: overlaps,
|
||||
};
|
||||
})
|
||||
`;
|
||||
|
||||
// ── Pass 2: Layer 3 text vs text zone overlap detection ──
|
||||
// Only checks foreground text (Layer 3) against each other.
|
||||
// Skips Layer 1 elements (watermarks, sidebar decoration) to avoid false positives.
|
||||
// A "zone overflow" is when two Layer 3 text blocks' bounding boxes overlap
|
||||
// or are closer than minGapPx on the Y axis (both must share X range).
|
||||
|
||||
const TEXT_ZONE_DETECTION = `
|
||||
(function detectTextZoneOverlaps(minGapPx) {
|
||||
const allElements = document.querySelectorAll('*');
|
||||
const layer3Blocks = [];
|
||||
const layer1Count = { value: 0 };
|
||||
|
||||
const textTags = ['h1','h2','h3','h4','h5','h6','p','span','a','li','td','th','label','summary','div'];
|
||||
|
||||
for (const el of allElements) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width === 0 || rect.height === 0) continue;
|
||||
|
||||
const tag = el.tagName.toLowerCase();
|
||||
const style = getComputedStyle(el);
|
||||
|
||||
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') continue;
|
||||
|
||||
// Must have direct text content
|
||||
const hasDirectText = Array.from(el.childNodes).some(n => n.nodeType === 3 && n.textContent.trim());
|
||||
if (!textTags.includes(tag) && !hasDirectText) continue;
|
||||
if (!el.textContent.trim()) continue;
|
||||
if (rect.height < 5) continue;
|
||||
|
||||
// Compute effective opacity (walk up to find multiplicative opacity)
|
||||
const opacity = parseFloat(style.opacity);
|
||||
let parentEl = el.parentElement;
|
||||
let parentOpacity = 1;
|
||||
while (parentEl) {
|
||||
const po = parseFloat(getComputedStyle(parentEl).opacity);
|
||||
if (!isNaN(po)) parentOpacity *= po;
|
||||
parentEl = parentEl.parentElement;
|
||||
}
|
||||
const effectiveOpacity = opacity * parentOpacity;
|
||||
|
||||
// Parse color alpha from rgba(r, g, b, a) using string split
|
||||
let colorAlpha = 1;
|
||||
const colorStr = style.color;
|
||||
if (colorStr.indexOf('rgba') === 0) {
|
||||
const parts = colorStr.replace('rgba(', '').replace(')', '').split(',');
|
||||
if (parts.length >= 4) {
|
||||
const a = parseFloat(parts[3].trim());
|
||||
if (!isNaN(a)) colorAlpha = a;
|
||||
}
|
||||
}
|
||||
const visualOpacity = effectiveOpacity * colorAlpha;
|
||||
|
||||
const zIndex = parseInt(style.zIndex, 10) || 0;
|
||||
const isPointerNone = style.pointerEvents === 'none';
|
||||
|
||||
// Layer 1 detection: watermark / sidebar decoration
|
||||
// Uses visualOpacity (CSS opacity * color alpha) to catch both patterns:
|
||||
// - opacity: 0.03 (CSS property)
|
||||
// - color: rgba(0,0,0,0.03) (color alpha)
|
||||
const isLayer1 = (
|
||||
visualOpacity <= 0.10 ||
|
||||
(zIndex <= 1 && visualOpacity <= 0.15) ||
|
||||
(isPointerNone && visualOpacity <= 0.20)
|
||||
);
|
||||
|
||||
if (isLayer1) {
|
||||
layer1Count.value++;
|
||||
continue; // Skip Layer 1 entirely
|
||||
}
|
||||
|
||||
// Also skip decorative lines (thin elements) — they're handled by Pass 1
|
||||
const isThin = (rect.height <= 5 && rect.width > 20) || (rect.width <= 5 && rect.height > 20);
|
||||
if (isThin) continue;
|
||||
|
||||
layer3Blocks.push({
|
||||
tag: tag,
|
||||
class: el.className || '',
|
||||
text: el.textContent.trim().substring(0, 60),
|
||||
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
||||
opacity: Math.round(effectiveOpacity * 100) / 100,
|
||||
zIndex: zIndex,
|
||||
area: rect.width * rect.height,
|
||||
});
|
||||
}
|
||||
|
||||
// De-duplicate: keep only the most specific (smallest area) text block
|
||||
// when a parent and child both contain the same text region.
|
||||
// Sort by area ascending, then for each block, remove any larger block
|
||||
// that fully contains it.
|
||||
layer3Blocks.sort((a, b) => a.area - b.area);
|
||||
|
||||
const filtered = [];
|
||||
for (const block of layer3Blocks) {
|
||||
// Check if this block is a parent container of an already-added smaller block
|
||||
const isParent = filtered.some(existing => {
|
||||
return (
|
||||
block.rect.x <= existing.rect.x &&
|
||||
block.rect.y <= existing.rect.y &&
|
||||
block.rect.x + block.rect.width >= existing.rect.x + existing.rect.width &&
|
||||
block.rect.y + block.rect.height >= existing.rect.y + existing.rect.height
|
||||
);
|
||||
});
|
||||
if (!isParent) {
|
||||
filtered.push(block);
|
||||
}
|
||||
}
|
||||
|
||||
// Check pairwise overlaps among Layer 3 blocks
|
||||
const overlaps = [];
|
||||
const reported = new Set();
|
||||
|
||||
for (let i = 0; i < filtered.length; i++) {
|
||||
for (let j = i + 1; j < filtered.length; j++) {
|
||||
const a = filtered[i];
|
||||
const b = filtered[j];
|
||||
const ar = a.rect;
|
||||
const br = b.rect;
|
||||
|
||||
// Check X-axis overlap (they must share horizontal range)
|
||||
const xOverlap = !(ar.x + ar.width < br.x || br.x + br.width < ar.x);
|
||||
if (!xOverlap) continue;
|
||||
|
||||
// Calculate Y-axis gap
|
||||
const aTop = ar.y;
|
||||
const aBottom = ar.y + ar.height;
|
||||
const bTop = br.y;
|
||||
const bBottom = br.y + br.height;
|
||||
|
||||
let yGap;
|
||||
if (bTop >= aBottom) {
|
||||
yGap = bTop - aBottom;
|
||||
} else if (aTop >= bBottom) {
|
||||
yGap = aTop - bBottom;
|
||||
} else {
|
||||
yGap = -Math.min(aBottom - bTop, bBottom - aTop); // negative = overlapping
|
||||
}
|
||||
|
||||
// Only flag if blocks actually overlap (yGap < 0) or touch (yGap == 0)
|
||||
// We use 0 as threshold, NOT minGapPx — because tight spacing between
|
||||
// Kicker and Hero Title is intentional design (gap may be only 8-15px).
|
||||
// Zone overflow means actual pixel overlap, not "too close".
|
||||
if (yGap < 0) {
|
||||
const key = a.text.substring(0, 20) + ':' + b.text.substring(0, 20);
|
||||
if (reported.has(key)) continue;
|
||||
reported.add(key);
|
||||
|
||||
overlaps.push({
|
||||
a: { tag: a.tag, class: a.class, text: a.text, rect: ar, opacity: a.opacity },
|
||||
b: { tag: b.tag, class: b.class, text: b.text, rect: br, opacity: b.opacity },
|
||||
axis: 'vertical',
|
||||
overlap: Math.round(Math.abs(yGap)),
|
||||
required: 0, // actual pixel overlap, not gap threshold
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
layer3Count: filtered.length,
|
||||
layer1Count: layer1Count.value,
|
||||
overlaps: overlaps,
|
||||
};
|
||||
})
|
||||
`;
|
||||
|
||||
// ── Main ──
|
||||
|
||||
async function main() {
|
||||
const { input, width, height, minGap, noTextOverlap } = parseArgs(process.argv);
|
||||
|
||||
if (!input) {
|
||||
console.error('✗ No input file specified. Usage: node cover_validate.js cover.html');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const absIn = path.resolve(input);
|
||||
if (!fs.existsSync(absIn)) {
|
||||
console.error(`✗ File not found: ${absIn}`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const widthPx = dimToPx(width) || 794; // A4 width in px
|
||||
const heightPx = dimToPx(height) || 1123; // A4 height in px
|
||||
const gap = minGap || Math.round(widthPx * 0.05); // 1U = 5% of page width
|
||||
|
||||
console.log(`🔍 cover_validate — Cover overlap detection`);
|
||||
console.log(` Input: ${absIn}`);
|
||||
console.log(` Page: ${widthPx}×${heightPx}px`);
|
||||
console.log(` Min gap: ${gap}px (1U)`);
|
||||
console.log(` Pass 2 (text↔text): ${noTextOverlap ? 'DISABLED' : 'enabled'}`);
|
||||
|
||||
const { chromium } = playwright;
|
||||
const bInfo = resolveChromium(chromium);
|
||||
|
||||
if (bInfo.status === 'missing') {
|
||||
console.error('✗ No Chromium found. Install via: npx playwright install chromium');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
let browser;
|
||||
try {
|
||||
const opts = { headless: true };
|
||||
if (bInfo.status === 'fallback') opts.executablePath = bInfo.executablePath;
|
||||
browser = await chromium.launch(opts);
|
||||
} catch (err) {
|
||||
console.error(`✗ Browser launch failed: ${err.message}`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: widthPx, height: heightPx } });
|
||||
await page.goto('file://' + absIn, { waitUntil: 'networkidle' });
|
||||
console.log(` ✓ HTML loaded`);
|
||||
|
||||
// Wait for fonts
|
||||
const fontsLoaded = await page.evaluate(() =>
|
||||
document.fonts.ready.then(() => document.fonts.size)
|
||||
).catch(() => 0);
|
||||
console.log(` ✓ Fonts: ${fontsLoaded} loaded`);
|
||||
|
||||
// Run Pass 1: text vs decorative line overlap detection
|
||||
const result = await page.evaluate(`(${DECORATIVE_LINE_DETECTION})(${gap})`);
|
||||
|
||||
const layer3Text = result.textElements_by_layer ? result.textElements_by_layer.layer3 : '?';
|
||||
const layer1Text = result.textElements_by_layer ? result.textElements_by_layer.layer1 : '?';
|
||||
console.log(` ✓ Found ${result.textElements} text elements (L3: ${layer3Text}, L1: ${layer1Text}), ${result.lineElements} decorative lines`);
|
||||
|
||||
let allIssues = [];
|
||||
|
||||
// Pass 1 results
|
||||
if (result.overlaps.length > 0) {
|
||||
console.error(`\n ❌ Pass 1: Found ${result.overlaps.length} text-line overlap(s):`);
|
||||
for (const o of result.overlaps) {
|
||||
const direction = o.lineType === 'vertical' ? 'horizontal' : 'vertical';
|
||||
console.error(`\n ERROR [text↔line]: ${direction} gap = ${o.gap}px (required ≥ ${o.required}px)`);
|
||||
console.error(` Text: <${o.textTag}> "${o.text}" @ y=${Math.round(o.textRect.y)}-${Math.round(o.textRect.y + o.textRect.height)}`);
|
||||
console.error(` Line: <${o.lineTag}${o.lineClass ? '.' + o.lineClass.split(' ')[0] : ''}> [${o.lineType}] @ y=${Math.round(o.lineRect.y)}-${Math.round(o.lineRect.y + o.lineRect.height)}`);
|
||||
console.error(` Fix: Move the decorative line at least ${Math.ceil(o.required - o.gap)}px away from the text.`);
|
||||
}
|
||||
allIssues.push(...result.overlaps.map(o => ({ pass: 1, ...o })));
|
||||
} else {
|
||||
console.log(` ✅ Pass 1: No text-line overlaps`);
|
||||
}
|
||||
|
||||
// Pass 2: Layer 3 text vs text zone overlap detection
|
||||
if (!noTextOverlap) {
|
||||
const textOverlaps = await page.evaluate(`(${TEXT_ZONE_DETECTION})(${gap})`);
|
||||
|
||||
if (textOverlaps.overlaps.length > 0) {
|
||||
console.error(`\n ❌ Pass 2: Found ${textOverlaps.overlaps.length} text-text zone overflow(s):`);
|
||||
for (const o of textOverlaps.overlaps) {
|
||||
console.error(`\n ERROR [text↔text]: ${o.axis} overlap = ${o.overlap}px (gap should be ≥ ${o.required}px)`);
|
||||
console.error(` Block A: <${o.a.tag}.${o.a.class.split(' ')[0] || '?'}> "${o.a.text}" @ y=${Math.round(o.a.rect.y)}-${Math.round(o.a.rect.y + o.a.rect.height)}`);
|
||||
console.error(` Block B: <${o.b.tag}.${o.b.class.split(' ')[0] || '?'}> "${o.b.text}" @ y=${Math.round(o.b.rect.y)}-${Math.round(o.b.rect.y + o.b.rect.height)}`);
|
||||
console.error(` Fix: Move block B down by at least ${Math.ceil(o.overlap + o.required)}px, or reduce block A font size.`);
|
||||
}
|
||||
allIssues.push(...textOverlaps.overlaps.map(o => ({ pass: 2, ...o })));
|
||||
} else {
|
||||
console.log(` ✅ Pass 2: No text-text zone overflows (checked ${textOverlaps.layer3Count} L3 blocks, skipped ${textOverlaps.layer1Count} L1 blocks)`);
|
||||
}
|
||||
}
|
||||
|
||||
if (allIssues.length === 0) {
|
||||
console.log(`\n ✅ All checks passed`);
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.error(`\n ❌ Total: ${allIssues.length} issue(s) found`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error(`✗ Unexpected error: ${err.message}`);
|
||||
process.exit(2);
|
||||
});
|
||||
Executable
+2834
File diff suppressed because it is too large.
Load diff
Executable
+822
@@ -0,0 +1,822 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* html2pdf-next.js — HTML → PDF converter using Playwright + Paged.js + pdf-lib
|
||||
*
|
||||
* Uses Paged.js polyfill for pagination (break-inside/before/after, named pages,
|
||||
* orphans/widows) and Chromium for rendering. pdf-lib for post-processing.
|
||||
*
|
||||
* Usage:
|
||||
* node html2pdf-next.js input.html
|
||||
* node html2pdf-next.js input.html --output result.pdf
|
||||
* node html2pdf-next.js input.html --css extra.css
|
||||
* node html2pdf-next.js input.html --width 720px --height 960px
|
||||
* node html2pdf-next.js input.html --merge a.pdf b.pdf (merge additional PDFs after)
|
||||
*
|
||||
* Architecture:
|
||||
* 1. Playwright loads HTML, runs pre-render hooks (Mermaid, KaTeX, overflow detection)
|
||||
* 2. Paged.js polyfill injected — reads @page rules, chunks content into .pagedjs_page containers
|
||||
* 3. Chromium renders the Paged.js output to PDF via page.pdf()
|
||||
* 4. pdf-lib for merge, metadata, page count extraction
|
||||
* 5. Continuous-canvas mode (design_engine.py) skips Paged.js, uses Chromium native print
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync, spawnSync } = require('child_process');
|
||||
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Playwright / Chromium resolution (self-contained, no external helper)
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
function loadPlaywright() {
|
||||
// Try direct require first
|
||||
try { return require('playwright'); } catch (_) {}
|
||||
|
||||
// Search common global paths
|
||||
const Module = require('module');
|
||||
const roots = new Set();
|
||||
if (process.env.PLAYWRIGHT_PATH) roots.add(process.env.PLAYWRIGHT_PATH);
|
||||
if (process.env.NODE_PATH) {
|
||||
process.env.NODE_PATH.split(path.delimiter).filter(Boolean).forEach(p => roots.add(p));
|
||||
}
|
||||
try {
|
||||
const g = execSync('npm root -g', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
|
||||
if (g) roots.add(g);
|
||||
} catch (_) {}
|
||||
|
||||
for (const base of roots) {
|
||||
const pkg = path.join(base, 'playwright', 'package.json');
|
||||
if (!fs.existsSync(pkg)) continue;
|
||||
try { return Module.createRequire(pkg)('playwright'); } catch (_) {}
|
||||
}
|
||||
throw new Error('Playwright not found. Install: npm install -g playwright@1.50.0');
|
||||
}
|
||||
|
||||
function loadPdfLib() {
|
||||
try { return require('pdf-lib'); } catch (_) {}
|
||||
const Module = require('module');
|
||||
try {
|
||||
const g = execSync('npm root -g', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
|
||||
const pkg = path.join(g, 'pdf-lib', 'package.json');
|
||||
if (fs.existsSync(pkg)) return Module.createRequire(pkg)('pdf-lib');
|
||||
} catch (_) {}
|
||||
throw new Error('pdf-lib not found. Install: npm install -g pdf-lib');
|
||||
}
|
||||
|
||||
function resolveChromium(chromiumObj, allowInstall = false) {
|
||||
let exe;
|
||||
try { exe = chromiumObj.executablePath(); } catch (_) { exe = null; }
|
||||
|
||||
if (exe && fs.existsSync(exe)) {
|
||||
return { status: 'ok', executablePath: exe };
|
||||
}
|
||||
|
||||
// Try system Chrome/Chromium
|
||||
const candidates = [
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
'/usr/bin/chromium-browser', '/usr/bin/chromium', '/usr/bin/google-chrome',
|
||||
];
|
||||
if (process.env.PLAYWRIGHT_CHROMIUM_PATH) candidates.unshift(process.env.PLAYWRIGHT_CHROMIUM_PATH);
|
||||
|
||||
for (const c of candidates) {
|
||||
if (fs.existsSync(c)) return { status: 'fallback', executablePath: c };
|
||||
}
|
||||
|
||||
if (allowInstall) {
|
||||
const r = spawnSync('npx', ['playwright', 'install', 'chromium'], { stdio: 'inherit', shell: true });
|
||||
if (r.status === 0) {
|
||||
try { exe = chromiumObj.executablePath(); } catch (_) {}
|
||||
if (exe && fs.existsSync(exe)) return { status: 'installed', executablePath: exe };
|
||||
}
|
||||
}
|
||||
|
||||
return { status: 'missing', executablePath: exe || '' };
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// CLI
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
function cli() {
|
||||
const tokens = process.argv.slice(2);
|
||||
if (!tokens.length || tokens[0] === '-h' || tokens[0] === '--help') {
|
||||
console.log(`
|
||||
Usage: node html2pdf-next.js <input.html> [options]
|
||||
|
||||
Options:
|
||||
--output, -o <file> Output PDF path (default: <input>.pdf)
|
||||
--css <file> Inject extra stylesheet
|
||||
--width <px> Custom page width (e.g. 720px)
|
||||
--height <px> Custom page height (e.g. 960px)
|
||||
--nopaged Skip Paged.js, use Chromium native @page pagination
|
||||
--merge <files...> Append additional PDF files after conversion
|
||||
--title <text> Set PDF document title metadata
|
||||
--help, -h Show help
|
||||
`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const inputFile = tokens[0];
|
||||
let outputFile = null, customCSS = null, width = null, height = null;
|
||||
let mergeFiles = [], title = null, noPaged = false;
|
||||
|
||||
for (let i = 1; i < tokens.length; i++) {
|
||||
const t = tokens[i];
|
||||
if (t === '--output' || t === '-o') outputFile = tokens[++i];
|
||||
else if (t === '--css') customCSS = tokens[++i];
|
||||
else if (t === '--width') width = tokens[++i];
|
||||
else if (t === '--height') height = tokens[++i];
|
||||
else if (t === '--direct' || t === '--nopaged') { noPaged = true; }
|
||||
else if (t === '--title') title = tokens[++i];
|
||||
else if (t === '--merge') {
|
||||
while (i + 1 < tokens.length && !tokens[i + 1].startsWith('--')) {
|
||||
mergeFiles.push(tokens[++i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!outputFile) {
|
||||
const p = path.parse(inputFile);
|
||||
outputFile = path.join(p.dir || '.', p.name + '.pdf');
|
||||
}
|
||||
|
||||
return { inputFile, outputFile, customCSS, width, height, mergeFiles, title, noPaged };
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Helpers
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
function prettyBytes(n) {
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let u = 0;
|
||||
while (n >= 1024 && u < units.length - 1) { n /= 1024; u++; }
|
||||
return `${n.toFixed(1)} ${units[u]}`;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Pre-render hooks (run in browser context before PDF export)
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
async function preRenderHooks(page) {
|
||||
const warnings = [];
|
||||
|
||||
// 1. Wait for Mermaid diagrams
|
||||
const hasMermaid = await page.evaluate(() => document.querySelectorAll('.mermaid').length > 0);
|
||||
if (hasMermaid) {
|
||||
console.log(' ⏳ Waiting for Mermaid diagrams...');
|
||||
try {
|
||||
await page.waitForFunction(() => {
|
||||
for (const m of document.querySelectorAll('.mermaid'))
|
||||
if (!m.querySelector('svg') && !m.getAttribute('data-processed')) return false;
|
||||
return true;
|
||||
}, { timeout: 30000 });
|
||||
await sleep(2000);
|
||||
console.log(' ✓ Mermaid rendered');
|
||||
} catch (_) {
|
||||
warnings.push('Mermaid rendering timed out (30s)');
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Trigger KaTeX math rendering
|
||||
const katexStatus = await page.evaluate(() => ({
|
||||
lib: typeof renderMathInElement === 'function' || typeof katex !== 'undefined',
|
||||
rendered: document.querySelectorAll('.katex').length > 0,
|
||||
raw: /\$[^$]+\$|\$\$[^$]+\$\$|\\\(.*?\\\)|\\\[.*?\\\]/.test(document.body.innerText),
|
||||
}));
|
||||
|
||||
// Auto-inject KaTeX CDN if raw math detected but library not loaded
|
||||
if (!katexStatus.lib && katexStatus.raw && !katexStatus.rendered) {
|
||||
console.log(' ⏳ Auto-injecting KaTeX CDN (math formulas detected but KaTeX not loaded)...');
|
||||
await page.addStyleTag({ url: 'https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.css' });
|
||||
await page.addScriptTag({ url: 'https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.js' });
|
||||
await page.addScriptTag({ url: 'https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/contrib/auto-render.min.js' });
|
||||
await sleep(2000); // Wait for CDN scripts to load
|
||||
// Re-check
|
||||
const recheckLib = await page.evaluate(() => typeof renderMathInElement === 'function');
|
||||
if (recheckLib) {
|
||||
console.log(' ✓ KaTeX CDN loaded successfully');
|
||||
} else {
|
||||
console.log(' ⚠ KaTeX CDN failed to load — math will render as raw text');
|
||||
warnings.push('KaTeX CDN injection failed; math formulas may appear as raw LaTeX code');
|
||||
}
|
||||
}
|
||||
|
||||
// Re-evaluate after potential CDN injection
|
||||
const katexReady = await page.evaluate(() => ({
|
||||
lib: typeof renderMathInElement === 'function' || typeof katex !== 'undefined',
|
||||
rendered: document.querySelectorAll('.katex').length > 0,
|
||||
raw: /\$[^$]+\$|\$\$[^$]+\$\$|\\\(.*?\\\)|\\\[.*?\\\]/.test(document.body.innerText),
|
||||
}));
|
||||
|
||||
if (katexReady.lib && !katexReady.rendered && katexReady.raw) {
|
||||
console.log(' ⏳ Triggering KaTeX rendering...');
|
||||
await page.evaluate(() => {
|
||||
if (typeof renderMathInElement === 'function')
|
||||
renderMathInElement(document.body, {
|
||||
delimiters: [
|
||||
{ left: '$$', right: '$$', display: true },
|
||||
{ left: '$', right: '$', display: false },
|
||||
{ left: '\\(', right: '\\)', display: false },
|
||||
{ left: '\\[', right: '\\]', display: true },
|
||||
],
|
||||
throwOnError: false,
|
||||
});
|
||||
});
|
||||
await sleep(1000);
|
||||
console.log(' ✓ KaTeX rendered');
|
||||
} else if (katexReady.rendered) {
|
||||
await sleep(500); // Font loading settle
|
||||
}
|
||||
|
||||
// 3. Fix oversized elements that prevent page breaks
|
||||
const nFixed = await page.evaluate(() => {
|
||||
const LIMIT = 1000;
|
||||
let n = 0;
|
||||
document.querySelectorAll(
|
||||
'[style*="page-break-inside: avoid"],[style*="break-inside: avoid"],' +
|
||||
'.avoid-break,table,figure,.theorem,.algorithm'
|
||||
).forEach(el => {
|
||||
if (el.getBoundingClientRect().height > LIMIT) {
|
||||
el.style.pageBreakInside = 'auto';
|
||||
el.style.breakInside = 'auto';
|
||||
n++;
|
||||
}
|
||||
});
|
||||
return n;
|
||||
});
|
||||
if (nFixed) {
|
||||
console.log(` ⚠ Fixed ${nFixed} oversized elements (removed break-inside: avoid)`);
|
||||
}
|
||||
|
||||
// 4. Detect overflow (horizontal AND vertical)
|
||||
const overflows = await page.evaluate(() => {
|
||||
const out = [];
|
||||
document.querySelectorAll('pre,table,figure,img,svg,.mermaid,blockquote,.equation').forEach(el => {
|
||||
const hDiff = el.scrollWidth - el.clientWidth;
|
||||
const vDiff = el.scrollHeight - el.clientHeight;
|
||||
if (hDiff > 2 || vDiff > 2) out.push({
|
||||
tag: el.tagName.toLowerCase(),
|
||||
cls: el.className || '',
|
||||
hOverflow: hDiff > 2 ? hDiff : 0,
|
||||
vOverflow: vDiff > 2 ? vDiff : 0,
|
||||
preview: (el.textContent || '').slice(0, 50).replace(/\s+/g, ' '),
|
||||
});
|
||||
});
|
||||
return out;
|
||||
});
|
||||
if (overflows.length) {
|
||||
console.log(' ⚠ Overflow detected:');
|
||||
overflows.forEach(o => {
|
||||
const parts = [];
|
||||
if (o.hOverflow) parts.push(`H +${o.hOverflow}px`);
|
||||
if (o.vOverflow) parts.push(`V +${o.vOverflow}px`);
|
||||
console.log(` <${o.tag}${o.cls ? '.' + o.cls.split(' ')[0] : ''}> ${parts.join(', ')}`);
|
||||
});
|
||||
warnings.push(`${overflows.length} element(s) have overflow`);
|
||||
}
|
||||
|
||||
// 4b. Remove overflow:hidden BEFORE Paged.js injection.
|
||||
// Paged.js reads the DOM layout to chunk content into pages.
|
||||
// If a container has overflow:hidden + fixed height, Paged.js only sees
|
||||
// the visible portion and silently loses the clipped content.
|
||||
// We must remove overflow:hidden first so Paged.js gets the full content.
|
||||
const vOverflowFix = await page.evaluate(() => {
|
||||
const fixes = [];
|
||||
const candidates = [document.documentElement, document.body];
|
||||
const bodyChildren = document.body.children;
|
||||
for (let i = 0; i < bodyChildren.length; i++) {
|
||||
const child = bodyChildren[i];
|
||||
const tag = child.tagName.toLowerCase();
|
||||
if (tag === 'svg' || tag === 'script' || tag === 'style' || tag === 'link') continue;
|
||||
candidates.push(child);
|
||||
for (let j = 0; j < child.children.length; j++) {
|
||||
const grandchild = child.children[j];
|
||||
const gtag = grandchild.tagName.toLowerCase();
|
||||
if (gtag === 'svg' || gtag === 'script' || gtag === 'style') continue;
|
||||
candidates.push(grandchild);
|
||||
}
|
||||
}
|
||||
|
||||
for (const el of candidates) {
|
||||
const computed = getComputedStyle(el);
|
||||
const overflow = computed.overflow || computed.overflowY;
|
||||
const hasHiddenOverflow = overflow === 'hidden' || overflow === 'clip';
|
||||
const diff = el.scrollHeight - el.clientHeight;
|
||||
|
||||
if (hasHiddenOverflow && diff > 5) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
const id = el.id ? `#${el.id}` : '';
|
||||
const cls = el.className ? `.${String(el.className).split(' ')[0]}` : '';
|
||||
const selector = `${tag}${id}${cls}`;
|
||||
|
||||
// ONLY remove overflow — do NOT touch height, position, or any
|
||||
// other layout property (preserves absolute-positioned layouts).
|
||||
el.style.overflow = 'visible';
|
||||
el.style.overflowY = 'visible';
|
||||
|
||||
fixes.push({ selector, clipped: diff });
|
||||
}
|
||||
}
|
||||
|
||||
const finalHeight = Math.max(
|
||||
document.documentElement.scrollHeight,
|
||||
document.body.scrollHeight
|
||||
);
|
||||
return { fixes, finalHeight };
|
||||
});
|
||||
|
||||
if (vOverflowFix.fixes.length) {
|
||||
console.log(' ⚠️ Removed overflow:hidden (content was clipped):');
|
||||
vOverflowFix.fixes.forEach(f => {
|
||||
console.log(` ${f.selector}: ${f.clipped}px clipped → overflow:visible`);
|
||||
});
|
||||
}
|
||||
|
||||
// Measure content height for diagnostics
|
||||
const contentHeight = vOverflowFix.fixes.length > 0
|
||||
? vOverflowFix.finalHeight
|
||||
: await page.evaluate(() => Math.max(
|
||||
document.documentElement.scrollHeight,
|
||||
document.body.scrollHeight
|
||||
));
|
||||
|
||||
// 5. Inject minimal @page CSS fallback
|
||||
await page.evaluate(() => {
|
||||
const styles = Array.from(document.querySelectorAll('style'));
|
||||
const hasPageRule = styles.some(s => (s.textContent || '').includes('@page'));
|
||||
if (!hasPageRule) {
|
||||
const s = document.createElement('style');
|
||||
s.textContent = `@page { margin: 20mm; }`;
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
});
|
||||
|
||||
// 6. (Removed) — Paged.js handles cover sections (100vh, break-after: page,
|
||||
// page-break-inside: avoid) natively. No manual fixup needed.
|
||||
|
||||
return { warnings, contentHeight };
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Content statistics (post-render, from PDF or page)
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
async function collectStats(page) {
|
||||
return page.evaluate(() => {
|
||||
const body = document.body;
|
||||
const text = body.innerText || '';
|
||||
const zhChars = (text.match(/[\u4e00-\u9fa5]/g) || []).length;
|
||||
const enWords = (text.match(/[a-zA-Z]+/g) || []).length;
|
||||
return {
|
||||
wordCount: zhChars + enWords,
|
||||
figures: document.querySelectorAll('figure,.figure,img').length,
|
||||
tables: document.querySelectorAll('table').length,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// pdf-lib post-processing: page count, metadata, merge
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
async function postProcess(pdfPath, options = {}) {
|
||||
const { PDFDocument } = loadPdfLib();
|
||||
const pdfBytes = fs.readFileSync(pdfPath);
|
||||
const doc = await PDFDocument.load(pdfBytes);
|
||||
|
||||
// Set metadata
|
||||
if (options.title) doc.setTitle(options.title);
|
||||
doc.setProducer('html2pdf-next (Playwright + pdf-lib)');
|
||||
doc.setCreationDate(new Date());
|
||||
|
||||
const pageCount = doc.getPageCount();
|
||||
|
||||
// Merge additional PDFs
|
||||
if (options.mergeFiles && options.mergeFiles.length) {
|
||||
for (const mf of options.mergeFiles) {
|
||||
if (!fs.existsSync(mf)) {
|
||||
console.log(` ⚠ Merge file not found: ${mf}`);
|
||||
continue;
|
||||
}
|
||||
console.log(` 📎 Merging: ${path.basename(mf)}`);
|
||||
const donorBytes = fs.readFileSync(mf);
|
||||
const donorDoc = await PDFDocument.load(donorBytes);
|
||||
const copiedPages = await doc.copyPages(donorDoc, donorDoc.getPageIndices());
|
||||
copiedPages.forEach(p => doc.addPage(p));
|
||||
}
|
||||
}
|
||||
|
||||
// Save
|
||||
const finalBytes = await doc.save();
|
||||
fs.writeFileSync(pdfPath, finalBytes);
|
||||
|
||||
return { pageCount: doc.getPageCount(), originalPages: pageCount };
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Main pipeline
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
async function convert(inputFile, outputFile, customCSS, options = {}) {
|
||||
const { width, height, mergeFiles, title, noPaged } = options;
|
||||
|
||||
if (!fs.existsSync(inputFile)) {
|
||||
console.error(`✗ File not found: ${inputFile}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const playwright = loadPlaywright();
|
||||
const { chromium } = playwright;
|
||||
|
||||
// Resolve browser
|
||||
const canInstall = process.env.PDF_SKIP_BROWSER_INSTALL !== '1';
|
||||
const bInfo = resolveChromium(chromium, canInstall);
|
||||
|
||||
if (bInfo.status === 'missing') {
|
||||
console.error('\n✗ Chromium not found. Run: npx playwright install chromium\n');
|
||||
process.exit(2);
|
||||
}
|
||||
if (bInfo.status === 'fallback') {
|
||||
console.log(`⚠ Using fallback Chromium: ${bInfo.executablePath}`);
|
||||
}
|
||||
|
||||
const absIn = path.resolve(inputFile);
|
||||
const absOut = path.resolve(outputFile);
|
||||
|
||||
console.log(`\n🔄 Converting ${path.basename(inputFile)}...`);
|
||||
console.log(` Engine: Playwright + ${noPaged ? 'Chromium native @page' : 'Paged.js'}`);
|
||||
|
||||
// Read and optionally inject CSS
|
||||
let html = fs.readFileSync(absIn, 'utf-8');
|
||||
if (customCSS) {
|
||||
if (!fs.existsSync(customCSS)) {
|
||||
console.error(`✗ CSS file not found: ${customCSS}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const tag = `<style>${fs.readFileSync(customCSS, 'utf-8')}</style>`;
|
||||
html = html.includes('</head>') ? html.replace('</head>', tag + '\n</head>') : tag + '\n' + html;
|
||||
// Write modified HTML for Playwright to load
|
||||
const tmpHtml = absIn + '.tmp.html';
|
||||
fs.writeFileSync(tmpHtml, html);
|
||||
// We'll clean up later
|
||||
}
|
||||
|
||||
// Launch browser
|
||||
let browser;
|
||||
try {
|
||||
const opts = { headless: true };
|
||||
if (bInfo.status === 'fallback') opts.executablePath = bInfo.executablePath;
|
||||
browser = await chromium.launch(opts);
|
||||
} catch (err) {
|
||||
const msg = err.message || '';
|
||||
if (msg.includes('shared libraries') || msg.includes('.so')) {
|
||||
console.error('\n✗ Missing system libraries. Run: npx playwright install-deps chromium\n');
|
||||
} else {
|
||||
console.error(`\n✗ Browser launch failed: ${msg}\n`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
const loadFile = customCSS ? absIn + '.tmp.html' : absIn;
|
||||
await page.goto('file://' + loadFile, { waitUntil: 'networkidle' });
|
||||
|
||||
// ── Pre-render hooks ──
|
||||
console.log('\n📋 Pre-render checks:');
|
||||
const preRenderResult = await preRenderHooks(page);
|
||||
const warnings = preRenderResult.warnings;
|
||||
const measuredContentHeight = preRenderResult.contentHeight;
|
||||
|
||||
// ── Detect continuous-canvas mode (design_engine.py output) ──
|
||||
const continuousInfo = await page.evaluate(() => {
|
||||
const el = document.querySelector('.continuous-canvas');
|
||||
if (!el) return null;
|
||||
const root = getComputedStyle(document.documentElement);
|
||||
return {
|
||||
width: root.getPropertyValue('--canvas-w').trim() || '720px',
|
||||
height: root.getPropertyValue('--canvas-h').trim() || '960px',
|
||||
pages: el.querySelectorAll('.page-section').length,
|
||||
};
|
||||
});
|
||||
|
||||
if (continuousInfo) {
|
||||
// Creative PDF: design_engine.py output with fixed-canvas page-sections.
|
||||
// These are already laid out as absolute-positioned pages — skip Paged.js,
|
||||
// use Chromium native print directly.
|
||||
console.log(`\n🎨 Creative layout: ${continuousInfo.pages} pages @ ${continuousInfo.width} × ${continuousInfo.height}`);
|
||||
await page.pdf({
|
||||
path: absOut,
|
||||
printBackground: true,
|
||||
preferCSSPageSize: false,
|
||||
margin: { top: 0, right: 0, bottom: 0, left: 0 },
|
||||
width: continuousInfo.width,
|
||||
height: continuousInfo.height,
|
||||
});
|
||||
} else if (noPaged) {
|
||||
// ── Chromium native pagination (--nopaged flag) ──
|
||||
console.log('\n📄 Rendering PDF (Chromium native @page)...');
|
||||
const pdfOpts = {
|
||||
path: absOut,
|
||||
printBackground: true,
|
||||
preferCSSPageSize: true,
|
||||
tagged: true,
|
||||
};
|
||||
|
||||
if (width || height) {
|
||||
if (width) pdfOpts.width = width;
|
||||
if (height) pdfOpts.height = height;
|
||||
pdfOpts.margin = { top: 0, right: 0, bottom: 0, left: 0 };
|
||||
console.log(` Custom size: ${pdfOpts.width || 'auto'} × ${pdfOpts.height || 'auto'}`);
|
||||
} else {
|
||||
const pageSize = await page.evaluate(() => {
|
||||
const styles = Array.from(document.querySelectorAll('style'));
|
||||
for (const s of styles) {
|
||||
const text = s.textContent || '';
|
||||
const match = text.match(/@page\s*\{[^}]*size:\s*([\d.]+)px\s+([\d.]+)px/);
|
||||
if (match) return { width: parseFloat(match[1]), height: parseFloat(match[2]) };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
if (pageSize) {
|
||||
pdfOpts.margin = { top: 0, right: 0, bottom: 0, left: 0 };
|
||||
console.log(` @page size: ${pageSize.width}px × ${pageSize.height}px`);
|
||||
} else {
|
||||
pdfOpts.format = 'A4';
|
||||
}
|
||||
}
|
||||
|
||||
await page.pdf(pdfOpts);
|
||||
} else {
|
||||
// Inject Paged.js polyfill to take over pagination.
|
||||
// Paged.js reads @page rules, chunks content into .pagedjs_page containers,
|
||||
// and handles break-inside/before/after, orphans/widows, named pages, etc.
|
||||
console.log('\n📖 Injecting Paged.js for pagination...');
|
||||
|
||||
// Resolve the paged.polyfill.js path
|
||||
const pagedPolyfillPath = (() => {
|
||||
// Try workspace node_modules first
|
||||
const candidates = [
|
||||
path.resolve(__dirname, '..', '..', '..', 'node_modules', 'pagedjs', 'dist', 'paged.polyfill.js'),
|
||||
path.resolve(process.cwd(), 'node_modules', 'pagedjs', 'dist', 'paged.polyfill.js'),
|
||||
];
|
||||
for (const c of candidates) {
|
||||
if (fs.existsSync(c)) return c;
|
||||
}
|
||||
// Try require.resolve
|
||||
try {
|
||||
const mod = require.resolve('pagedjs/dist/paged.polyfill.js');
|
||||
return mod;
|
||||
} catch (_) {}
|
||||
return null;
|
||||
})();
|
||||
|
||||
if (!pagedPolyfillPath) {
|
||||
console.error('\n✗ pagedjs not found. Run: npm install pagedjs');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const pagedScript = fs.readFileSync(pagedPolyfillPath, 'utf-8');
|
||||
await page.addScriptTag({ content: pagedScript });
|
||||
|
||||
// Wait for Paged.js to finish rendering
|
||||
try {
|
||||
await page.waitForSelector('.pagedjs_page', { timeout: 30000 });
|
||||
// Give it extra time for all pages to settle
|
||||
await sleep(2000);
|
||||
} catch (_) {
|
||||
console.log(' ⚠ Paged.js did not create .pagedjs_page elements (timeout 30s)');
|
||||
console.log(' Falling back to Chromium native @page pagination.');
|
||||
warnings.push('Paged.js rendering timed out; fell back to native pagination');
|
||||
}
|
||||
|
||||
// Check Paged.js results
|
||||
const pagedInfo = await page.evaluate(() => {
|
||||
const pages = document.querySelectorAll('.pagedjs_page');
|
||||
if (pages.length === 0) return null;
|
||||
return {
|
||||
pageCount: pages.length,
|
||||
pageWidth: pages[0].offsetWidth,
|
||||
pageHeight: pages[0].offsetHeight,
|
||||
};
|
||||
});
|
||||
|
||||
if (pagedInfo) {
|
||||
console.log(` ✓ Paged.js: ${pagedInfo.pageCount} pages @ ${pagedInfo.pageWidth}×${pagedInfo.pageHeight}px`);
|
||||
}
|
||||
|
||||
// ── Post-Paged.js background & centering fix ──
|
||||
// Paged.js creates .pagedjs_page containers that are transparent by default.
|
||||
// If body has a background color/image, the empty areas of each page may show
|
||||
// the wrong color. Fix: propagate background to .pagedjs_page containers.
|
||||
const bgFix = await page.evaluate(() => {
|
||||
const body = document.body;
|
||||
const html = document.documentElement;
|
||||
const bodyStyle = getComputedStyle(body);
|
||||
const htmlStyle = getComputedStyle(html);
|
||||
const bodyBg = bodyStyle.backgroundColor;
|
||||
const htmlBg = htmlStyle.backgroundColor;
|
||||
const bodyBgImg = bodyStyle.backgroundImage;
|
||||
const htmlBgImg = htmlStyle.backgroundImage;
|
||||
|
||||
const isTransparent = (c) => !c || c === 'rgba(0, 0, 0, 0)' || c === 'transparent';
|
||||
const hasGradient = (img) => img && img !== 'none';
|
||||
|
||||
// Check if body or html uses a gradient/image background
|
||||
const bodyHasGradient = hasGradient(bodyBgImg);
|
||||
const htmlHasGradient = hasGradient(htmlBgImg);
|
||||
|
||||
// If body uses gradient, propagate the full background shorthand
|
||||
// (not just backgroundColor, which would be transparent for gradients)
|
||||
if (bodyHasGradient) {
|
||||
// Copy body's full background to each .pagedjs_page
|
||||
const pages = document.querySelectorAll('.pagedjs_page');
|
||||
pages.forEach(p => {
|
||||
p.style.backgroundImage = bodyBgImg;
|
||||
p.style.backgroundSize = bodyStyle.backgroundSize;
|
||||
p.style.backgroundPosition = bodyStyle.backgroundPosition;
|
||||
p.style.backgroundRepeat = bodyStyle.backgroundRepeat;
|
||||
if (!isTransparent(bodyBg)) p.style.backgroundColor = bodyBg;
|
||||
});
|
||||
// Unify html to prevent color leaking
|
||||
html.style.backgroundColor = isTransparent(bodyBg) ? '#000' : bodyBg;
|
||||
html.style.backgroundImage = bodyBgImg;
|
||||
|
||||
const pagesContainer = document.querySelector('.pagedjs_pages');
|
||||
if (pagesContainer) { pagesContainer.style.margin = '0'; pagesContainer.style.padding = '0'; }
|
||||
|
||||
return { pageBg: 'gradient', pagesFixed: pages.length, bodyBg, htmlBg, gradient: true };
|
||||
}
|
||||
|
||||
// Solid color path: body bg > html bg > white
|
||||
const pageBg = !isTransparent(bodyBg) ? bodyBg
|
||||
: !isTransparent(htmlBg) ? htmlBg
|
||||
: 'white';
|
||||
|
||||
const pages = document.querySelectorAll('.pagedjs_page');
|
||||
let fixed = 0;
|
||||
pages.forEach(p => {
|
||||
if (isTransparent(getComputedStyle(p).backgroundColor)) {
|
||||
p.style.backgroundColor = pageBg;
|
||||
fixed++;
|
||||
}
|
||||
});
|
||||
|
||||
// Unify html/body background to match (prevents color leaking around pages)
|
||||
html.style.backgroundColor = pageBg;
|
||||
// Only set body bg if it was transparent (don't override an intentional body bg)
|
||||
if (isTransparent(bodyBg)) body.style.backgroundColor = pageBg;
|
||||
|
||||
const pagesContainer = document.querySelector('.pagedjs_pages');
|
||||
if (pagesContainer) { pagesContainer.style.margin = '0'; pagesContainer.style.padding = '0'; }
|
||||
|
||||
return { pageBg, pagesFixed: fixed, bodyBg, htmlBg, gradient: false };
|
||||
});
|
||||
|
||||
if (bgFix.pagesFixed > 0) {
|
||||
console.log(` ✓ Background fix: ${bgFix.pagesFixed} pages → ${bgFix.pageBg}`);
|
||||
}
|
||||
|
||||
// ── Post-Paged.js layout alignment fix ──
|
||||
// Paged.js renders pages inside .pagedjs_pages with padding/margin for screen
|
||||
// preview (visual spacing between pages). In print/PDF mode this causes the
|
||||
// .pagedjs_page to start at (40,40) instead of (0,0), shifting content.
|
||||
// Fix: reset all Paged.js wrapper margins/paddings so pages start at origin.
|
||||
if (pagedInfo) {
|
||||
const alignFix = await page.evaluate(() => {
|
||||
// Reset pagedjs_pages container
|
||||
const pc = document.querySelector('.pagedjs_pages');
|
||||
if (pc) {
|
||||
pc.style.margin = '0';
|
||||
pc.style.padding = '0';
|
||||
}
|
||||
// Reset all pagedjs_page wrappers
|
||||
document.querySelectorAll('.pagedjs_page').forEach(p => {
|
||||
p.style.margin = '0';
|
||||
});
|
||||
// Reset body/html margins
|
||||
document.body.style.margin = '0';
|
||||
document.body.style.padding = '0';
|
||||
document.documentElement.style.margin = '0';
|
||||
document.documentElement.style.padding = '0';
|
||||
// Check final alignment
|
||||
const firstPage = document.querySelector('.pagedjs_page');
|
||||
if (!firstPage) return { fixed: false };
|
||||
const rect = firstPage.getBoundingClientRect();
|
||||
return { fixed: true, x: rect.x, y: rect.y, w: rect.width, h: rect.height };
|
||||
});
|
||||
if (alignFix.fixed && (alignFix.x > 1 || alignFix.y > 1)) {
|
||||
console.log(` ⚠ Page offset after reset: (${alignFix.x}, ${alignFix.y}) — may still have internal pagedjs margins`);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate PDF
|
||||
console.log('\n📄 Rendering PDF...');
|
||||
const pdfOpts = {
|
||||
path: absOut,
|
||||
printBackground: true,
|
||||
preferCSSPageSize: true,
|
||||
tagged: true,
|
||||
margin: { top: 0, right: 0, bottom: 0, left: 0 },
|
||||
};
|
||||
|
||||
if (width || height) {
|
||||
if (width) pdfOpts.width = width;
|
||||
if (height) pdfOpts.height = height;
|
||||
console.log(` Custom size: ${pdfOpts.width || 'auto'} × ${pdfOpts.height || 'auto'}`);
|
||||
} else if (!pagedInfo) {
|
||||
// Paged.js didn't run (fallback) — detect @page or use A4
|
||||
const pageSize = await page.evaluate(() => {
|
||||
const styles = Array.from(document.querySelectorAll('style'));
|
||||
for (const s of styles) {
|
||||
const text = s.textContent || '';
|
||||
const match = text.match(/@page\s*\{[^}]*size:\s*([\d.]+)px\s+([\d.]+)px/);
|
||||
if (match) return { width: parseFloat(match[1]), height: parseFloat(match[2]) };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
if (pageSize) {
|
||||
console.log(` @page size: ${pageSize.width}px × ${pageSize.height}px`);
|
||||
} else {
|
||||
pdfOpts.format = 'A4';
|
||||
}
|
||||
}
|
||||
|
||||
await page.pdf(pdfOpts);
|
||||
}
|
||||
|
||||
// Collect content stats from the page
|
||||
const stats = await collectStats(page);
|
||||
|
||||
// ── pdf-lib post-processing ──
|
||||
console.log('\n🔧 Post-processing (pdf-lib):');
|
||||
const postResult = await postProcess(absOut, { mergeFiles, title });
|
||||
|
||||
// Clean up temp HTML
|
||||
const tmpHtml = absIn + '.tmp.html';
|
||||
if (fs.existsSync(tmpHtml)) fs.unlinkSync(tmpHtml);
|
||||
|
||||
// ── Report ──
|
||||
const sz = fs.statSync(absOut).size;
|
||||
console.log('\n' + '═'.repeat(40));
|
||||
console.log(' PDF Generated Successfully');
|
||||
console.log('═'.repeat(40));
|
||||
console.log(` File: ${path.basename(absOut)}`);
|
||||
console.log(` Pages: ${postResult.pageCount}`);
|
||||
console.log(` Size: ${prettyBytes(sz)}`);
|
||||
console.log(` Words: ~${stats.wordCount.toLocaleString()}`);
|
||||
console.log(` Assets: ${stats.figures} figures, ${stats.tables} tables`);
|
||||
const engineLabel = continuousInfo ? 'Playwright (Creative canvas)'
|
||||
: noPaged ? 'Playwright (Chromium native @page)'
|
||||
: 'Playwright + Paged.js';
|
||||
console.log(` Engine: ${engineLabel}`);
|
||||
console.log(` Path: ${absOut}`);
|
||||
|
||||
if (mergeFiles && mergeFiles.length && postResult.pageCount > postResult.originalPages) {
|
||||
console.log(` Merged: +${postResult.pageCount - postResult.originalPages} pages from ${mergeFiles.length} file(s)`);
|
||||
}
|
||||
|
||||
if (warnings.length) {
|
||||
console.log('\n⚠ Warnings:');
|
||||
warnings.forEach(w => console.log(` · ${w}`));
|
||||
}
|
||||
|
||||
// Anomaly detection
|
||||
if (postResult.pageCount > 1 && stats.wordCount > 0) {
|
||||
const avgWordsPerPage = stats.wordCount / postResult.pageCount;
|
||||
if (avgWordsPerPage < 30) {
|
||||
console.log(`\n⚠ Low content density: ~${Math.round(avgWordsPerPage)} words/page (expected 100+)`);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('\n✗ Conversion failed:', err.message);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Entry
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const args = cli();
|
||||
await convert(args.inputFile, args.outputFile, args.customCSS, {
|
||||
width: args.width,
|
||||
height: args.height,
|
||||
mergeFiles: args.mergeFiles,
|
||||
title: args.title,
|
||||
noPaged: args.noPaged,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error:', err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
Executable
+256
@@ -0,0 +1,256 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* html2poster.js — Single-page poster/long-image HTML → PDF converter
|
||||
*
|
||||
* Purpose: Convert a fixed-width, dynamic-height HTML poster into a single-page
|
||||
* vector PDF with zero margins. This script is PURPOSE-BUILT for posters and
|
||||
* infographics — it does NOT handle multi-page documents, A4 pagination, or
|
||||
* document-style margins. For those, use html2pdf-next.js.
|
||||
*
|
||||
* Usage:
|
||||
* node html2poster.js poster.html
|
||||
* node html2poster.js poster.html --output out.pdf
|
||||
* node html2poster.js poster.html --width 720px
|
||||
* node html2poster.js poster.html --width 720px --max-height 8000
|
||||
*
|
||||
* What it does (in order):
|
||||
* 1. Load HTML in Playwright
|
||||
* 2. Force overflow:hidden on .poster/.page containers (clip decorative overflow)
|
||||
* 3. Inject @page { margin: 0 } (override any existing margin)
|
||||
* 4. Ensure html/body have margin:0, padding:0, matching background
|
||||
* 5. Measure .poster scrollHeight (actual content height)
|
||||
* 6. Generate single-page PDF with exact dimensions
|
||||
*
|
||||
* What it does NOT do:
|
||||
* - No pagination / page breaks
|
||||
* - No A4 fallback
|
||||
* - No margin injection (always zero)
|
||||
* - No cover adaptation
|
||||
* - No pdf-lib post-processing
|
||||
* - No continuous-canvas detection
|
||||
* - No vertical overflow expansion (posters WANT overflow:hidden)
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
// ── Chromium resolution (shared logic with html2pdf-next.js) ──
|
||||
|
||||
function resolveChromium(chromiumObj) {
|
||||
let exe;
|
||||
try { exe = chromiumObj.executablePath(); } catch (_) { exe = null; }
|
||||
if (exe && fs.existsSync(exe)) return { status: 'ok', executablePath: exe };
|
||||
|
||||
const candidates = [
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
'/usr/bin/chromium-browser', '/usr/bin/chromium', '/usr/bin/google-chrome',
|
||||
];
|
||||
if (process.env.PLAYWRIGHT_CHROMIUM_PATH) candidates.unshift(process.env.PLAYWRIGHT_CHROMIUM_PATH);
|
||||
|
||||
for (const c of candidates) {
|
||||
if (fs.existsSync(c)) return { status: 'fallback', executablePath: c };
|
||||
}
|
||||
return { status: 'missing', executablePath: exe || '' };
|
||||
}
|
||||
|
||||
// ── CLI parsing ──
|
||||
|
||||
function parseArgs(argv) {
|
||||
const tokens = argv.slice(2);
|
||||
let input = null, output = null, width = '720px', maxHeight = 16000;
|
||||
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
const t = tokens[i];
|
||||
if (t === '--output' || t === '-o') output = tokens[++i];
|
||||
else if (t === '--width') width = tokens[++i];
|
||||
else if (t === '--max-height') maxHeight = parseInt(tokens[++i], 10);
|
||||
else if (t === '--help' || t === '-h') {
|
||||
console.log(`
|
||||
Usage: node html2poster.js <input.html> [options]
|
||||
|
||||
Options:
|
||||
--output, -o Output PDF path (default: input with .pdf extension)
|
||||
--width Poster width (default: 720px)
|
||||
--max-height Maximum allowed height in px (default: 16000, safety limit)
|
||||
-h, --help Show this help
|
||||
`);
|
||||
process.exit(0);
|
||||
}
|
||||
else if (!input) input = t;
|
||||
else if (!output) output = t;
|
||||
}
|
||||
|
||||
if (!input) {
|
||||
console.error('Error: No input HTML file specified.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!output) {
|
||||
output = input.replace(/\.html?$/i, '.pdf');
|
||||
if (output === input) output = input + '.pdf';
|
||||
}
|
||||
|
||||
return { input, output, width, maxHeight };
|
||||
}
|
||||
|
||||
// ── Main ──
|
||||
|
||||
async function main() {
|
||||
const { input, output, width, maxHeight } = parseArgs(process.argv);
|
||||
const absIn = path.resolve(input);
|
||||
const absOut = path.resolve(output);
|
||||
|
||||
if (!fs.existsSync(absIn)) {
|
||||
console.error(`Error: File not found: ${absIn}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`\n🖼 html2poster — Single-page poster PDF generator`);
|
||||
console.log(` Input: ${absIn}`);
|
||||
console.log(` Output: ${absOut}`);
|
||||
console.log(` Width: ${width}`);
|
||||
|
||||
// Load Playwright
|
||||
let playwright;
|
||||
try {
|
||||
playwright = require('playwright');
|
||||
} catch {
|
||||
try {
|
||||
playwright = require('playwright-core');
|
||||
} catch {
|
||||
console.error('Error: playwright or playwright-core not installed.');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const { chromium } = playwright;
|
||||
const bInfo = resolveChromium(chromium);
|
||||
|
||||
if (bInfo.status === 'missing') {
|
||||
console.error('Error: No Chromium found. Run: npx playwright install chromium');
|
||||
process.exit(1);
|
||||
}
|
||||
if (bInfo.status === 'fallback') {
|
||||
console.log(` ⚠ Using fallback Chromium: ${bInfo.executablePath}`);
|
||||
}
|
||||
|
||||
// Launch browser
|
||||
const launchOpts = { headless: true };
|
||||
if (bInfo.status === 'fallback') launchOpts.executablePath = bInfo.executablePath;
|
||||
|
||||
const browser = await chromium.launch(launchOpts);
|
||||
|
||||
try {
|
||||
// Use a wide viewport so content doesn't wrap unexpectedly
|
||||
const widthPx = parseInt(width, 10) || 720;
|
||||
const page = await browser.newPage({ viewport: { width: widthPx, height: 1200 } });
|
||||
|
||||
await page.goto('file://' + absIn, { waitUntil: 'networkidle' });
|
||||
console.log(`\n ✓ HTML loaded`);
|
||||
|
||||
// ── Step 1: Force overflow:hidden on page containers ──
|
||||
// Decorative elements with negative offsets or width>100% inflate scrollWidth,
|
||||
// causing Playwright to shrink content to fit. overflow:hidden clips them.
|
||||
const overflowFixed = await page.evaluate(() => {
|
||||
const selectors = ['.poster', '.page', '#poster', '#page'];
|
||||
let fixed = 0;
|
||||
for (const sel of selectors) {
|
||||
const el = document.querySelector(sel);
|
||||
if (!el) continue;
|
||||
const computed = getComputedStyle(el);
|
||||
if (computed.overflow !== 'hidden') {
|
||||
el.style.overflow = 'hidden';
|
||||
fixed++;
|
||||
}
|
||||
}
|
||||
return fixed;
|
||||
});
|
||||
if (overflowFixed > 0) {
|
||||
console.log(` ✓ Added overflow:hidden to ${overflowFixed} container(s)`);
|
||||
}
|
||||
|
||||
// ── Step 2: Inject @page { margin: 0 } — override any existing @page rule ──
|
||||
await page.evaluate(() => {
|
||||
const s = document.createElement('style');
|
||||
// Use !important-equivalent: place at end so it wins cascade
|
||||
s.textContent = `@page { margin: 0 !important; size: auto; }`;
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
|
||||
// ── Step 3: Ensure html/body have zero margin/padding ──
|
||||
const bgSync = await page.evaluate(() => {
|
||||
const html = document.documentElement;
|
||||
const body = document.body;
|
||||
html.style.margin = '0';
|
||||
html.style.padding = '0';
|
||||
body.style.margin = '0';
|
||||
body.style.padding = '0';
|
||||
|
||||
// Sync body background with poster background to avoid color gaps
|
||||
const poster = document.querySelector('.poster') || document.querySelector('.page');
|
||||
if (poster) {
|
||||
const posterBg = getComputedStyle(poster).backgroundColor;
|
||||
if (posterBg && posterBg !== 'rgba(0, 0, 0, 0)' && posterBg !== 'transparent') {
|
||||
body.style.backgroundColor = posterBg;
|
||||
html.style.backgroundColor = posterBg;
|
||||
return posterBg;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
if (bgSync) {
|
||||
console.log(` ✓ Synced body background: ${bgSync}`);
|
||||
}
|
||||
|
||||
// ── Step 4: Measure actual content height ──
|
||||
const measurement = await page.evaluate(() => {
|
||||
const poster = document.querySelector('.poster') || document.querySelector('.page') || document.body;
|
||||
return {
|
||||
scrollHeight: poster.scrollHeight,
|
||||
scrollWidth: poster.scrollWidth,
|
||||
offsetWidth: poster.offsetWidth,
|
||||
selector: poster.className ? '.' + poster.className.split(' ')[0] : poster.tagName,
|
||||
};
|
||||
});
|
||||
|
||||
console.log(` ✓ Measured: ${measurement.selector} = ${measurement.scrollWidth}×${measurement.scrollHeight}px`);
|
||||
|
||||
if (measurement.scrollWidth > widthPx + 2) {
|
||||
console.log(` ⚠ WARNING: scrollWidth (${measurement.scrollWidth}px) > width (${widthPx}px)`);
|
||||
console.log(` Decorative elements may still overflow. Check for position:absolute elements with negative offsets.`);
|
||||
}
|
||||
|
||||
let contentHeight = measurement.scrollHeight;
|
||||
if (contentHeight > maxHeight) {
|
||||
console.log(` ⚠ Content height ${contentHeight}px exceeds max ${maxHeight}px, clamping.`);
|
||||
contentHeight = maxHeight;
|
||||
}
|
||||
if (contentHeight < 100) {
|
||||
console.log(` ⚠ Content height ${contentHeight}px seems too small, using 960px fallback.`);
|
||||
contentHeight = 960;
|
||||
}
|
||||
|
||||
// ── Step 5: Generate PDF ──
|
||||
console.log(`\n 📄 Generating PDF: ${width} × ${contentHeight}px`);
|
||||
await page.pdf({
|
||||
path: absOut,
|
||||
width: width,
|
||||
height: contentHeight + 'px',
|
||||
printBackground: true,
|
||||
margin: { top: '0', right: '0', bottom: '0', left: '0' },
|
||||
});
|
||||
|
||||
console.log(`\n ✅ Done: ${absOut}`);
|
||||
console.log(` Size: ${(fs.statSync(absOut).size / 1024).toFixed(1)} KB`);
|
||||
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error(`\n✗ Fatal: ${err.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
Executable
+2991
File diff suppressed because it is too large.
Load diff
Executable
+1174
File diff suppressed because it is too large.
Load diff
Executable
+1414
File diff suppressed because it is too large.
Load diff
Executable
+329
@@ -0,0 +1,329 @@
|
||||
#!/usr/bin/env bash
|
||||
# ---
|
||||
# name: pdf-setup
|
||||
# author: Z.AI
|
||||
# version: "1.0"
|
||||
# description: Environment setup for the PDF skill. Checks and installs all required dependencies.
|
||||
# ---
|
||||
#
|
||||
# Installs only dependencies required by the PDF skill.
|
||||
set -euo pipefail
|
||||
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m'
|
||||
ok() { echo -e " ${GREEN}✓${NC} $1"; }
|
||||
fail() { echo -e " ${RED}✗${NC} $1"; }
|
||||
warn() { echo -e " ${YELLOW}○${NC} $1"; }
|
||||
info() { echo -e " ${BLUE}→${NC} $1"; }
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
echo "============================================"
|
||||
echo " PDF Skill — Environment Setup"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
|
||||
# ── Detect platform ──
|
||||
OS="$(uname -s)"
|
||||
ARCH="$(uname -m)"
|
||||
echo "Platform: $OS $ARCH"
|
||||
echo ""
|
||||
|
||||
# ── 0. macOS: Homebrew ──
|
||||
if [ "$OS" = "Darwin" ]; then
|
||||
echo "--- Homebrew (macOS package manager) ---"
|
||||
if command -v brew &>/dev/null; then
|
||||
BREW_VER=$(brew --version 2>/dev/null | head -1)
|
||||
ok "brew ($BREW_VER)"
|
||||
else
|
||||
fail "brew not found — most dependencies below need Homebrew on macOS"
|
||||
info "Install: /bin/bash -c \"\$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\""
|
||||
fi
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# ── 1. Python 3 ──
|
||||
echo "--- Python ---"
|
||||
if command -v python3 &>/dev/null; then
|
||||
PY_VER=$(python3 --version 2>&1)
|
||||
ok "python3 ($PY_VER)"
|
||||
# macOS: warn if using system Python
|
||||
if [ "$OS" = "Darwin" ]; then
|
||||
PY_PATH=$(which python3 2>/dev/null)
|
||||
if [[ "$PY_PATH" == "/usr/bin/python3" ]]; then
|
||||
warn "Using macOS system Python (limited). Recommend: brew install python3"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
fail "python3 not found"
|
||||
case "$OS" in
|
||||
Darwin) info "Install: brew install python3" ;;
|
||||
Linux) info "Install: sudo apt install python3 python3-pip (Debian/Ubuntu)"
|
||||
info " sudo dnf install python3 python3-pip (Fedora/RHEL)" ;;
|
||||
*) info "Install: https://www.python.org/downloads/" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# ── 2. pip ──
|
||||
echo ""
|
||||
echo "--- pip ---"
|
||||
if python3 -m pip --version &>/dev/null 2>&1; then
|
||||
PIP_VER=$(python3 -m pip --version 2>/dev/null | head -1)
|
||||
ok "pip ($PIP_VER)"
|
||||
else
|
||||
fail "pip not found"
|
||||
case "$OS" in
|
||||
Darwin) info "Install: python3 -m ensurepip --upgrade"
|
||||
info " or: brew install python3 (includes pip)" ;;
|
||||
Linux) info "Install: sudo apt install python3-pip (Debian/Ubuntu)" ;;
|
||||
*) info "Install: python3 -m ensurepip --upgrade" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# ── 3. Python packages (pip) ──
|
||||
echo ""
|
||||
echo "--- Python Packages ---"
|
||||
PY_PKGS=(
|
||||
"pikepdf:pikepdf"
|
||||
"pdfplumber:pdfplumber"
|
||||
"pypdf:pypdf"
|
||||
"reportlab:reportlab"
|
||||
"pymupdf:PyMuPDF"
|
||||
)
|
||||
|
||||
MISSING_PY=()
|
||||
for entry in "${PY_PKGS[@]}"; do
|
||||
mod="${entry%%:*}"
|
||||
pkg="${entry##*:}"
|
||||
if python3 -c "import $mod" 2>/dev/null; then
|
||||
ver=$(python3 -c "import $mod; print(getattr($mod, '__version__', 'installed'))" 2>/dev/null)
|
||||
ok "$pkg ($ver)"
|
||||
else
|
||||
fail "$pkg not installed"
|
||||
MISSING_PY+=("$pkg")
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ${#MISSING_PY[@]} -gt 0 ]; then
|
||||
echo ""
|
||||
if [ -t 0 ]; then
|
||||
read -p " Install missing Python packages? [Y/n] " -n 1 -r REPLY
|
||||
echo ""
|
||||
REPLY=${REPLY:-Y}
|
||||
else
|
||||
warn "Non-interactive mode — skipping auto-install. Run interactively or install manually."
|
||||
REPLY=N
|
||||
fi
|
||||
if [[ ! $REPLY =~ ^[Nn]$ ]]; then
|
||||
python3 -m pip install -q "${MISSING_PY[@]}" 2>/dev/null \
|
||||
|| python3 -m pip install -q --user "${MISSING_PY[@]}" 2>/dev/null \
|
||||
|| python3 -m pip install -q --break-system-packages "${MISSING_PY[@]}" 2>/dev/null \
|
||||
|| { fail "pip install failed. Try manually: pip install ${MISSING_PY[*]}"; }
|
||||
ok "Installed: ${MISSING_PY[*]}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── 4. Node.js ──
|
||||
echo ""
|
||||
echo "--- Node.js ---"
|
||||
if command -v node &>/dev/null; then
|
||||
NODE_VER=$(node --version)
|
||||
ok "node ($NODE_VER)"
|
||||
else
|
||||
fail "node not found"
|
||||
case "$OS" in
|
||||
Darwin) info "Install: brew install node" ;;
|
||||
Linux) info "Install: curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -"
|
||||
info " sudo apt install -y nodejs" ;;
|
||||
*) info "Install: https://nodejs.org/" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# ── 5. npm ──
|
||||
echo ""
|
||||
echo "--- npm ---"
|
||||
if command -v npm &>/dev/null; then
|
||||
NPM_VER=$(npm --version 2>/dev/null)
|
||||
ok "npm ($NPM_VER)"
|
||||
else
|
||||
fail "npm not found"
|
||||
case "$OS" in
|
||||
Darwin) info "Install: brew install node (includes npm)" ;;
|
||||
Linux) info "Install: comes with nodejs" ;;
|
||||
*) info "Install: https://nodejs.org/" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# ── 6. Playwright + Chromium ──
|
||||
echo ""
|
||||
echo "--- Playwright (HTML→PDF engine) ---"
|
||||
if node -e "require('playwright')" 2>/dev/null; then
|
||||
PW_VER=$(node -e "console.log(require('playwright/package.json').version)" 2>/dev/null)
|
||||
ok "playwright ($PW_VER)"
|
||||
else
|
||||
fail "playwright not installed"
|
||||
info "Install: npm install -g playwright@1.50.0"
|
||||
fi
|
||||
|
||||
# Check Chromium
|
||||
if [ "$OS" = "Darwin" ]; then
|
||||
PW_CACHE="$HOME/Library/Caches/ms-playwright"
|
||||
else
|
||||
PW_CACHE="$HOME/.cache/ms-playwright"
|
||||
fi
|
||||
if ls "$PW_CACHE"/chromium-* &>/dev/null 2>&1; then
|
||||
CR_DIR=$(ls -d "$PW_CACHE"/chromium-* 2>/dev/null | tail -1)
|
||||
ok "chromium ($(basename "$CR_DIR"))"
|
||||
else
|
||||
fail "chromium not installed"
|
||||
info "Install: npx playwright install chromium"
|
||||
if [ "$OS" = "Linux" ]; then
|
||||
info " npx playwright install-deps (system libs, needs sudo)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── 7. Tectonic (LaTeX engine, optional) ──
|
||||
echo ""
|
||||
echo "--- Tectonic (LaTeX→PDF, optional) ---"
|
||||
BUNDLED="$SCRIPT_DIR/tectonic"
|
||||
if [ -x "$BUNDLED" ]; then
|
||||
if [ "$OS" = "Darwin" ] && [ "$ARCH" = "arm64" ]; then
|
||||
ok "tectonic (bundled, macOS arm64)"
|
||||
else
|
||||
warn "bundled tectonic is macOS arm64 only — cannot run on $OS $ARCH"
|
||||
if command -v tectonic &>/dev/null; then
|
||||
TEC_VER=$(tectonic --version 2>&1 | head -1)
|
||||
ok "tectonic (system: $TEC_VER)"
|
||||
else
|
||||
fail "tectonic not in PATH"
|
||||
case "$OS" in
|
||||
Darwin) info "Install: brew install tectonic" ;;
|
||||
Linux) info "Install: conda install -c conda-forge tectonic"
|
||||
info " or: conda config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/ && conda install tectonic" ;;
|
||||
MINGW*|MSYS*|CYGWIN*) info "Install: scoop install tectonic / choco install tectonic" ;;
|
||||
esac
|
||||
fi
|
||||
fi
|
||||
elif command -v tectonic &>/dev/null; then
|
||||
TEC_VER=$(tectonic --version 2>&1 | head -1)
|
||||
ok "tectonic ($TEC_VER)"
|
||||
elif [ -x "$HOME/tectonic" ]; then
|
||||
ok "tectonic (~/tectonic)"
|
||||
else
|
||||
warn "tectonic not installed (needed only for LaTeX/academic PDFs)"
|
||||
case "$OS" in
|
||||
Darwin) info "Install: brew install tectonic" ;;
|
||||
Linux) info "Install: conda install -c conda-forge tectonic"
|
||||
info " or: conda config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/ && conda install tectonic" ;;
|
||||
MINGW*|MSYS*|CYGWIN*) info "Install: scoop install tectonic / choco install tectonic" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# ── 8. LibreOffice (optional, for Office→PDF conversion) ──
|
||||
echo ""
|
||||
echo "--- LibreOffice (optional, Office→PDF) ---"
|
||||
if command -v soffice &>/dev/null; then
|
||||
LO_VER=$(soffice --version 2>/dev/null | head -1)
|
||||
ok "libreoffice ($LO_VER)"
|
||||
else
|
||||
warn "libreoffice not installed (needed only for .docx/.xlsx→PDF conversion)"
|
||||
case "$OS" in
|
||||
Darwin) info "Install: brew install --cask libreoffice" ;;
|
||||
Linux) info "Install: sudo apt install libreoffice-core (Debian/Ubuntu)" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# ── 9. CJK Fonts ──
|
||||
echo ""
|
||||
echo "--- CJK Fonts ---"
|
||||
FONT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)/fonts"
|
||||
if [ -d "$FONT_DIR" ]; then
|
||||
FONT_COUNT=$(find "$FONT_DIR" -name "*.ttf" -o -name "*.otf" 2>/dev/null | head -20 | wc -l | tr -d ' ')
|
||||
ok "fonts directory ($FONT_COUNT font files in $FONT_DIR)"
|
||||
else
|
||||
warn "no fonts/ directory found — CJK PDFs may have missing glyphs"
|
||||
info "Expected at: $FONT_DIR"
|
||||
fi
|
||||
# Check system CJK fonts
|
||||
if [ "$OS" = "Darwin" ]; then
|
||||
if [ -f "$HOME/Library/Fonts/NotoSansSC[wght].ttf" ] || fc-list :lang=zh 2>/dev/null | head -1 | grep -q . \
|
||||
|| ls "$HOME/Library/Fonts/"*NotoSansSC* &>/dev/null 2>&1; then
|
||||
ok "CJK fonts available (Noto Sans SC or system)"
|
||||
else
|
||||
warn "no common CJK system fonts found"
|
||||
fi
|
||||
elif [ "$OS" = "Linux" ]; then
|
||||
if fc-list :lang=zh 2>/dev/null | head -1 | grep -q .; then
|
||||
ok "system CJK fonts available (fc-list)"
|
||||
else
|
||||
warn "no CJK fonts found. Install: sudo apt install fonts-noto-cjk"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── 9b. Noto Serif SC (Chinese serif for covers / academic docs) ──
|
||||
echo ""
|
||||
echo "--- Noto Serif SC (Chinese serif, covers/academic) ---"
|
||||
ENV_SETUP_FONT="$(cd "$SCRIPT_DIR/../.." && pwd)/env-setup/fonts/noto-serif-sc/NotoSerifSC-Regular.ttf"
|
||||
NOTO_SERIF_FOUND=0
|
||||
|
||||
# Method 1: Check via fc-list
|
||||
if command -v fc-list &>/dev/null; then
|
||||
if fc-list | grep -qi "Noto Serif SC"; then
|
||||
ok "Noto Serif SC (fc-list)"
|
||||
NOTO_SERIF_FOUND=1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Method 2: Check common font directories
|
||||
if [ $NOTO_SERIF_FOUND -eq 0 ]; then
|
||||
for CHECK_PATH in \
|
||||
"$HOME/Library/Fonts/NotoSerifSC"*.ttf \
|
||||
"/Library/Fonts/NotoSerifSC"*.ttf \
|
||||
"$HOME/.local/share/fonts/NotoSerifSC"*.ttf \
|
||||
"/usr/share/fonts/"*"/NotoSerifSC"*.ttf; do
|
||||
if ls $CHECK_PATH &>/dev/null 2>&1; then
|
||||
ok "Noto Serif SC ($CHECK_PATH)"
|
||||
NOTO_SERIF_FOUND=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Method 3: Check env-setup bundled font
|
||||
if [ $NOTO_SERIF_FOUND -eq 0 ]; then
|
||||
if [ -f "$ENV_SETUP_FONT" ]; then
|
||||
warn "Noto Serif SC not installed system-wide, but available in env-setup bundle"
|
||||
info "Run this setup interactively to auto-install, or copy manually"
|
||||
if [ -t 0 ]; then
|
||||
read -p " Install Noto Serif SC to user fonts? [Y/n] " -n 1 -r REPLY
|
||||
echo ""
|
||||
REPLY=${REPLY:-Y}
|
||||
if [[ ! $REPLY =~ ^[Nn]$ ]]; then
|
||||
USER_FONT_DIR="$HOME/Library/Fonts"
|
||||
if [ "$OS" = "Linux" ]; then
|
||||
USER_FONT_DIR="$HOME/.local/share/fonts"
|
||||
fi
|
||||
mkdir -p "$USER_FONT_DIR"
|
||||
cp "$ENV_SETUP_FONT" "$USER_FONT_DIR/"
|
||||
if command -v fc-cache &>/dev/null; then
|
||||
fc-cache -f "$USER_FONT_DIR" 2>/dev/null
|
||||
fi
|
||||
ok "Noto Serif SC installed to $USER_FONT_DIR"
|
||||
NOTO_SERIF_FOUND=1
|
||||
fi
|
||||
fi
|
||||
else
|
||||
fail "Noto Serif SC not found"
|
||||
info "Download from: https://fonts.google.com/noto/specimen/Noto+Serif+SC"
|
||||
case "$OS" in
|
||||
Linux) info "Or install: sudo apt install fonts-noto-cjk" ;;
|
||||
esac
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Summary ──
|
||||
echo ""
|
||||
echo "============================================"
|
||||
echo " Setup complete."
|
||||
echo " Run 'python3 pdf.py env.check' for detailed status."
|
||||
echo " Run 'python3 pdf.py env.fix' to auto-install Python deps."
|
||||
echo "============================================"
|
||||
Executable
+2085
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user