const http = require("http"); const fs = require("fs"); const path = require("path"); const os = require("os"); const crypto = require("crypto"); const { spawn } = require("child_process"); const { fileURLToPath, pathToFileURL } = require("url"); const { createSharedDraftStore, MAX_DRAFT_BYTES } = require("./shared-drafts"); const ROOT = __dirname; const PORT = Number(process.env.PORT || process.env.PROPOSAL_BOT_PORT || 4181); const HOST = process.env.HOST || "127.0.0.1"; const PROJECT_IMAGES_DIR = process.env.PROPOSAL_BOT_PROJECT_IMAGES_DIR ? path.resolve(process.env.PROPOSAL_BOT_PROJECT_IMAGES_DIR) : path.join(ROOT, "Project Images"); const DRAFTS_DIR = process.env.PROPOSAL_BOT_DATA_DIR ? path.resolve(process.env.PROPOSAL_BOT_DATA_DIR) : path.join(ROOT, "data"); const sharedDrafts = createSharedDraftStore(DRAFTS_DIR); const AUTH_REQUIRED = /^(1|true|yes)$/i.test(String(process.env.PROPOSAL_BOT_AUTH_REQUIRED || "")); const MICROSOFT_TENANT_ID = String(process.env.MICROSOFT_TENANT_ID || "").trim(); const MICROSOFT_CLIENT_ID = String(process.env.MICROSOFT_CLIENT_ID || "").trim(); const MICROSOFT_CLIENT_SECRET = String(process.env.MICROSOFT_CLIENT_SECRET || "").trim(); const MICROSOFT_REDIRECT_URI = String(process.env.MICROSOFT_REDIRECT_URI || `http://localhost:${PORT}/auth/microsoft/callback`).trim(); const MICROSOFT_CONFIGURED = Boolean(MICROSOFT_TENANT_ID && MICROSOFT_CLIENT_ID && MICROSOFT_CLIENT_SECRET); const MICROSOFT_AUTHORITY = `https://login.microsoftonline.com/${encodeURIComponent(MICROSOFT_TENANT_ID)}`; const ALLOWED_EMAILS = new Set(String(process.env.PROPOSAL_BOT_ALLOWED_EMAILS || "") .split(/[;,\s]+/) .map((email) => email.trim().toLowerCase()) .filter(Boolean)); const SESSION_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; const SECURE_SESSION_COOKIE = MICROSOFT_REDIRECT_URI.toLowerCase().startsWith("https://"); const MIME_TYPES = { ".css": "text/css; charset=utf-8", ".gif": "image/gif", ".html": "text/html; charset=utf-8", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".js": "application/javascript; charset=utf-8", ".mjs": "application/javascript; charset=utf-8", ".json": "application/json; charset=utf-8", ".png": "image/png", ".svg": "image/svg+xml", ".webp": "image/webp" }; const PROJECT_IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".gif", ".webp"]); // Export pages embed uploaded images as data URLs, so allow more than one full-size project. const MAX_EXPORT_BYTES = 2 * MAX_DRAFT_BYTES; // Docker containers have no user namespaces for Chromium's sandbox and only 64 MB of /dev/shm. const CHROMIUM_CONTAINER_ARGS = /^(1|true|yes)$/i.test(String(process.env.CHROMIUM_NO_SANDBOX || "")) ? ["--no-sandbox", "--disable-dev-shm-usage"] : []; let idmlBaseFiles = null; let microsoftOidcCache = null; const sessions = new Map(); const pendingMicrosoftAuth = new Map(); const server = http.createServer(async (request, response) => { try { const url = new URL(request.url || "/", `http://${request.headers.host || "localhost"}`); if (request.method === "GET" && url.pathname === "/api/health") { sendJson(response, { ok: true, service: "Proposal Bot", authRequired: AUTH_REQUIRED, microsoftConfigured: MICROSOFT_CONFIGURED, projectImagesDirectory: PROJECT_IMAGES_DIR, sharedDraftsDirectory: DRAFTS_DIR }); return; } if (request.method === "GET" && url.pathname === "/auth/microsoft") { beginMicrosoftLogin(response); return; } if (request.method === "GET" && url.pathname === "/auth/microsoft/callback") { await finishMicrosoftLogin(url, response); return; } if (request.method === "POST" && url.pathname === "/api/logout") { const token = getSessionToken(request); if (token) sessions.delete(token); clearSessionCookie(response); sendJson(response, { ok: true }); return; } const user = requireOfficeAccess(request, response, url); if (!user) return; if (request.method === "GET" && url.pathname === "/api/session") { sendJson(response, { authenticated: AUTH_REQUIRED, user }); return; } if (url.pathname === "/api/drafts") { if (request.method === "GET") { sendJson(response, await sharedDrafts.list()); return; } } const draftRoute = /^\/api\/drafts\/([a-zA-Z0-9][a-zA-Z0-9_-]{0,127})$/.exec(url.pathname); if (draftRoute && (request.method === "PUT" || request.method === "DELETE")) { try { const id = draftRoute[1]; const revision = String(request.headers["if-match"] || ""); if (request.method === "PUT") { const draft = await readJsonBody(request, MAX_DRAFT_BYTES); sendJson(response, { revision: await sharedDrafts.save(id, draft, revision) }); } else { sendJson(response, { deleted: await sharedDrafts.remove(id, revision) }); } } catch (error) { if (!error.status) throw error; sendJson(response, { error: error.message }, error.status); } return; } if (request.method === "POST" && url.pathname === "/api/export-idml") { await handleInDesignExport(request, response); return; } if (request.method === "GET" && url.pathname === "/api/project-images") { await handleProjectImages(request, response); return; } if (request.method === "OPTIONS") { response.writeHead(204, corsHeaders()); response.end(); return; } serveStaticFile(request, response); } catch (error) { console.error(error); if (response.headersSent) { response.destroy(); return; } response.writeHead(500, { "Content-Type": "text/plain; charset=utf-8", ...corsHeaders() }); response.end(error && error.message ? error.message : "Unexpected server error."); } }); if (require.main === module) { server.listen(PORT, HOST, () => { console.log("Proposal Bot server running at http://" + HOST + ":" + PORT + "/"); if (AUTH_REQUIRED && (!MICROSOFT_CONFIGURED || !ALLOWED_EMAILS.size)) { console.warn("Sign-in is required but MICROSOFT_TENANT_ID, MICROSOFT_CLIENT_ID, MICROSOFT_CLIENT_SECRET or PROPOSAL_BOT_ALLOWED_EMAILS is missing. Every page will show a configuration error."); } if (AUTH_REQUIRED && !SECURE_SESSION_COOKIE) { console.warn("MICROSOFT_REDIRECT_URI is not an https:// address. Microsoft sign-in only accepts HTTPS callbacks outside localhost."); } if (!AUTH_REQUIRED) { console.warn("Sign-in is turned off. Anyone who can reach this server can read and edit every project."); } checkStorageFolders(); }); } function checkStorageFolders() { try { fs.mkdirSync(DRAFTS_DIR, { recursive: true }); const probe = path.join(DRAFTS_DIR, ".write-check-" + crypto.randomUUID()); fs.writeFileSync(probe, "ok"); fs.rmSync(probe, { force: true }); } catch (error) { console.error("Projects cannot be saved: the data folder " + DRAFTS_DIR + " is not writable by this process (" + error.code + ")."); } if (!fs.existsSync(PROJECT_IMAGES_DIR)) { console.warn("The photo library folder " + PROJECT_IMAGES_DIR + " does not exist. The Photos tab will be empty."); } } function requireOfficeAccess(request, response, url) { if (!AUTH_REQUIRED) { return { name: "Local user", email: "" }; } if (!MICROSOFT_CONFIGURED || !ALLOWED_EMAILS.size) { sendAuthConfigurationError(response, url); return null; } const token = getSessionToken(request); const session = token ? sessions.get(token) : null; if (!session || session.expiresAt < Date.now()) { if (token) sessions.delete(token); if (url.pathname.startsWith("/api/")) { sendJson(response, { error: "Please sign in." }, 401); } else { sendRedirect(response, "/auth/microsoft"); } return null; } session.expiresAt = Date.now() + SESSION_MAX_AGE_MS; return { name: session.name, email: session.email }; } function beginMicrosoftLogin(response) { if (!AUTH_REQUIRED) { sendRedirect(response, "/"); return; } if (!MICROSOFT_CONFIGURED || !ALLOWED_EMAILS.size) { sendAuthErrorPage(response, "Microsoft 365 sign-in has not been configured for Proposal Bot.", 503); return; } const state = randomBase64Url(32); const nonce = randomBase64Url(32); const verifier = randomBase64Url(48); const challenge = crypto.createHash("sha256").update(verifier).digest("base64url"); pendingMicrosoftAuth.set(state, { nonce, verifier, expiresAt: Date.now() + 10 * 60 * 1000 }); prunePendingMicrosoftAuth(); const authorizeUrl = new URL(`${MICROSOFT_AUTHORITY}/oauth2/v2.0/authorize`); authorizeUrl.search = new URLSearchParams({ client_id: MICROSOFT_CLIENT_ID, response_type: "code", redirect_uri: MICROSOFT_REDIRECT_URI, response_mode: "query", scope: "openid profile email", state, nonce, code_challenge: challenge, code_challenge_method: "S256" }).toString(); sendRedirect(response, authorizeUrl.toString()); } async function finishMicrosoftLogin(url, response) { const state = String(url.searchParams.get("state") || ""); const pending = pendingMicrosoftAuth.get(state); pendingMicrosoftAuth.delete(state); if (url.searchParams.get("error")) { sendAuthErrorPage(response, "Microsoft sign-in was canceled or could not be completed.", 401); return; } if (!pending || pending.expiresAt < Date.now()) { sendAuthErrorPage(response, "The Microsoft sign-in request expired. Please try again.", 401); return; } const code = String(url.searchParams.get("code") || ""); if (!code) { sendAuthErrorPage(response, "Microsoft did not return a sign-in code.", 401); return; } try { const tokenResponse = await fetch(`${MICROSOFT_AUTHORITY}/oauth2/v2.0/token`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ client_id: MICROSOFT_CLIENT_ID, client_secret: MICROSOFT_CLIENT_SECRET, grant_type: "authorization_code", code, redirect_uri: MICROSOFT_REDIRECT_URI, code_verifier: pending.verifier, scope: "openid profile email" }) }); const tokens = await tokenResponse.json(); if (!tokenResponse.ok || !tokens.id_token) throw new Error("Microsoft could not verify this sign-in."); const claims = await validateMicrosoftIdToken(tokens.id_token, pending.nonce); const email = String(claims.email || claims.preferred_username || "").trim().toLowerCase(); if (!email || !ALLOWED_EMAILS.has(email)) { throw new Error("Your Microsoft 365 account is not approved to use Proposal Bot."); } createSession(response, { email, name: String(claims.name || email).trim().slice(0, 160) || email }); sendRedirect(response, "/"); } catch (error) { console.error("Microsoft sign-in failed:", error && error.message ? error.message : error); sendAuthErrorPage(response, error && error.message ? error.message : "Microsoft sign-in could not be verified.", 401); } } async function validateMicrosoftIdToken(token, expectedNonce) { const parts = String(token || "").split("."); if (parts.length !== 3) throw new Error("Microsoft returned an invalid identity token."); let header; let claims; try { header = JSON.parse(Buffer.from(parts[0], "base64url").toString("utf8")); claims = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")); } catch { throw new Error("Microsoft returned an invalid identity token."); } if (header.alg !== "RS256" || !header.kid) throw new Error("Microsoft returned an unsupported identity token."); const oidc = await getMicrosoftOidcConfiguration(); let jwk = oidc.keys.find((item) => item.kid === header.kid && item.kty === "RSA"); if (!jwk) { microsoftOidcCache = null; const refreshed = await getMicrosoftOidcConfiguration(); jwk = refreshed.keys.find((item) => item.kid === header.kid && item.kty === "RSA"); } if (!jwk) throw new Error("Microsoft's signing key was not recognized."); const validSignature = crypto.verify( "RSA-SHA256", Buffer.from(`${parts[0]}.${parts[1]}`), crypto.createPublicKey({ key: jwk, format: "jwk" }), Buffer.from(parts[2], "base64url") ); const now = Math.floor(Date.now() / 1000); const audienceValid = claims.aud === MICROSOFT_CLIENT_ID || (Array.isArray(claims.aud) && claims.aud.includes(MICROSOFT_CLIENT_ID)); if (!validSignature || !audienceValid) throw new Error("Microsoft could not verify this identity token."); if (String(claims.iss || "") !== String(oidc.metadata.issuer || "")) throw new Error("The Microsoft token issuer was not recognized."); if (String(claims.tid || "").toLowerCase() !== MICROSOFT_TENANT_ID.toLowerCase()) throw new Error("Use an account from the configured Microsoft 365 organization."); if (String(claims.nonce || "") !== expectedNonce) throw new Error("The Microsoft sign-in response could not be matched to this request."); if (!Number(claims.exp) || claims.exp <= now || (claims.nbf && claims.nbf > now + 60)) throw new Error("The Microsoft identity token is expired or not active."); return claims; } async function getMicrosoftOidcConfiguration() { if (microsoftOidcCache && microsoftOidcCache.expiresAt > Date.now()) return microsoftOidcCache; const metadataResponse = await fetch(`${MICROSOFT_AUTHORITY}/v2.0/.well-known/openid-configuration`); if (!metadataResponse.ok) throw new Error("Microsoft identity metadata is unavailable."); const metadata = await metadataResponse.json(); if (!String(metadata.jwks_uri || "").startsWith("https://login.microsoftonline.com/")) throw new Error("Microsoft returned an invalid signing-key endpoint."); const keysResponse = await fetch(metadata.jwks_uri); if (!keysResponse.ok) throw new Error("Microsoft signing keys are unavailable."); const keys = await keysResponse.json(); microsoftOidcCache = { metadata, keys: Array.isArray(keys.keys) ? keys.keys : [], expiresAt: Date.now() + 60 * 60 * 1000 }; return microsoftOidcCache; } function createSession(response, user) { const token = crypto.randomBytes(32).toString("hex"); sessions.set(token, { ...user, expiresAt: Date.now() + SESSION_MAX_AGE_MS }); response.setHeader("Set-Cookie", `proposal_bot_session=${token}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${Math.floor(SESSION_MAX_AGE_MS / 1000)}${SECURE_SESSION_COOKIE ? "; Secure" : ""}`); } function clearSessionCookie(response) { response.setHeader("Set-Cookie", `proposal_bot_session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0${SECURE_SESSION_COOKIE ? "; Secure" : ""}`); } function getSessionToken(request) { const cookies = String(request.headers.cookie || "").split(";"); const item = cookies.map((cookie) => cookie.trim()).find((cookie) => cookie.startsWith("proposal_bot_session=")); return item ? item.slice(item.indexOf("=") + 1) : ""; } function randomBase64Url(bytes) { return crypto.randomBytes(bytes).toString("base64url"); } function prunePendingMicrosoftAuth() { const now = Date.now(); for (const [key, value] of pendingMicrosoftAuth) { if (value.expiresAt < now) pendingMicrosoftAuth.delete(key); } } function sendAuthConfigurationError(response, url) { const message = "Proposal Bot access has not been configured yet. Contact the administrator."; if (url.pathname.startsWith("/api/")) { sendJson(response, { error: message }, 503); } else { sendAuthErrorPage(response, message, 503); } } function sendAuthErrorPage(response, message, statusCode) { response.writeHead(statusCode, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" }); response.end(`Proposal Bot access

Proposal Bot access

${escapeHtml(String(message || "Access could not be completed."))}

`); } function sendRedirect(response, location) { response.writeHead(302, { Location: location, "Cache-Control": "no-store" }); response.end(); } function sendJson(response, payload, statusCode = 200) { response.writeHead(statusCode, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" }); response.end(JSON.stringify(payload)); } async function handleInDesignExport(request, response) { let payload; try { payload = await readJsonBody(request, MAX_EXPORT_BYTES); } catch (error) { if (!error.status) throw error; response.writeHead(error.status, { "Content-Type": "text/plain; charset=utf-8", ...corsHeaders() }); response.end(error.status === 413 ? "This proposal is too large to export." : "The export request was not valid JSON."); return; } if (!payload || !Array.isArray(payload.pages) || !payload.pages.length) { response.writeHead(400, { "Content-Type": "text/plain; charset=utf-8", ...corsHeaders() }); response.end("No proposal pages were provided for the InDesign export."); return; } const pages = payload.pages.map((page) => ({ ...page, html: localizeExportImageSources(page && page.html) })); const editablePages = await extractEditableProposalPages( pages, payload.stylesText || "", payload.bodyClasses || "", payload.baseUrl || "" ); const packageBuffer = buildEditableInDesignPackageBufferFromLayout(editablePages, payload.pageSize || {}, payload.filenameBase || "proposal"); const downloadName = safeFilename(payload.filenameBase || "proposal") + "-indesign-package.zip"; response.writeHead(200, { "Content-Type": "application/zip", "Content-Disposition": 'attachment; filename="' + downloadName + '"', ...corsHeaders() }); response.end(packageBuffer); } async function handleProjectImages(request, response) { const images = await listProjectImages(PROJECT_IMAGES_DIR, ""); images.sort((a, b) => (a.relativePath || a.name).localeCompare(b.relativePath || b.name, undefined, { numeric: true, sensitivity: "base" })); response.writeHead(200, { "Content-Type": "application/json; charset=utf-8", ...corsHeaders() }); response.end(JSON.stringify({ images })); } async function listProjectImages(rootDir, relativeDir) { let entries = []; try { entries = await fs.promises.readdir(path.join(rootDir, relativeDir), { withFileTypes: true }); } catch (error) { if (error && error.code === "ENOENT") { return []; } throw error; } const images = []; for (const entry of entries) { const nextRelativePath = relativeDir ? path.posix.join(relativeDir, entry.name) : entry.name; if (entry.isDirectory()) { images.push(...await listProjectImages(rootDir, nextRelativePath)); continue; } if (!entry.isFile() || !PROJECT_IMAGE_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) { continue; } const filePath = path.join(rootDir, nextRelativePath); const stats = await fs.promises.stat(filePath); const parentDir = path.posix.dirname(nextRelativePath); images.push({ name: entry.name, relativePath: nextRelativePath, typology: parentDir && parentDir !== "." ? path.posix.basename(parentDir) : "", url: "/Project%20Images/" + nextRelativePath.split("/").map(encodeURIComponent).join("/"), updatedAt: stats.mtime.toISOString(), size: stats.size }); } return images; } const PUBLIC_APP_FILES = new Set(["index.html", "app.js", "styles.css", "MA Logo.jpg", "version.json"]); const PROJECT_IMAGES_URL_PREFIX = "Project Images/"; function isInsideDirectory(filePath, directory) { return filePath.startsWith(path.resolve(directory) + path.sep); } // Maps a decoded URL path (without the leading slash) to the file the app serves for it, or null. function resolvePublicFilePath(relativePath) { if (PUBLIC_APP_FILES.has(relativePath)) { return path.join(ROOT, relativePath); } if (relativePath.startsWith("assets/")) { const filePath = path.resolve(ROOT, relativePath); return isInsideDirectory(filePath, path.join(ROOT, "assets")) ? filePath : null; } if (relativePath.startsWith(PROJECT_IMAGES_URL_PREFIX)) { const filePath = path.resolve(PROJECT_IMAGES_DIR, relativePath.slice(PROJECT_IMAGES_URL_PREFIX.length)); return isInsideDirectory(filePath, PROJECT_IMAGES_DIR) && PROJECT_IMAGE_EXTENSIONS.has(path.extname(filePath).toLowerCase()) ? filePath : null; } return null; } function isPublicFilePath(filePath) { const resolved = path.resolve(filePath); if (isInsideDirectory(resolved, PROJECT_IMAGES_DIR)) { return PROJECT_IMAGE_EXTENSIONS.has(path.extname(resolved).toLowerCase()); } if (isInsideDirectory(resolved, path.join(ROOT, "assets"))) { return true; } return path.dirname(resolved) === path.resolve(ROOT) && PUBLIC_APP_FILES.has(path.basename(resolved)); } function serveStaticFile(request, response) { const pathname = (request.url || "/").split("?")[0]; let relativePath; try { relativePath = pathname === "/" ? "index.html" : decodeURIComponent(pathname).replace(/^\/+/, ""); } catch { response.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" }); response.end("Invalid path"); return; } const filePath = resolvePublicFilePath(relativePath); if (!filePath) { response.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" }); response.end("Forbidden"); return; } fs.stat(filePath, (error, stats) => { if (error || !stats.isFile()) { response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); response.end("Not found"); return; } const headers = { "Content-Type": MIME_TYPES[path.extname(filePath).toLowerCase()] || "application/octet-stream", "Content-Length": stats.size }; if (relativePath.startsWith(PROJECT_IMAGES_URL_PREFIX)) { // Library URLs carry a ?v= stamp, so browsers can keep them. headers["Cache-Control"] = "private, max-age=604800"; } response.writeHead(200, headers); if (request.method === "HEAD") { response.end(); return; } fs.createReadStream(filePath).on("error", () => response.destroy()).pipe(response); }); } async function renderProposalPages(pages, stylesText, bodyClasses, baseUrl) { const scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), "proposal-bot-idml-")); const executablePath = detectChromiumExecutable(); const rendered = []; try { for (let index = 0; index < pages.length; index += 1) { const pageDef = pages[index]; const width = Math.max(1, Number(pageDef.width) || 816); const height = Math.max(1, Number(pageDef.height) || 1056); const htmlPath = path.join(scratchDir, "page-" + (index + 1) + ".html"); const pngPath = path.join(scratchDir, "page-" + (index + 1) + ".png"); const profilePath = path.join(scratchDir, "profile-" + (index + 1)); fs.writeFileSync(htmlPath, buildCaptureHtml(pageDef.html || "", stylesText, width, height, bodyClasses, baseUrl), "utf8"); await captureScreenshot(executablePath, [ "--headless", ...CHROMIUM_CONTAINER_ARGS, "--no-first-run", "--disable-background-networking", "--disable-extensions", "--disable-gpu", "--disable-gpu-compositing", "--disable-accelerated-2d-canvas", "--disable-3d-apis", "--disable-features=UseGraphiteDawn,Vulkan,CalculateNativeWinOcclusion", "--allow-file-access-from-files", "--hide-scrollbars", "--run-all-compositor-stages-before-draw", "--force-device-scale-factor=2", "--user-data-dir=" + profilePath, "--virtual-time-budget=2500", "--window-size=" + width + "," + height, "--screenshot=" + pngPath, pathToFileURL(htmlPath).href ], pngPath); rendered.push(fs.readFileSync(pngPath)); } } finally { removeScratchDirectory(scratchDir); } return rendered; } async function extractEditableProposalPages(pages, stylesText, bodyClasses, baseUrl) { const scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), "proposal-bot-layout-")); const executablePath = detectChromiumExecutable(); const extracted = []; try { for (let index = 0; index < pages.length; index += 1) { const pageDef = pages[index]; const width = Math.max(1, Number(pageDef.width) || 816); const height = Math.max(1, Number(pageDef.height) || 1056); const htmlPath = path.join(scratchDir, "page-" + (index + 1) + ".html"); const profilePath = path.join(scratchDir, "profile-" + (index + 1)); fs.writeFileSync(htmlPath, buildLayoutCaptureHtml(pageDef.html || "", stylesText, width, height, bodyClasses, baseUrl), "utf8"); const result = await runProcess(executablePath, [ "--headless", ...CHROMIUM_CONTAINER_ARGS, "--no-first-run", "--disable-background-networking", "--disable-extensions", "--disable-gpu", "--disable-gpu-compositing", "--disable-accelerated-2d-canvas", "--disable-3d-apis", "--disable-features=UseGraphiteDawn,Vulkan,CalculateNativeWinOcclusion", "--allow-file-access-from-files", "--hide-scrollbars", "--run-all-compositor-stages-before-draw", "--force-device-scale-factor=1", "--user-data-dir=" + profilePath, "--virtual-time-budget=3000", "--window-size=" + width + "," + height, "--dump-dom", pathToFileURL(htmlPath).href ], { timeoutMs: 60000 }); extracted.push(parseLayoutDump(result.stdout, index + 1, width, height)); } } finally { removeScratchDirectory(scratchDir); } return extracted; } function buildLayoutCaptureHtml(pageHtml, stylesText, width, height, bodyClasses, baseUrl) { const safeWidth = Math.max(1, Number(width) || 816); const safeHeight = Math.max(1, Number(height) || 1056); return [ "", '', "", '', baseUrl ? '' : "", "", "", '', '
', pageHtml, "
", layoutExtractionScript(), "", "" ].join(""); } function layoutExtractionScript() { return [ "" ].join(""); } function parseLayoutDump(stdout, pageNumber, width, height) { const match = String(stdout || "").match(/]+id=["']proposal-bot-layout-json["'][^>]*>([\s\S]*?)<\/pre>/i); if (!match) { const errorMatch = String(stdout || "").match(/data-layout-json-error=["']([^"']*)["']/i); const detail = errorMatch ? " " + decodeHtmlAttribute(errorMatch[1]) : ""; throw new Error("Could not read editable layout data for proposal page " + pageNumber + "." + detail); } const encoded = match[1].replace(/\s+/g, ""); let parsed = null; try { parsed = JSON.parse(Buffer.from(encoded, "base64").toString("utf8")); } catch (error) { throw new Error("The editable layout data for proposal page " + pageNumber + " could not be parsed."); } const items = Array.isArray(parsed.items) ? parsed.items : []; return { width: Number(parsed.width) || width, height: Number(parsed.height) || height, items: items.filter((item) => item && item.type && item.bounds) }; } function decodeHtmlAttribute(value) { return String(value || "") .replace(/"/g, '"') .replace(/'|'/g, "'") .replace(/</g, "<") .replace(/>/g, ">") .replace(/&/g, "&"); } async function renderProposalPageFiles(pages, stylesText, bodyClasses, baseUrl, scratchDir) { const executablePath = detectChromiumExecutable(); const pagesDir = path.join(scratchDir, "rendered-pages"); fs.mkdirSync(pagesDir, { recursive: true }); const rendered = []; for (let index = 0; index < pages.length; index += 1) { const pageDef = pages[index]; const width = Math.max(1, Number(pageDef.width) || 816); const height = Math.max(1, Number(pageDef.height) || 1056); const htmlPath = path.join(pagesDir, "page-" + (index + 1) + ".html"); const pngPath = path.join(pagesDir, "page-" + (index + 1) + ".png"); const profilePath = path.join(scratchDir, "profile-" + (index + 1)); fs.writeFileSync(htmlPath, buildCaptureHtml(pageDef.html || "", stylesText, width, height, bodyClasses, baseUrl), "utf8"); await captureScreenshot(executablePath, [ "--headless", ...CHROMIUM_CONTAINER_ARGS, "--no-first-run", "--disable-background-networking", "--disable-extensions", "--disable-gpu", "--disable-gpu-compositing", "--disable-accelerated-2d-canvas", "--disable-3d-apis", "--disable-features=UseGraphiteDawn,Vulkan,CalculateNativeWinOcclusion", "--allow-file-access-from-files", "--hide-scrollbars", "--run-all-compositor-stages-before-draw", "--force-device-scale-factor=2", "--user-data-dir=" + profilePath, "--virtual-time-budget=2500", "--window-size=" + width + "," + height, "--screenshot=" + pngPath, pathToFileURL(htmlPath).href ], pngPath); rendered.push({ filePath: pngPath, width, height }); } return rendered; } async function buildEditableNativeInDesignPackageBuffer(layoutPages, pageSize, filenameBase) { const scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), "proposal-bot-native-indesign-")); try { const safeBase = safeFilename(filenameBase || "proposal"); const packageFolderName = safeBase + "-indesign-package"; const packageDir = path.join(scratchDir, packageFolderName); const linksDir = path.join(packageDir, "Links"); const inddPath = path.join(packageDir, safeBase + ".indd"); const idmlPath = path.join(packageDir, safeBase + ".idml"); const layoutPath = path.join(scratchDir, "editable-layout.json"); const jsxPath = path.join(scratchDir, "build-editable-indesign-package.jsx"); const psPath = path.join(scratchDir, "run-editable-indesign-package.ps1"); const logPath = path.join(scratchDir, "editable-indesign-package.log"); const width = Number(pageSize.width) || 612; const height = Number(pageSize.height) || 792; fs.mkdirSync(linksDir, { recursive: true }); const layout = prepareEditableNativeLayout(layoutPages, width, height, linksDir); layout.inddPath = toInDesignPath(inddPath); layout.idmlPath = toInDesignPath(idmlPath); layout.logPath = toInDesignPath(logPath); fs.writeFileSync(layoutPath, JSON.stringify(layout), "utf8"); fs.writeFileSync(jsxPath, buildEditableNativeInDesignJsx(layoutPath), "utf8"); fs.writeFileSync(psPath, buildRunInDesignPowerShell(jsxPath), "utf8"); await runProcess("powershell.exe", [ "-STA", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", psPath ], { timeoutMs: 300000 }); if (!fs.existsSync(inddPath)) { const logText = fs.existsSync(logPath) ? fs.readFileSync(logPath, "utf8") : ""; throw new Error("InDesign did not create the editable .indd file." + (logText ? "\n" + logText : "")); } fs.writeFileSync(path.join(packageDir, "README.txt"), [ "Editable InDesign proposal package", "", "Unzip this folder before opening the InDesign file.", "Open the .indd file first. The .idml file is included as a compatibility copy.", "Keep the Links folder beside the InDesign file so all proposal photos stay linked.", "", "Open: " + safeBase + ".indd" ].join("\r\n"), "utf8"); return zipDirectoryBuffer(packageDir, packageFolderName); } finally { removeScratchDirectory(scratchDir); } } function buildEditableInDesignPackageBufferFromLayout(layoutPages, pageSize, filenameBase) { const scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), "proposal-bot-editable-idml-")); try { const safeBase = safeFilename(filenameBase || "proposal"); const folderName = safeBase + "-indesign-package"; const linksDir = path.join(scratchDir, "Links"); const width = Number(pageSize.width) || 612; const height = Number(pageSize.height) || 792; fs.mkdirSync(linksDir, { recursive: true }); const layout = prepareEditableNativeLayout(layoutPages, width, height, linksDir); const idmlBuffer = buildEditableInDesignIdmlBuffer(layout); const entries = [ { name: folderName + "/" + safeBase + ".idml", bytes: idmlBuffer }, { name: folderName + "/README.txt", bytes: Buffer.from([ "Editable InDesign proposal package", "", "Unzip this folder before opening the IDML file.", "Open the .idml file in InDesign. The proposal text is exported as editable text frames.", "Keep the Links folder beside the IDML file so all proposal photos stay linked.", "", "Open: " + safeBase + ".idml" ].join("\r\n"), "utf8") } ]; layout.assets.forEach((asset) => { entries.push({ name: folderName + "/Links/" + asset.linkName, bytes: asset.bytes }); }); return createStoredZipBuffer(entries); } finally { removeScratchDirectory(scratchDir); } } function buildEditableInDesignIdmlBuffer(layout) { const baseFiles = loadIdmlBaseFiles(); const width = Number(layout.width) || 612; const height = Number(layout.height) || 792; const spreadDescriptors = []; const storyDescriptors = []; let storyIndex = 1; let itemIndex = 1; const entries = [ { name: "mimetype", bytes: Buffer.from("application/vnd.adobe.indesign-idml-package", "utf8") }, { name: "META-INF/container.xml", bytes: Buffer.from(baseFiles["META-INF/container.xml"] || "", "utf8") }, { name: "META-INF/metadata.xml", bytes: Buffer.from(baseFiles["META-INF/metadata.xml"] || "", "utf8") }, { name: "Resources/Graphic.xml", bytes: Buffer.from(buildIdmlGraphicXml(baseFiles["Resources/Graphic.xml"] || "", layout.colors || []), "utf8") }, { name: "Resources/Fonts.xml", bytes: Buffer.from(baseFiles["Resources/Fonts.xml"] || "", "utf8") }, { name: "Resources/Styles.xml", bytes: Buffer.from(baseFiles["Resources/Styles.xml"] || "", "utf8") }, { name: "Resources/Preferences.xml", bytes: Buffer.from(baseFiles["Resources/Preferences.xml"] || "", "utf8") }, { name: "XML/Tags.xml", bytes: Buffer.from(baseFiles["XML/Tags.xml"] || "", "utf8") }, { name: "XML/BackingStory.xml", bytes: Buffer.from(baseFiles["XML/BackingStory.xml"] || "", "utf8") }, { name: "MasterSpreads/MasterSpread_uca.xml", bytes: Buffer.from(buildIdmlMasterSpreadXml(width, height), "utf8") } ]; (layout.pages || []).forEach((page, index) => { const pageNumber = index + 1; const spreadSelf = "pbSpread" + pageNumber; const pageSelf = "pbPage" + pageNumber; const spreadPath = "Spreads/Spread_" + spreadSelf + ".xml"; const pageItems = (page.items || []).map((item) => { const next = { ...item, self: "pbItem" + itemIndex }; itemIndex += 1; if (item.type === "text") { next.storySelf = "pbStory" + storyIndex; storyIndex += 1; storyDescriptors.push({ storySelf: next.storySelf }); entries.push({ name: "Stories/Story_" + next.storySelf + ".xml", bytes: Buffer.from(buildIdmlStoryXml(next), "utf8") }); } return next; }); spreadDescriptors.push({ spreadPath, pageSelf }); entries.push({ name: spreadPath, bytes: Buffer.from(buildEditableIdmlSpreadXml(spreadSelf, pageSelf, pageNumber, width, height, pageItems), "utf8") }); }); (layout.assets || []).forEach((asset) => { entries.push({ name: "Links/" + asset.linkName, bytes: asset.bytes }); }); entries.splice(2, 0, { name: "designmap.xml", bytes: Buffer.from(buildIdmlDesignmapXml(baseFiles["designmap.xml"] || "", spreadDescriptors, storyDescriptors), "utf8") }); return createStoredZipBuffer(entries); } function buildIdmlGraphicXml(template, colors) { const extraColors = (colors || []) .map((color) => normalizeHexColor(color)) .filter((color) => color && color !== "000000" && color !== "ffffff") .filter((color, index, all) => all.indexOf(color) === index) .map((color) => { const r = parseInt(color.slice(0, 2), 16); const g = parseInt(color.slice(2, 4), 16); const b = parseInt(color.slice(4, 6), 16); const self = idmlColorSelf(color); return '\t'; }); if (!extraColors.length) { return template; } return String(template || "").replace(/\n\t { if (item.type === "shape") { return buildIdmlShapeXml(item, pageX, pageY); } if (item.type === "image") { return buildIdmlImageFrameXml(item, pageX, pageY); } if (item.type === "text") { return buildIdmlTextFrameXml(item, pageX, pageY); } return ""; }).filter(Boolean).join("\n"); return [ '', '', '\t', '\t\t50', '\t\t', '\t\t\tArabictruefalse' + pageNumber + 'UseMasterColor', '\t\t\t', '\t\t\tMinion Pro', "\t\t", itemXml, "\t", "" ].join("\n"); } function buildIdmlShapeXml(item, pageX, pageY) { const b = item.bounds || {}; const x = pageX + (Number(b.x) || 0); const y = pageY + (Number(b.y) || 0); const w = Math.max(0.25, Number(b.width) || 0); const h = Math.max(0.25, Number(b.height) || 0); return [ '\t\t', "\t\t\t" + rectanglePathGeometryXml(w, h) + "", textWrapNoneXml("\t\t\t"), "\t\t" ].join("\n"); } function buildIdmlTextFrameXml(item, pageX, pageY) { const b = item.bounds || {}; const x = pageX + (Number(b.x) || 0); const y = pageY + (Number(b.y) || 0); const w = Math.max(1, Number(b.width) || 1); const h = Math.max(1, Number(b.height) || 1); return [ '\t\t', "\t\t\t" + rectanglePathGeometryXml(w, h) + "", '\t\t\t0000', textWrapNoneXml("\t\t\t"), objectExportOptionXml("\t\t\t"), "\t\t" ].join("\n"); } function buildIdmlImageFrameXml(item, pageX, pageY) { const b = item.bounds || {}; const x = pageX + (Number(b.x) || 0); const y = pageY + (Number(b.y) || 0); const w = Math.max(1, Number(b.width) || 1); const h = Math.max(1, Number(b.height) || 1); const linkName = path.basename(String(item.filePath || "").replace(/\\/g, "/")); const linkPath = "Links/" + linkName; const imageSelf = item.self + "Image"; const linkSelf = item.self + "Link"; const format = idmlImageFormat(linkName); let linkSize = "0~0"; try { const stats = fs.statSync(String(item.filePath || "")); linkSize = "0~" + Math.max(0, Number(stats.size) || 0).toString(16); } catch (error) { linkSize = "0~0"; } return [ '\t\t', "\t\t\t" + rectanglePathGeometryXml(w, h) + "", textWrapNoneXml("\t\t\t"), '\t\t\t', '\t\t\t', objectExportOptionXml("\t\t\t"), '\t\t\t', '\t\t\t\t$ID/None', textWrapNoneXml("\t\t\t\t"), '\t\t\t\t', '\t\t\t\t', '\t\t\t\t', "\t\t\t", "\t\t" ].join("\n"); } function buildIdmlCharacterStyleRangeXml(run, appendParagraphBreak) { const fontFamily = escapeXmlText(run.fontFamily || "Arial"); const fontStyle = escapeXmlAttribute(run.fontStyle || "Regular"); const pointSize = fmtPoint(run.pointSize || 12); const leading = fmtPoint(run.leading || (Number(run.pointSize) || 12) * 1.2); const fillColor = idmlSwatchForHex(run.color || "000000"); const tracking = Number(run.tracking) || 0; const parts = String(run.text || "").split("\n"); const contentXml = parts.map((part, index) => { const content = part ? "\t\t\t\t" + escapeXmlText(part) + "" : ""; return content + (index < parts.length - 1 ? "\n\t\t\t\t
" : ""); }).join("\n") + (appendParagraphBreak ? "\n\t\t\t\t
" : ""); return [ '\t\t\t', "\t\t\t\t" + fontFamily + "", contentXml, "\t\t\t" ].filter(Boolean).join("\n"); } function buildIdmlStoryXml(item) { const paragraphs = Array.isArray(item.paragraphs) && item.paragraphs.length ? item.paragraphs : [{ justification: item.justification, runs: [{ text: item.text || "", fontFamily: item.fontFamily, fontStyle: item.fontStyle, pointSize: item.pointSize, leading: item.leading, tracking: item.tracking, color: item.color }] }]; const paragraphXml = paragraphs.map((paragraph, paragraphIndex) => { const runs = Array.isArray(paragraph.runs) ? paragraph.runs : []; const runXml = runs.map((run, runIndex) => buildIdmlCharacterStyleRangeXml( run, paragraphIndex < paragraphs.length - 1 && runIndex === runs.length - 1 )).join("\n"); return [ '\t\t', runXml, "\t\t" ].join("\n"); }).join("\n"); return [ '', '', '\t', '\t\t', '\t\t', paragraphXml, "\t", "" ].join("\n"); } function rectanglePathGeometryXml(width, height) { return [ "", '' + '' + '' + '' + '' + "", "" ].join(""); } function textWrapNoneXml(indent) { return indent + ''; } function objectExportOptionXml(indent) { return indent + ''; } function idmlColorSelf(color) { return "Color/pb_" + normalizeHexColor(color); } function idmlSwatchForHex(color) { const hex = normalizeHexColor(color); if (!hex) { return "Color/Black"; } if (hex === "000000") { return "Color/Black"; } if (hex === "ffffff") { return "Color/Paper"; } return idmlColorSelf(hex); } function idmlJustification(value) { const align = String(value || "").toLowerCase(); if (align === "center") { return "CenterAlign"; } if (align === "right") { return "RightAlign"; } if (align === "justify") { return "LeftJustified"; } return "LeftAlign"; } function idmlFittingMode(value) { if (value === "contain") { return "Proportionally"; } if (value === "stretch") { return "ContentToFrame"; } return "FillProportionally"; } function idmlImageFormat(filename) { const ext = path.extname(String(filename || "")).toLowerCase(); if (ext === ".jpg" || ext === ".jpeg") { return { typeName: "$ID/JPEG", resourceFormat: "$ID/JPEG" }; } if (ext === ".gif") { return { typeName: "$ID/CompuServe GIF (GIF)", resourceFormat: "$ID/CompuServe GIF (GIF)" }; } if (ext === ".webp") { return { typeName: "$ID/WEBP", resourceFormat: "$ID/WEBP" }; } return { typeName: "$ID/Portable Network Graphics (PNG)", resourceFormat: "$ID/Portable Network Graphics (PNG)" }; } function fmtPoint(value) { const rounded = Math.round(Number(value || 0) * 1000) / 1000; return String(Math.abs(rounded) < 0.0005 ? 0 : rounded); } function escapeXmlText(value) { return String(value || "") .replace(/&/g, "&") .replace(//g, ">"); } function escapeXmlAttribute(value) { return escapeXmlText(value).replace(/"/g, """); } function prepareEditableTextStyle(style, scaleY, colorSet) { const source = style || {}; const color = normalizeHexColor(source.color || "#000000") || "000000"; colorSet.add(color); const sourceFontSize = Number(source.fontSize) || 12; const fontSize = Math.max(2, sourceFontSize * scaleY); const leading = Math.max(fontSize * 1.05, (Number(source.lineHeight) || sourceFontSize * 1.2) * scaleY); const tracking = Number(source.letterSpacing) && sourceFontSize ? Math.round((Number(source.letterSpacing) / sourceFontSize) * 1000) : 0; return { fontFamily: inDesignFontFamily(source.fontFamily), fontStyle: inDesignFontStyle(source.fontWeight, source.fontStyle, source.fontFamily), pointSize: roundNumber(fontSize), leading: roundNumber(leading), tracking, color, justification: inDesignJustification(source.textAlign) }; } function prepareEditableTextParagraphs(item, scaleY, colorSet) { const sourceParagraphs = Array.isArray(item.paragraphs) && item.paragraphs.length ? item.paragraphs : [{ style: item.style || {}, runs: [{ text: item.text || "", style: item.style || {} }] }]; return sourceParagraphs.map((paragraph) => { const paragraphStyle = prepareEditableTextStyle(paragraph.style || item.style || {}, scaleY, colorSet); const runs = (Array.isArray(paragraph.runs) ? paragraph.runs : []).map((run) => ({ ...prepareEditableTextStyle(run.style || paragraph.style || item.style || {}, scaleY, colorSet), text: String(run.text || "") })).filter((run) => run.text); return { ...paragraphStyle, runs }; }).filter((paragraph) => paragraph.runs.length); } function prepareEditableNativeLayout(layoutPages, pageWidth, pageHeight, linksDir) { const assetMap = new Map(); const usedLinkNames = new Set(); const colorSet = new Set(); const pages = layoutPages.map((page) => { const sourceWidth = Number(page.width) || 816; const sourceHeight = Number(page.height) || 1056; const scaleX = pageWidth / sourceWidth; const scaleY = pageHeight / sourceHeight; const items = []; (Array.isArray(page.items) ? page.items : []).forEach((item) => { const bounds = convertExportBounds(item.bounds, scaleX, scaleY, pageWidth, pageHeight); if (!bounds) { return; } if (item.type === "shape") { const color = normalizeHexColor(item.color); if (!color) { return; } colorSet.add(color); items.push({ type: "shape", bounds, color }); return; } if (item.type === "image") { const src = String(item.src || ""); if (!src) { return; } const asset = getOrCreatePackageImageAsset(src, linksDir, assetMap, usedLinkNames); items.push({ type: "image", bounds, filePath: toInDesignPath(asset.filePath), fit: imageFitMode(item.objectFit) }); return; } if (item.type === "text") { const paragraphs = prepareEditableTextParagraphs(item, scaleY, colorSet); if (!paragraphs.length) { return; } const baseStyle = paragraphs[0]; const fontSize = Math.max(...paragraphs.flatMap((paragraph) => paragraph.runs.map((run) => Number(run.pointSize) || 0)), Number(baseStyle.pointSize) || 12); const textBounds = padTextBounds(bounds, fontSize, pageWidth, pageHeight); const text = paragraphs.map((paragraph) => paragraph.runs.map((run) => run.text).join("")).join("\n"); items.push({ type: "text", bounds: textBounds, text, tag: item.tag || "", paragraphs, fontFamily: baseStyle.fontFamily, fontStyle: baseStyle.fontStyle, pointSize: baseStyle.pointSize, leading: baseStyle.leading, tracking: baseStyle.tracking, color: baseStyle.color, justification: baseStyle.justification }); } }); return { items }; }); return { width: roundNumber(pageWidth), height: roundNumber(pageHeight), colors: Array.from(colorSet).sort(), assets: Array.from(assetMap.values()).map((asset) => ({ filePath: asset.filePath, linkName: asset.linkName, bytes: asset.bytes, extension: asset.extension })), pages }; } function convertExportBounds(bounds, scaleX, scaleY, pageWidth, pageHeight) { if (!bounds) { return null; } const x = Math.max(0, Number(bounds.x) || 0) * scaleX; const y = Math.max(0, Number(bounds.y) || 0) * scaleY; const width = Math.max(0, Number(bounds.width) || 0) * scaleX; const height = Math.max(0, Number(bounds.height) || 0) * scaleY; const right = Math.min(pageWidth, x + width); const bottom = Math.min(pageHeight, y + height); const safeWidth = right - Math.min(pageWidth, x); const safeHeight = bottom - Math.min(pageHeight, y); if (safeWidth <= 0.25 || safeHeight <= 0.25) { return null; } return { x: roundNumber(Math.min(pageWidth, x)), y: roundNumber(Math.min(pageHeight, y)), width: roundNumber(safeWidth), height: roundNumber(safeHeight) }; } function padTextBounds(bounds, fontSize, pageWidth, pageHeight) { const topPad = Math.max(1, fontSize * 0.2); const bottomPad = Math.max(2, fontSize * 0.45); const sidePad = 0.5; const x = Math.max(0, bounds.x - sidePad); const y = Math.max(0, bounds.y - topPad); const right = Math.min(pageWidth, bounds.x + bounds.width + sidePad); const bottom = Math.min(pageHeight, bounds.y + bounds.height + bottomPad); return { x: roundNumber(x), y: roundNumber(y), width: roundNumber(Math.max(1, right - x)), height: roundNumber(Math.max(fontSize * 1.2, bottom - y)) }; } function getOrCreatePackageImageAsset(src, linksDir, assetMap, usedLinkNames) { if (assetMap.has(src)) { return assetMap.get(src); } const source = readImageSource(src); if (!source) { throw new Error("The InDesign export could not find this linked proposal image: " + src); } const extension = source.extension || ".png"; const baseName = safeFilename(source.name ? path.basename(source.name, path.extname(source.name)) : "proposal-image"); let linkName = baseName + extension; let counter = 1; while (usedLinkNames.has(linkName.toLowerCase())) { linkName = baseName + "-" + counter + extension; counter += 1; } usedLinkNames.add(linkName.toLowerCase()); const filePath = path.join(linksDir, linkName); fs.writeFileSync(filePath, source.bytes); const asset = { filePath, linkName, bytes: source.bytes, extension }; assetMap.set(src, asset); return asset; } function readImageSource(src) { const dataMatch = String(src || "").match(/^data:([^;,]+)?(;base64)?,([\s\S]*)$/i); if (dataMatch) { const mime = (dataMatch[1] || "image/png").toLowerCase(); const bytes = dataMatch[2] ? Buffer.from(dataMatch[3], "base64") : Buffer.from(decodeURIComponent(dataMatch[3]), "utf8"); return { bytes, extension: extensionForMime(mime), name: "proposal-image" + extensionForMime(mime) }; } const filePath = localFilePathFromImageSource(src); if (!filePath || !fs.existsSync(filePath)) { return null; } const extension = supportedImageExtension(path.extname(filePath).toLowerCase()); return { bytes: fs.readFileSync(filePath), extension, name: path.basename(filePath) }; } function localFilePathFromImageSource(src) { const value = String(src || ""); try { const url = new URL(value); if (url.protocol === "file:") { const filePath = fileURLToPath(url); return isPublicFilePath(filePath) ? path.resolve(filePath) : null; } if (url.protocol === "http:" || url.protocol === "https:") { // The browser sends absolute URLs on whatever hostname staff use (or the proxy's), so map by path only. return resolvePublicFilePath(decodeURIComponent(url.pathname || "").replace(/^\/+/, "")); } } catch (error) { if (path.isAbsolute(value) && isPublicFilePath(value)) { return path.resolve(value); } } return null; } // Headless Chromium has no sign-in cookie and may not reach the public hostname from inside a container, // so point image sources that belong to this app at the files on disk. function localizeExportImageSources(pageHtml) { return String(pageHtml || "").replace(/(]*?\ssrc=)(["'])([^"']*)\2/gi, (match, prefix, quote, src) => { const decodedSrc = decodeHtmlAttribute(src); if (/^(?:data|blob|file):/i.test(decodedSrc)) { return match; } const filePath = localFilePathFromImageSource(decodedSrc); if (!filePath || !fs.existsSync(filePath)) { return match; } return prefix + quote + escapeHtmlAttribute(pathToFileURL(filePath).href) + quote; }); } function extensionForMime(mime) { const normalized = String(mime || "").toLowerCase(); if (normalized.includes("jpeg") || normalized.includes("jpg")) { return ".jpg"; } if (normalized.includes("gif")) { return ".gif"; } if (normalized.includes("webp")) { return ".webp"; } return ".png"; } function supportedImageExtension(extension) { const normalized = String(extension || "").toLowerCase(); if ([".jpg", ".jpeg", ".png", ".gif", ".webp"].includes(normalized)) { return normalized === ".jpeg" ? ".jpg" : normalized; } return ".png"; } function normalizeHexColor(value) { const match = String(value || "").trim().match(/^#?([0-9a-f]{6})$/i); return match ? match[1].toLowerCase() : ""; } function imageFitMode(value) { const fit = String(value || "").toLowerCase(); if (fit === "contain" || fit === "scale-down") { return "contain"; } if (fit === "fill") { return "stretch"; } return "cover"; } function inDesignFontFamily(value) { const family = String(value || "").replace(/["']/g, "").trim(); const lower = family.toLowerCase(); if (lower.includes("georgia")) { return "Georgia"; } if (lower.includes("times")) { return "Times New Roman"; } if (lower.includes("arial narrow") || lower.includes("franklin") || lower.includes("impact")) { return "Arial Narrow"; } if (lower.includes("century gothic")) { return "Century Gothic"; } return family || "Arial"; } function inDesignFontStyle(weight, style, family) { const numericWeight = parseInt(weight, 10) || 400; const italic = /italic|oblique/i.test(String(style || "")); const bold = numericWeight >= 600 || /bold|black/i.test(String(weight || "")); const fontFamily = String(family || "").toLowerCase(); if (fontFamily.includes("arial narrow")) { if (bold && italic) { return "Narrow Bold Italic"; } if (bold) { return "Narrow Bold"; } if (italic) { return "Narrow Italic"; } return "Narrow"; } if (bold && italic) { return "Bold Italic"; } if (bold) { return "Bold"; } if (italic) { return "Italic"; } return "Regular"; } function inDesignJustification(value) { const align = String(value || "").toLowerCase(); if (align === "center") { return "center"; } if (align === "right" || align === "end") { return "right"; } if (align === "justify") { return "justify"; } return "left"; } function roundNumber(value) { return Math.round(Number(value || 0) * 1000) / 1000; } function buildEditableNativeInDesignJsx(layoutJsonPath) { return [ "#target indesign", "(function () {", " var dataPath = " + jsxString(toInDesignPath(layoutJsonPath)) + ";", " var dataFile = File(dataPath);", " dataFile.encoding = 'UTF-8';", " if (!dataFile.open('r')) { throw new Error('Could not open editable layout data.'); }", " var dataText = dataFile.read();", " dataFile.close();", " var data = JSON && JSON.parse ? JSON.parse(dataText) : eval('(' + dataText + ')');", " var logFile = File(data.logPath);", " function log(message) {", " try { logFile.encoding = 'UTF-8'; logFile.open('a'); logFile.writeln(message); logFile.close(); } catch (ignored) {}", " }", " function colorFromHex(doc, hex) {", " hex = String(hex || '000000').replace(/^#/, '').toLowerCase();", " if (hex === 'ffffff') { return doc.swatches.itemByName('Paper'); }", " if (hex === '000000') { return doc.swatches.itemByName('Black'); }", " var name = 'PB #' + hex.toUpperCase();", " var existing = doc.colors.itemByName(name);", " if (existing && existing.isValid) { return existing; }", " var r = parseInt(hex.substr(0, 2), 16);", " var g = parseInt(hex.substr(2, 2), 16);", " var b = parseInt(hex.substr(4, 2), 16);", " return doc.colors.add({ name: name, model: ColorModel.PROCESS, space: ColorSpace.RGB, colorValue: [r, g, b] });", " }", " function none(doc) { return doc.swatches.itemByName('None'); }", " function bounds(item) {", " var b = item.bounds || {};", " return [Number(b.y) || 0, Number(b.x) || 0, (Number(b.y) || 0) + (Number(b.height) || 0), (Number(b.x) || 0) + (Number(b.width) || 0)];", " }", " function applyJustification(text, value) {", " if (value === 'center') { text.justification = Justification.CENTER_ALIGN; return; }", " if (value === 'right') { text.justification = Justification.RIGHT_ALIGN; return; }", " if (value === 'justify') { text.justification = Justification.FULLY_JUSTIFIED; return; }", " text.justification = Justification.LEFT_ALIGN;", " }", " function applyFont(text, item) {", " var family = item.fontFamily || 'Arial';", " try { text.appliedFont = family; } catch (fontError) { try { text.appliedFont = 'Arial'; } catch (ignored) {} }", " try { text.fontStyle = item.fontStyle || 'Regular'; } catch (styleError) { try { text.fontStyle = 'Regular'; } catch (ignoredStyle) {} }", " }", " try {", " app.scriptPreferences.userInteractionLevel = UserInteractionLevels.NEVER_INTERACT;", " var doc = app.documents.add();", " doc.viewPreferences.horizontalMeasurementUnits = MeasurementUnits.POINTS;", " doc.viewPreferences.verticalMeasurementUnits = MeasurementUnits.POINTS;", " doc.documentPreferences.facingPages = false;", " doc.documentPreferences.pageWidth = String(data.width) + 'pt';", " doc.documentPreferences.pageHeight = String(data.height) + 'pt';", " while (doc.pages.length < data.pages.length) { doc.pages.add(LocationOptions.AT_END); }", " while (doc.pages.length > data.pages.length) { doc.pages.item(doc.pages.length - 1).remove(); }", " for (var p = 0; p < data.pages.length; p += 1) {", " var page = doc.pages.item(p);", " page.marginPreferences.top = 0;", " page.marginPreferences.bottom = 0;", " page.marginPreferences.left = 0;", " page.marginPreferences.right = 0;", " var items = data.pages[p].items || [];", " for (var i = 0; i < items.length; i += 1) {", " var item = items[i];", " if (item.type === 'shape') {", " var shape = page.rectangles.add();", " shape.geometricBounds = bounds(item);", " shape.strokeWeight = 0;", " shape.strokeColor = none(doc);", " shape.fillColor = colorFromHex(doc, item.color);", " } else if (item.type === 'image') {", " var frame = page.rectangles.add();", " frame.geometricBounds = bounds(item);", " frame.strokeWeight = 0;", " frame.strokeColor = none(doc);", " frame.fillColor = none(doc);", " var imageFile = File(item.filePath);", " if (!imageFile.exists) { throw new Error('Missing linked image: ' + item.filePath); }", " frame.place(imageFile);", " if (item.fit === 'contain') { frame.fit(FitOptions.PROPORTIONALLY); }", " else if (item.fit === 'stretch') { frame.fit(FitOptions.CONTENT_TO_FRAME); }", " else { frame.fit(FitOptions.FILL_PROPORTIONALLY); }", " frame.fit(FitOptions.CENTER_CONTENT);", " } else if (item.type === 'text') {", " var tf = page.textFrames.add();", " tf.geometricBounds = bounds(item);", " tf.strokeWeight = 0;", " tf.strokeColor = none(doc);", " tf.fillColor = none(doc);", " tf.textFramePreferences.insetSpacing = [0, 0, 0, 0];", " tf.contents = item.text || '';", " var text = tf.texts.item(0);", " applyFont(text, item);", " text.pointSize = Number(item.pointSize) || 12;", " if (Number(item.leading) > 0) { text.leading = Number(item.leading); }", " if (Number(item.tracking)) { text.tracking = Number(item.tracking); }", " text.fillColor = colorFromHex(doc, item.color);", " applyJustification(text, item.justification);", " }", " }", " }", " var inddFile = File(data.inddPath);", " doc.save(inddFile);", " doc.exportFile(ExportFormat.INDESIGN_MARKUP, File(data.idmlPath), false);", " doc.close(SaveOptions.YES);", " log('OK created editable InDesign package: ' + inddFile.fsName);", " } catch (error) {", " try { log('ERROR ' + error + ' line ' + (error && error.line ? error.line : '')); } catch (ignored) {}", " throw error;", " }", "}());" ].join("\n"); } async function buildNativeInDesignPackageBuffer(pageFiles, pageSize, filenameBase, scratchDir) { const safeBase = safeFilename(filenameBase || "proposal"); const packageFolderName = safeBase + "-indesign-package"; const packageDir = path.join(scratchDir, packageFolderName); const linksDir = path.join(packageDir, "Links"); const inddPath = path.join(packageDir, safeBase + ".indd"); const jsxPath = path.join(scratchDir, "build-indesign-package.jsx"); const psPath = path.join(scratchDir, "run-indesign-package.ps1"); const logPath = path.join(scratchDir, "indesign-package.log"); const width = Number(pageSize.width) || 612; const height = Number(pageSize.height) || 792; fs.mkdirSync(linksDir, { recursive: true }); const linkFiles = pageFiles.map((page, index) => { const linkPath = path.join(linksDir, "page-" + (index + 1) + ".png"); fs.copyFileSync(page.filePath, linkPath); return linkPath; }); fs.writeFileSync(jsxPath, buildNativeInDesignPackageJsx({ inddPath, linkFiles, logPath, width, height }), "utf8"); fs.writeFileSync(psPath, buildRunInDesignPowerShell(jsxPath), "utf8"); await runProcess("powershell.exe", [ "-STA", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", psPath ], { timeoutMs: 180000 }); if (!fs.existsSync(inddPath)) { const logText = fs.existsSync(logPath) ? fs.readFileSync(logPath, "utf8") : ""; throw new Error("InDesign did not create the packaged .indd file." + (logText ? "\n" + logText : "")); } fs.writeFileSync(path.join(packageDir, "README.txt"), [ "InDesign package export", "", "Open the .indd file in this folder.", "Keep the Links folder beside the .indd file so InDesign can resolve the rendered proposal pages.", "", "Open: " + safeBase + ".indd" ].join("\r\n"), "utf8"); return zipDirectoryBuffer(packageDir, packageFolderName); } function buildNativeInDesignPackageJsx(options) { const linkArray = options.linkFiles.map((filePath) => jsxString(toInDesignPath(filePath))).join(","); const width = Number(options.width) || 612; const height = Number(options.height) || 792; return [ "#target indesign", "(function () {", " var logFile = File(" + jsxString(toInDesignPath(options.logPath)) + ");", " function log(message) {", " logFile.encoding = 'UTF-8';", " logFile.open('a');", " logFile.writeln(message);", " logFile.close();", " }", " try {", " app.scriptPreferences.userInteractionLevel = UserInteractionLevels.NEVER_INTERACT;", " var pageWidth = " + JSON.stringify(String(width) + "pt") + ";", " var pageHeight = " + JSON.stringify(String(height) + "pt") + ";", " var pageWidthPoints = " + JSON.stringify(width) + ";", " var pageHeightPoints = " + JSON.stringify(height) + ";", " var inddFile = File(" + jsxString(toInDesignPath(options.inddPath)) + ");", " var linkFiles = [" + linkArray + "].map(function (item) { return File(item); });", " var doc = app.documents.add();", " doc.viewPreferences.horizontalMeasurementUnits = MeasurementUnits.POINTS;", " doc.viewPreferences.verticalMeasurementUnits = MeasurementUnits.POINTS;", " doc.documentPreferences.facingPages = false;", " doc.documentPreferences.pageWidth = pageWidth;", " doc.documentPreferences.pageHeight = pageHeight;", " while (doc.pages.length < linkFiles.length) { doc.pages.add(LocationOptions.AT_END); }", " while (doc.pages.length > linkFiles.length) { doc.pages.item(doc.pages.length - 1).remove(); }", " for (var i = 0; i < linkFiles.length; i += 1) {", " var page = doc.pages.item(i);", " page.marginPreferences.top = 0;", " page.marginPreferences.bottom = 0;", " page.marginPreferences.left = 0;", " page.marginPreferences.right = 0;", " var frame = page.rectangles.add();", " frame.geometricBounds = [0, 0, pageHeightPoints, pageWidthPoints];", " frame.strokeWeight = 0;", " frame.strokeColor = doc.swatches.itemByName('None');", " frame.fillColor = doc.swatches.itemByName('None');", " frame.place(linkFiles[i]);", " frame.fit(FitOptions.CONTENT_TO_FRAME);", " }", " doc.save(inddFile);", " doc.close(SaveOptions.YES);", " log('OK created ' + inddFile.fsName);", " } catch (error) {", " try { log('ERROR ' + error + ' line ' + (error && error.line ? error.line : '')); } catch (ignored) {}", " throw error;", " }", "}());" ].join("\n"); } function buildRunInDesignPowerShell(jsxPath) { return [ "$ErrorActionPreference = 'Stop'", "$progIds = @('InDesign.Application.2026', 'InDesign.Application.2025', 'InDesign.Application.2024', 'InDesign.Application')", "$app = $null", "$lastError = $null", "foreach ($progId in $progIds) {", " try {", " $app = New-Object -ComObject $progId", " break", " } catch {", " $lastError = $_", " }", "}", "if ($null -eq $app) { throw $lastError }", "$script = Get-Content -LiteralPath " + psString(jsxPath) + " -Raw", "$arguments = [object[]]@()", "$app.DoScript($script, 1246973031, $arguments, 1699963733, 'Proposal Bot InDesign Export')" ].join("\r\n"); } function zipDirectoryBuffer(directoryPath, rootName) { const entries = []; collectZipEntries(directoryPath, rootName, entries); return createStoredZipBuffer(entries); } function collectZipEntries(directoryPath, zipPath, entries) { fs.readdirSync(directoryPath, { withFileTypes: true }).forEach((entry) => { const fullPath = path.join(directoryPath, entry.name); const entryPath = zipPath + "/" + entry.name; if (entry.isDirectory()) { collectZipEntries(fullPath, entryPath, entries); return; } entries.push({ name: entryPath.replace(/\\/g, "/"), bytes: fs.readFileSync(fullPath) }); }); } function runProcess(command, args, options) { const timeoutMs = options && options.timeoutMs ? options.timeoutMs : 30000; return new Promise((resolve, reject) => { const child = spawn(command, args, { windowsHide: true }); let stdout = ""; let stderr = ""; let settled = false; const timer = setTimeout(() => { if (settled) { return; } settled = true; child.kill(); reject(new Error(command + " timed out.")); }, timeoutMs); child.stdout.on("data", (chunk) => { stdout += chunk.toString(); }); child.stderr.on("data", (chunk) => { stderr += chunk.toString(); }); child.on("error", (error) => { if (settled) { return; } settled = true; clearTimeout(timer); reject(error); }); child.on("close", (code) => { if (settled) { return; } settled = true; clearTimeout(timer); if (code === 0) { resolve({ stdout, stderr }); return; } reject(new Error((stderr || stdout || command + " failed with exit code " + code).trim())); }); }); } function toInDesignPath(value) { return String(value || "").replace(/\\/g, "/"); } function jsxString(value) { return JSON.stringify(String(value || "")); } function psString(value) { return "'" + String(value || "").replace(/'/g, "''") + "'"; } function buildCaptureHtml(pageHtml, stylesText, width, height, bodyClasses, baseUrl) { const safeWidth = Math.max(1, Number(width) || 816); const safeHeight = Math.max(1, Number(height) || 1056); return [ "", '', "", '', baseUrl ? '' : "", "", "", '', '
', pageHtml, "
", captureReadyScript(), "", "" ].join(""); } function captureOverrideCss(width, height) { return [ ".proposal-page{width:" + width + "px!important;height:" + height + "px!important;min-height:" + height + "px!important;margin:0!important;border:0!important;box-shadow:none!important;break-after:auto!important;page-break-after:auto!important;}", ".proposal-page:last-child{break-after:auto!important;page-break-after:auto!important;}", ".page-inner,.structured-page-inner{width:" + width + "px!important;height:" + height + "px!important;}", ".photo-slot-tools{display:none!important;}", "img{max-width:none;}" ].join("\n"); } function captureReadyScript() { return [ "" ].join(""); } function detectChromiumExecutable() { const candidates = [ process.env.CHROMIUM_EXECUTABLE, "/usr/bin/chromium", "/usr/bin/chromium-browser", "C:/Program Files/Google/Chrome/Application/chrome.exe", "C:/Program Files (x86)/Google/Chrome/Application/chrome.exe", "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe", "C:/Program Files/Microsoft/Edge/Application/msedge.exe" ]; const match = candidates.find((candidate) => fs.existsSync(candidate)); if (!match) { throw new Error("No Chromium browser executable was found for headless InDesign export."); } return match; } function loadIdmlBaseFiles() { if (idmlBaseFiles) { return idmlBaseFiles; } const source = fs.readFileSync(path.join(ROOT, "indesign-template.js"), "utf8"); const match = source.match(/window\.PROPOSAL_BOT_IDML_BASE_FILES\s*=\s*(\{[\s\S]*\});/); if (!match) { throw new Error("Could not load the base InDesign package resources."); } idmlBaseFiles = JSON.parse(match[1]); return idmlBaseFiles; } function buildInDesignIdmlBuffer(jpegPages, pageSize) { const baseFiles = loadIdmlBaseFiles(); const width = Number(pageSize.width) || 612; const height = Number(pageSize.height) || 792; const spreadDescriptors = []; const entries = [ { name: "mimetype", bytes: Buffer.from("application/vnd.adobe.indesign-idml-package", "utf8") }, { name: "META-INF/container.xml", bytes: Buffer.from(baseFiles["META-INF/container.xml"] || "", "utf8") }, { name: "META-INF/metadata.xml", bytes: Buffer.from(baseFiles["META-INF/metadata.xml"] || "", "utf8") }, { name: "Resources/Graphic.xml", bytes: Buffer.from(baseFiles["Resources/Graphic.xml"] || "", "utf8") }, { name: "Resources/Fonts.xml", bytes: Buffer.from(baseFiles["Resources/Fonts.xml"] || "", "utf8") }, { name: "Resources/Styles.xml", bytes: Buffer.from(baseFiles["Resources/Styles.xml"] || "", "utf8") }, { name: "Resources/Preferences.xml", bytes: Buffer.from(baseFiles["Resources/Preferences.xml"] || "", "utf8") }, { name: "XML/Tags.xml", bytes: Buffer.from(baseFiles["XML/Tags.xml"] || "", "utf8") }, { name: "XML/BackingStory.xml", bytes: Buffer.from(baseFiles["XML/BackingStory.xml"] || "", "utf8") }, { name: "MasterSpreads/MasterSpread_uca.xml", bytes: Buffer.from(buildIdmlMasterSpreadXml(width, height), "utf8") } ]; jpegPages.forEach((jpegBuffer, index) => { const pageNumber = index + 1; const spreadSelf = "us" + pageNumber; const pageSelf = "up" + pageNumber; const spreadPath = "Spreads/Spread_" + spreadSelf + ".xml"; const imagePath = "Links/page-" + pageNumber + ".png"; spreadDescriptors.push({ spreadPath, pageSelf }); entries.push({ name: spreadPath, bytes: Buffer.from(buildIdmlSpreadXml(spreadSelf, pageSelf, pageNumber, width, height, imagePath, jpegBuffer.length), "utf8") }); entries.push({ name: imagePath, bytes: jpegBuffer }); }); entries.splice(2, 0, { name: "designmap.xml", bytes: Buffer.from(buildIdmlDesignmapXml(baseFiles["designmap.xml"] || "", spreadDescriptors), "utf8") }); return createStoredZipBuffer(entries); } function buildInDesignPackageBuffer(jpegPages, pageSize, filenameBase) { const safeBase = safeFilename(filenameBase || "proposal"); const folderName = safeBase + "-indesign-package"; const idmlBuffer = buildInDesignIdmlBuffer(jpegPages, pageSize); const entries = [ { name: folderName + "/" + safeBase + ".idml", bytes: idmlBuffer }, { name: folderName + "/README.txt", bytes: Buffer.from([ "InDesign package export", "", "Unzip this folder before opening the IDML file.", "Keep the Links folder beside the IDML file so InDesign can resolve the rendered proposal pages.", "", "Open: " + safeBase + ".idml" ].join("\r\n"), "utf8") } ]; jpegPages.forEach((jpegBuffer, index) => { entries.push({ name: folderName + "/Links/page-" + (index + 1) + ".png", bytes: jpegBuffer }); }); return createStoredZipBuffer(entries); } function buildIdmlDesignmapXml(template, spreadDescriptors, storyDescriptors) { let xml = template || ""; xml = xml.replace(/\n\t/g, ""); const refs = [''] .concat(spreadDescriptors.map((descriptor) => '')) .concat((storyDescriptors || []).map((descriptor) => '')) .join("\n\t"); const storyList = (storyDescriptors || []).map((descriptor) => descriptor.storySelf).join(" "); xml = xml.replace(/StoryList="[^"]*"/, 'StoryList="' + storyList + '"'); xml = xml.replace(/\n\t[\s\S]*?(\n\t
', '', '\t', '\t\tUseMasterColor', '\t\t', '\t\t\tUseMasterColor', '\t\t\t', '\t\t\tMinion Pro', "\t\t", "\t", "" ].join("\n"); } function buildIdmlSpreadXml(spreadSelf, pageSelf, pageNumber, width, height, imagePath, imageSize) { const pageX = -width / 2; const pageY = -height / 2; const frameX = pageX + 5; const frameY = pageY + 5; const frameRight = width - 5; const frameBottom = height - 5; const rectangleSelf = "ur" + pageNumber; const imageSelf = "ui" + pageNumber; const linkSelf = "ul" + pageNumber; const linkPath = imagePath; const linkSize = "0~" + Math.max(0, Number(imageSize) || 0).toString(16); return [ '', '', '\t', '\t\t50', '\t\t', '\t\t\tArabictruefalse' + pageNumber + 'UseMasterColor', '\t\t\t', '\t\t\tMinion Pro', "\t\t", '\t\t', "\t\t\t", "\t\t\t\t", '\t\t\t\t\t' + '' + '' + '' + '' + "", "\t\t\t\t", "\t\t\t", '\t\t\t', '\t\t\t', '\t\t\t', '\t\t\t', '\t\t\t', '\t\t\t\t$ID/None', '\t\t\t\t', '\t\t\t\t', '\t\t\t\t', '\t\t\t\t', "\t\t\t", "\t\t", "\t", "" ].join("\n"); } function createStoredZipBuffer(entries) { const timestamp = new Date(); const date = dosDate(timestamp); const time = dosTime(timestamp); const localChunks = []; const centralChunks = []; let offset = 0; entries.forEach((entry) => { const nameBytes = Buffer.from(entry.name, "utf8"); const dataBytes = Buffer.isBuffer(entry.bytes) ? entry.bytes : Buffer.from(entry.bytes); const crc = crc32(dataBytes); const localHeader = Buffer.alloc(30 + nameBytes.length); writeUInt32LE(localHeader, 0, 0x04034b50); writeUInt16LE(localHeader, 4, 20); writeUInt16LE(localHeader, 6, 0); writeUInt16LE(localHeader, 8, 0); writeUInt16LE(localHeader, 10, time); writeUInt16LE(localHeader, 12, date); writeUInt32LE(localHeader, 14, crc); writeUInt32LE(localHeader, 18, dataBytes.length); writeUInt32LE(localHeader, 22, dataBytes.length); writeUInt16LE(localHeader, 26, nameBytes.length); writeUInt16LE(localHeader, 28, 0); nameBytes.copy(localHeader, 30); localChunks.push(localHeader, dataBytes); const centralHeader = Buffer.alloc(46 + nameBytes.length); writeUInt32LE(centralHeader, 0, 0x02014b50); writeUInt16LE(centralHeader, 4, 20); writeUInt16LE(centralHeader, 6, 20); writeUInt16LE(centralHeader, 8, 0); writeUInt16LE(centralHeader, 10, 0); writeUInt16LE(centralHeader, 12, time); writeUInt16LE(centralHeader, 14, date); writeUInt32LE(centralHeader, 16, crc); writeUInt32LE(centralHeader, 20, dataBytes.length); writeUInt32LE(centralHeader, 24, dataBytes.length); writeUInt16LE(centralHeader, 28, nameBytes.length); writeUInt16LE(centralHeader, 30, 0); writeUInt16LE(centralHeader, 32, 0); writeUInt16LE(centralHeader, 34, 0); writeUInt16LE(centralHeader, 36, 0); writeUInt32LE(centralHeader, 38, 0); writeUInt32LE(centralHeader, 42, offset); nameBytes.copy(centralHeader, 46); centralChunks.push(centralHeader); offset += localHeader.length + dataBytes.length; }); const centralDirectory = Buffer.concat(centralChunks); const endRecord = Buffer.alloc(22); writeUInt32LE(endRecord, 0, 0x06054b50); writeUInt16LE(endRecord, 4, 0); writeUInt16LE(endRecord, 6, 0); writeUInt16LE(endRecord, 8, entries.length); writeUInt16LE(endRecord, 10, entries.length); writeUInt32LE(endRecord, 12, centralDirectory.length); writeUInt32LE(endRecord, 16, offset); writeUInt16LE(endRecord, 20, 0); return Buffer.concat(localChunks.concat([centralDirectory, endRecord])); } function writeUInt16LE(buffer, offset, value) { buffer.writeUInt16LE(value & 0xffff, offset); } function writeUInt32LE(buffer, offset, value) { buffer.writeUInt32LE(value >>> 0, offset); } function dosTime(date) { return (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2); } function dosDate(date) { return ((date.getFullYear() - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate(); } function crc32(bytes) { if (!crc32.table) { crc32.table = []; for (let i = 0; i < 256; i += 1) { let c = i; for (let j = 0; j < 8; j += 1) { c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; } crc32.table[i] = c >>> 0; } } let crc = 0xffffffff; for (let index = 0; index < bytes.length; index += 1) { crc = crc32.table[(crc ^ bytes[index]) & 255] ^ (crc >>> 8); } return (crc ^ 0xffffffff) >>> 0; } function corsHeaders() { return { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Headers": "Content-Type", "Access-Control-Allow-Methods": "GET,POST,OPTIONS" }; } function readJsonBody(request, maxBytes = Infinity) { return new Promise((resolve, reject) => { const chunks = []; let bytes = 0; let tooLarge = false; request.on("data", (chunk) => { bytes += chunk.length; if (bytes > maxBytes) { tooLarge = true; return; } if (!tooLarge) chunks.push(chunk); }); request.on("end", () => { if (tooLarge) { const error = new Error("Request exceeds the save limit."); error.status = 413; reject(error); return; } try { const text = Buffer.concat(chunks).toString("utf8").replace(/^\uFEFF/, ""); resolve(text ? JSON.parse(text) : {}); } catch (error) { error.status = 400; reject(error); } }); request.on("error", reject); }); } function captureScreenshot(command, args, screenshotPath) { return new Promise((resolve, reject) => { const child = spawn(command, args, { windowsHide: true }); const startedAt = Date.now(); const timeoutMs = 30000; let stdout = ""; let stderr = ""; let lastSize = -1; let stableTicks = 0; let settled = false; function cleanup() { clearInterval(interval); child.stdout.removeAllListeners(); child.stderr.removeAllListeners(); child.removeAllListeners(); } function screenshotIsStable() { try { const stats = fs.statSync(screenshotPath); if (!stats.size) { return false; } if (stats.size === lastSize) { stableTicks += 1; } else { lastSize = stats.size; stableTicks = 0; } return stableTicks >= 2; } catch (error) { return false; } } function resolveWithScreenshot() { if (settled) { return; } settled = true; cleanup(); if (!child.killed) { child.kill(); } resolve(); } function rejectCapture(message) { if (settled) { return; } settled = true; cleanup(); if (!child.killed) { child.kill(); } reject(new Error(message)); } const interval = setInterval(() => { if (screenshotIsStable()) { resolveWithScreenshot(); return; } if (Date.now() - startedAt > timeoutMs) { if (fs.existsSync(screenshotPath)) { resolveWithScreenshot(); return; } rejectCapture((stderr || stdout || "Headless browser capture timed out.").trim()); } }, 250); child.stdout.on("data", (chunk) => { stdout += chunk.toString(); }); child.stderr.on("data", (chunk) => { stderr += chunk.toString(); }); child.on("error", (error) => { rejectCapture(error && error.message ? error.message : "Headless browser capture failed."); }); child.on("exit", (code) => { if (settled) { return; } if (code === 0 || fs.existsSync(screenshotPath)) { resolveWithScreenshot(); return; } rejectCapture((stderr || stdout || "Headless browser capture failed.").trim()); }); }); } function escapeHtmlAttribute(value) { return String(value || "") .replace(/&/g, "&") .replace(/"/g, """) .replace(//g, ">"); } function escapeHtml(value) { return escapeHtmlAttribute(value).replace(/'/g, "'"); } function safeFilename(value) { return String(value || "proposal").replace(/[^a-z0-9-_]+/gi, "-").replace(/^-+|-+$/g, "") || "proposal"; } function removeScratchDirectory(directoryPath) { try { fs.rmSync(directoryPath, { recursive: true, force: true, maxRetries: 8, retryDelay: 250 }); } catch (error) { console.warn("Could not completely remove temporary Proposal Bot export files:", directoryPath, error && error.message ? error.message : error); } } module.exports = { buildLayoutCaptureHtml, buildEditableInDesignPackageBufferFromLayout, buildEditableInDesignIdmlBuffer, buildIdmlStoryXml, detectChromiumExecutable, layoutExtractionScript, parseLayoutDump, prepareEditableNativeLayout, runProcess };