CoolFace
Apppublic

Pq234/robot-learning-tutorial

sourceHugging Faceupdated 11mo agoView on Hugging Face
0likes
export-pdf.mjs484 linesDownload Raw Back to scripts
1#!/usr/bin/env node2import { spawn } from 'node:child_process';3import { setTimeout as delay } from 'node:timers/promises';4import { chromium } from 'playwright';5import { resolve } from 'node:path';6import { promises as fs } from 'node:fs';7import process from 'node:process';8 9async function run(command, args = [], options = {}) {10  return new Promise((resolvePromise, reject) => {11    const child = spawn(command, args, { stdio: 'inherit', shell: false, ...options });12    child.on('error', reject);13    child.on('exit', (code) => {14      if (code === 0) resolvePromise(undefined);15      else reject(new Error(`${command} ${args.join(' ')} exited with code ${code}`));16    });17  });18}19 20async function waitForServer(url, timeoutMs = 60000) {21  const start = Date.now();22  while (Date.now() - start < timeoutMs) {23    try {24      const res = await fetch(url);25      if (res.ok) return;26    } catch {}27    await delay(500);28  }29  throw new Error(`Server did not start in time: ${url}`);30}31 32function parseArgs(argv) {33  const out = {};34  for (const arg of argv.slice(2)) {35    if (!arg.startsWith('--')) continue;36    const [k, v] = arg.replace(/^--/, '').split('=');37    out[k] = v === undefined ? true : v;38  }39  return out;40}41 42function slugify(text) {43  return String(text || '')44    .normalize('NFKD')45    .replace(/\p{Diacritic}+/gu, '')46    .toLowerCase()47    .replace(/[^a-z0-9]+/g, '-')48    .replace(/^-+|-+$/g, '')49    .slice(0, 120) || 'article';50}51 52function parseMargin(margin) {53  if (!margin) return { top: '12mm', right: '12mm', bottom: '16mm', left: '12mm' };54  const parts = String(margin).split(',').map(s => s.trim()).filter(Boolean);55  if (parts.length === 1) {56    return { top: parts[0], right: parts[0], bottom: parts[0], left: parts[0] };57  }58  if (parts.length === 2) {59    return { top: parts[0], right: parts[1], bottom: parts[0], left: parts[1] };60  }61  if (parts.length === 3) {62    return { top: parts[0], right: parts[1], bottom: parts[2], left: parts[1] };63  }64  return { top: parts[0] || '12mm', right: parts[1] || '12mm', bottom: parts[2] || '16mm', left: parts[3] || '12mm' };65}66 67function cssLengthToMm(val) {68  if (!val) return 0;69  const s = String(val).trim();70  if (/mm$/i.test(s)) return parseFloat(s);71  if (/cm$/i.test(s)) return parseFloat(s) * 10;72  if (/in$/i.test(s)) return parseFloat(s) * 25.4;73  if (/px$/i.test(s)) return (parseFloat(s) / 96) * 25.4; // 96 CSS px per inch74  const num = parseFloat(s);75  return Number.isFinite(num) ? num : 0; // assume mm if unitless76}77 78function getFormatSizeMm(format) {79  const f = String(format || 'A4').toLowerCase();80  switch (f) {81    case 'letter': return { w: 215.9, h: 279.4 };82    case 'legal': return { w: 215.9, h: 355.6 };83    case 'a3': return { w: 297, h: 420 };84    case 'tabloid': return { w: 279.4, h: 431.8 };85    case 'a4':86    default: return { w: 210, h: 297 };87  }88}89 90async function waitForImages(page, timeoutMs = 15000) {91  await page.evaluate(async (timeout) => {92    const deadline = Date.now() + timeout;93    const imgs = Array.from(document.images || []);94    const unloaded = imgs.filter(img => !img.complete || (img.naturalWidth === 0));95    await Promise.race([96      Promise.all(unloaded.map(img => new Promise(res => {97        if (img.complete && img.naturalWidth !== 0) return res(undefined);98        img.addEventListener('load', () => res(undefined), { once: true });99        img.addEventListener('error', () => res(undefined), { once: true });100      }))),101      new Promise(res => setTimeout(res, Math.max(0, deadline - Date.now())))102    ]);103  }, timeoutMs);104}105 106async function waitForPlotly(page, timeoutMs = 20000) {107  await page.evaluate(async (timeout) => {108    const start = Date.now();109    const hasPlots = () => Array.from(document.querySelectorAll('.js-plotly-plot')).length > 0;110    // Wait until plots exist or timeout111    while (!hasPlots() && (Date.now() - start) < timeout) {112      await new Promise(r => setTimeout(r, 200));113    }114    const deadline = start + timeout;115    // Then wait until each plot contains the main svg116    const allReady = () => Array.from(document.querySelectorAll('.js-plotly-plot')).every(el => el.querySelector('svg.main-svg'));117    while (!allReady() && Date.now() < deadline) {118      await new Promise(r => setTimeout(r, 200));119    }120  }, timeoutMs);121}122 123async function waitForD3(page, timeoutMs = 20000) {124  await page.evaluate(async (timeout) => {125    const start = Date.now();126    const isReady = () => {127      // Prioritize hero banner if present (generic container)128      const hero = document.querySelector('.hero-banner');129      if (hero) {130        return !!hero.querySelector('svg circle, svg path, svg rect, svg g');131      }132      // Else require all D3 containers on page to have shapes133      const containers = [134        ...Array.from(document.querySelectorAll('.d3-line')),135        ...Array.from(document.querySelectorAll('.d3-bar'))136      ];137      if (!containers.length) return true;138      return containers.every(c => c.querySelector('svg circle, svg path, svg rect, svg g'));139    };140    while (!isReady() && (Date.now() - start) < timeout) {141      await new Promise(r => setTimeout(r, 200));142    }143  }, timeoutMs);144}145 146async function waitForStableLayout(page, timeoutMs = 5000) {147  const start = Date.now();148  let last = await page.evaluate(() => document.scrollingElement ? document.scrollingElement.scrollHeight : document.body.scrollHeight);149  let stableCount = 0;150  while ((Date.now() - start) < timeoutMs && stableCount < 3) {151    await page.waitForTimeout(250);152    const now = await page.evaluate(() => document.scrollingElement ? document.scrollingElement.scrollHeight : document.body.scrollHeight);153    if (now === last) stableCount += 1; else { stableCount = 0; last = now; }154  }155}156 157async function main() {158  const cwd = process.cwd();159  const port = Number(process.env.PREVIEW_PORT || 8080);160  const baseUrl = `http://127.0.0.1:${port}/`;161  const args = parseArgs(process.argv);162  // Default: light (do not rely on env vars implicitly)163  const theme = (args.theme === 'dark' || args.theme === 'light') ? args.theme : 'light';164  const format = args.format || 'A4';165  const margin = parseMargin(args.margin);166  const wait = (args.wait || 'full'); // 'networkidle' | 'images' | 'plotly' | 'full'167 168  // filename can be provided, else computed from DOM (button) or page title later169  let outFileBase = (args.filename && String(args.filename).replace(/\.pdf$/i, '')) || 'article';170 171  // Build only if dist/ does not exist172  const distDir = resolve(cwd, 'dist');173  let hasDist = false;174  try {175    const st = await fs.stat(distDir);176    hasDist = st && st.isDirectory();177  } catch {}178  if (!hasDist) {179    console.log('> Building Astro site…');180    await run('npm', ['run', 'build']);181  } else {182    console.log('> Skipping build (dist/ exists)…');183  }184 185  console.log('> Starting Astro preview…');186  // Start preview in its own process group so we can terminate all children reliably187  const preview = spawn('npm', ['run', 'preview'], { cwd, stdio: 'inherit', detached: true });188  const previewExit = new Promise((resolvePreview) => {189    preview.on('close', (code, signal) => resolvePreview({ code, signal }));190  });191 192  try {193    await waitForServer(baseUrl, 60000);194    console.log('> Server ready, generating PDF…');195 196    const browser = await chromium.launch({ headless: true });197    try {198      const context = await browser.newContext();199      await context.addInitScript((desired) => {200        try {201          localStorage.setItem('theme', desired);202          // Apply theme immediately to avoid flashes203          if (document && document.documentElement) {204            document.documentElement.dataset.theme = desired;205          }206        } catch {}207      }, theme);208      const page = await context.newPage();209      // Pre-fit viewport width to printable width so charts size correctly210      const fmt = getFormatSizeMm(format);211      const mw = fmt.w - cssLengthToMm(margin.left) - cssLengthToMm(margin.right);212      const printableWidthPx = Math.max(320, Math.round((mw / 25.4) * 96));213      await page.setViewportSize({ width: printableWidthPx, height: 1200 });214      await page.goto(baseUrl, { waitUntil: 'load', timeout: 60000 });215      // Give time for CDN scripts (Plotly/D3) to attach and for our fragment hooks to run216      try { await page.waitForFunction(() => !!window.Plotly, { timeout: 8000 }); } catch {}217      try { await page.waitForFunction(() => !!window.d3, { timeout: 8000 }); } catch {}218      // Prefer explicit filename from the download button if present219      if (!args.filename) {220        const fromBtn = await page.evaluate(() => {221          const btn = document.getElementById('download-pdf-btn');222          const f = btn ? btn.getAttribute('data-pdf-filename') : null;223          return f || '';224        });225        if (fromBtn) {226          outFileBase = String(fromBtn).replace(/\.pdf$/i, '');227        } else {228          // Fallback: compute slug from hero title or document.title229          const title = await page.evaluate(() => {230            const h1 = document.querySelector('h1.hero-title');231            const t = h1 ? h1.textContent : document.title;232            return (t || '').replace(/\s+/g, ' ').trim();233          });234          outFileBase = slugify(title);235        }236      }237 238      // Wait for render readiness239      if (wait === 'images' || wait === 'full') {240        await waitForImages(page);241      }242      if (wait === 'd3' || wait === 'full') {243        await waitForD3(page);244      }245      if (wait === 'plotly' || wait === 'full') {246        await waitForPlotly(page);247      }248      if (wait === 'full') {249        await waitForStableLayout(page);250      }251      await page.emulateMedia({ media: 'print' });252 253      // Enforce responsive sizing for SVG/iframes by removing hard attrs and injecting CSS (top-level and inside same-origin iframes)254      try {255        await page.evaluate(() => {256          function isSmallSvg(svg){257            try {258              const vb = svg && svg.viewBox && svg.viewBox.baseVal ? svg.viewBox.baseVal : null;259              if (vb && vb.width && vb.height && vb.width <= 50 && vb.height <= 50) return true;260              const r = svg.getBoundingClientRect && svg.getBoundingClientRect();261              if (r && r.width && r.height && r.width <= 50 && r.height <= 50) return true;262            } catch {}263            return false;264          }265          function lockSmallSvgSize(svg){266            try {267              const r = svg.getBoundingClientRect ? svg.getBoundingClientRect() : null;268              const w = (r && r.width) ? Math.round(r.width) : null;269              const h = (r && r.height) ? Math.round(r.height) : null;270              if (w) svg.style.setProperty('width', w + 'px', 'important');271              if (h) svg.style.setProperty('height', h + 'px', 'important');272              svg.style.setProperty('max-width', 'none', 'important');273            } catch {}274          }275          function fixSvg(svg){276            if (!svg) return;277            // Do not alter hero banner SVG sizing; it may rely on explicit width/height278            try { if (svg.closest && svg.closest('.hero-banner')) return; } catch {}279            if (isSmallSvg(svg)) { lockSmallSvgSize(svg); return; }280            try { svg.removeAttribute('width'); } catch {}281            try { svg.removeAttribute('height'); } catch {}282            svg.style.maxWidth = '100%';283            svg.style.width = '100%';284            svg.style.height = 'auto';285            if (!svg.getAttribute('preserveAspectRatio')) svg.setAttribute('preserveAspectRatio','xMidYMid meet');286          }287          document.querySelectorAll('svg').forEach(fixSvg);288          document.querySelectorAll('.mermaid, .mermaid svg').forEach((el)=>{289            if (el.tagName && el.tagName.toLowerCase() === 'svg') fixSvg(el);290            else { el.style.display='block'; el.style.width='100%'; el.style.maxWidth='100%'; }291          });292          document.querySelectorAll('iframe, embed, object').forEach((el) => {293            el.style.width = '100%';294            el.style.maxWidth = '100%';295            try { el.removeAttribute('width'); } catch {}296            // Best-effort inject into same-origin frames297            try {298              const doc = (el.tagName.toLowerCase()==='object' ? el.contentDocument : el.contentDocument);299              if (doc && doc.head) {300                const s = doc.createElement('style');301                s.textContent = 'html,body{overflow-x:hidden;} svg,canvas,img,video{max-width:100%!important;height:auto!important;} svg[width]{width:100%!important}';302                doc.head.appendChild(s);303                doc.querySelectorAll('svg').forEach((svg)=>{ if (isSmallSvg(svg)) lockSmallSvgSize(svg); else fixSvg(svg); });304              }305            } catch (_) { /* cross-origin; ignore */ }306          });307        });308      } catch {}309 310      // Generate OG thumbnail (1200x630)311      try {312        const ogW = 1200, ogH = 630;313        await page.setViewportSize({ width: ogW, height: ogH });314        // Give layout a tick to adjust315        await page.waitForTimeout(200);316        // Ensure layout & D3 re-rendered after viewport change317        await page.evaluate(() => { window.scrollTo(0, 0); window.dispatchEvent(new Event('resize')); });318        try { await waitForD3(page, 8000); } catch {}319 320        // Temporarily improve visibility for light theme thumbnails321        // - Force normal blend for points322        // - Ensure an SVG background (CSS background on svg element)323        const cssHandle = await page.addStyleTag({ content: `324          .hero .points { mix-blend-mode: normal !important; }325        ` });326        const thumbPath = resolve(cwd, 'dist', 'thumb.auto.jpg');327        await page.screenshot({ path: thumbPath, type: 'jpeg', quality: 85, fullPage: false });328        // Also emit PNG for compatibility if needed329        const thumbPngPath = resolve(cwd, 'dist', 'thumb.auto.png');330        await page.screenshot({ path: thumbPngPath, type: 'png', fullPage: false });331        const publicThumb = resolve(cwd, 'public', 'thumb.auto.jpg');332        const publicThumbPng = resolve(cwd, 'public', 'thumb.auto.png');333        try { await fs.copyFile(thumbPath, publicThumb); } catch {}334        try { await fs.copyFile(thumbPngPath, publicThumbPng); } catch {}335        // Remove temporary style so PDF is unaffected336        try { await cssHandle.evaluate((el) => el.remove()); } catch {}337        console.log(`✅ OG thumbnail generated: ${thumbPath}`);338      } catch (e) {339        console.warn('Unable to generate OG thumbnail:', e?.message || e);340      }341      const outPath = resolve(cwd, 'dist', `${outFileBase}.pdf`);342      // Restore viewport to printable width before PDF (thumbnail changed it)343      try {344        const fmt2 = getFormatSizeMm(format);345        const mw2 = fmt2.w - cssLengthToMm(margin.left) - cssLengthToMm(margin.right);346        const printableWidthPx2 = Math.max(320, Math.round((mw2 / 25.4) * 96));347        await page.setViewportSize({ width: printableWidthPx2, height: 1400 });348        await page.evaluate(() => { window.scrollTo(0, 0); window.dispatchEvent(new Event('resize')); });349        try { await waitForD3(page, 8000); } catch {}350        await waitForStableLayout(page);351        // Re-apply responsive fixes after viewport change352        try {353          await page.evaluate(() => {354            function isSmallSvg(svg){355              try {356                const vb = svg && svg.viewBox && svg.viewBox.baseVal ? svg.viewBox.baseVal : null;357                if (vb && vb.width && vb.height && vb.width <= 50 && vb.height <= 50) return true;358                const r = svg.getBoundingClientRect && svg.getBoundingClientRect();359                if (r && r.width && r.height && r.width <= 50 && r.height <= 50) return true;360              } catch {}361              return false;362            }363            function lockSmallSvgSize(svg){364              try {365                const r = svg.getBoundingClientRect ? svg.getBoundingClientRect() : null;366                const w = (r && r.width) ? Math.round(r.width) : null;367                const h = (r && r.height) ? Math.round(r.height) : null;368                if (w) svg.style.setProperty('width', w + 'px', 'important');369                if (h) svg.style.setProperty('height', h + 'px', 'important');370                svg.style.setProperty('max-width', 'none', 'important');371              } catch {}372            }373            function fixSvg(svg){374              if (!svg) return;375              // Do not alter hero banner SVG sizing; it may rely on explicit width/height376              try { if (svg.closest && svg.closest('.hero-banner')) return; } catch {}377              if (isSmallSvg(svg)) { lockSmallSvgSize(svg); return; }378              try { svg.removeAttribute('width'); } catch {}379              try { svg.removeAttribute('height'); } catch {}380              svg.style.maxWidth = '100%';381              svg.style.width = '100%';382              svg.style.height = 'auto';383              if (!svg.getAttribute('preserveAspectRatio')) svg.setAttribute('preserveAspectRatio','xMidYMid meet');384            }385            document.querySelectorAll('svg').forEach((svg)=>{ if (isSmallSvg(svg)) lockSmallSvgSize(svg); else fixSvg(svg); });386            document.querySelectorAll('.mermaid, .mermaid svg').forEach((el)=>{387              if (el.tagName && el.tagName.toLowerCase() === 'svg') fixSvg(el);388              else { el.style.display='block'; el.style.width='100%'; el.style.maxWidth='100%'; }389            });390            document.querySelectorAll('iframe, embed, object').forEach((el) => {391              el.style.width = '100%';392              el.style.maxWidth = '100%';393              try { el.removeAttribute('width'); } catch {}394              try {395                const doc = (el.tagName.toLowerCase()==='object' ? el.contentDocument : el.contentDocument);396                if (doc && doc.head) {397                  const s = doc.createElement('style');398                  s.textContent = 'html,body{overflow-x:hidden;} svg,canvas,img,video{max-width:100%!important;height:auto!important;} svg[width]{width:100%!important}';399                  doc.head.appendChild(s);400                  doc.querySelectorAll('svg').forEach((svg)=>{ if (isSmallSvg(svg)) lockSmallSvgSize(svg); else fixSvg(svg); });401                }402              } catch (_) {}403            });404          });405        } catch {}406      } catch {}407      // Temporarily enforce print-safe responsive sizing (SVG/iframes) and improve banner visibility408      let pdfCssHandle = null;409      try {410        pdfCssHandle = await page.addStyleTag({ content: `411          /* General container safety */412          html, body { overflow-x: hidden !important; }413 414          /* Make all vector/bitmap media responsive for print */415          svg, canvas, img, video { max-width: 100% !important; height: auto !important; }416          /* Mermaid diagrams */417          .mermaid, .mermaid svg { display: block; width: 100% !important; max-width: 100% !important; height: auto !important; }418          /* Any explicit width attributes */419          svg[width] { width: 100% !important; }420          /* Iframes and similar embeds */421          iframe, embed, object { width: 100% !important; max-width: 100% !important; height: auto; }422 423          /* HtmlEmbed wrappers (defensive) */424          .html-embed, .html-embed__card { max-width: 100% !important; width: 100% !important; }425          .html-embed__card > div[id^="frag-"] { width: 100% !important; max-width: 100% !important; }426 427          /* Banner centering & visibility */428          .hero .points { mix-blend-mode: normal !important; }429          /* Do NOT force a fixed height to avoid clipping in PDF */430          .hero-banner { width: 100% !important; max-width: 980px !important; margin-left: auto !important; margin-right: auto !important; }431          .hero-banner svg { width: 100% !important; height: auto !important; }432        ` });433      } catch {}434      await page.pdf({435        path: outPath,436        format,437        printBackground: true,438        margin439      });440      try { if (pdfCssHandle) await pdfCssHandle.evaluate((el) => el.remove()); } catch {}441      console.log(`✅ PDF generated: ${outPath}`);442 443      // Copy into public only under the slugified name444      const publicSlugPath = resolve(cwd, 'public', `${outFileBase}.pdf`);445      try {446        await fs.mkdir(resolve(cwd, 'public'), { recursive: true });447        await fs.copyFile(outPath, publicSlugPath);448        console.log(`✅ PDF copied to: ${publicSlugPath}`);449      } catch (e) {450        console.warn('Unable to copy PDF to public/:', e?.message || e);451      }452    } finally {453      await browser.close();454    }455  } finally {456    // Try a clean shutdown of preview (entire process group first)457    try {458      if (process.platform !== 'win32') {459        try { process.kill(-preview.pid, 'SIGINT'); } catch {}460      }461      try { preview.kill('SIGINT'); } catch {}462      await Promise.race([previewExit, delay(3000)]);463      // Force kill if still alive464      // eslint-disable-next-line no-unsafe-optional-chaining465      if (!preview.killed) {466        try {467          if (process.platform !== 'win32') {468            try { process.kill(-preview.pid, 'SIGKILL'); } catch {}469          }470          try { preview.kill('SIGKILL'); } catch {}471        } catch {}472        await Promise.race([previewExit, delay(1000)]);473      }474    } catch {}475  }476}477 478main().catch((err) => {479  console.error(err);480  process.exit(1);481});482 483 484