From d7cbc706d341a3ed4838a94b42d5ca943b2d3309 Mon Sep 17 00:00:00 2001 From: fanhongcai Date: Sun, 16 Aug 2026 04:08:20 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=87=BA=E5=BA=93?= =?UTF-8?q?=E5=8D=95=E6=89=93=E5=8D=B0=E9=A2=84=E8=A7=88=E7=A9=BA=E7=99=BD?= =?UTF-8?q?=20-=20iframe=20=E6=94=B9=E7=94=A8=20screen=20=E9=9A=90?= =?UTF-8?q?=E8=97=8F/print=20=E6=98=BE=E7=A4=BA=EF=BC=8C=E5=B9=B6=E7=AD=89?= =?UTF-8?q?=E5=BE=85=E5=AD=97=E4=BD=93=E5=8A=A0=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + openprint/src/assets/main.css | 19 ++++ openprint/src/core/headless/createHeadless.ts | 22 +++-- server/scripts/verify-print-visual.mjs | 87 +++++++++++++++++++ 4 files changed, 124 insertions(+), 6 deletions(-) create mode 100644 server/scripts/verify-print-visual.mjs diff --git a/.gitignore b/.gitignore index 2cb85a7..18a847a 100644 --- a/.gitignore +++ b/.gitignore @@ -34,5 +34,7 @@ Thumbs.db # ===== 工作区记忆 / 调试产物 ===== .codebuddy/ server/scripts/*.pdf +server/scripts/_token.txt +server/scripts/print-preview.png server/src/F9MES.Api/logs-err.txt server/src/F9MES.Api/logs-out.txt diff --git a/openprint/src/assets/main.css b/openprint/src/assets/main.css index 9c91254..09a15ce 100644 --- a/openprint/src/assets/main.css +++ b/openprint/src/assets/main.css @@ -57,3 +57,22 @@ canvas * { .n-input-number .n-input__input-el { text-align: center; } + +/* 静默打印 iframe: + - screen 下隐藏在屏幕下方,避免遮挡 UI + - print 下显示在左上角,确保 Chrome/Edge 打印对话框能正确渲染预览 + (left:-9999px 会使部分浏览器不渲染屏幕外 iframe)。 */ +@media screen { + .op-silent-print-frame { + opacity: 0; + pointer-events: none; + z-index: -1; + } +} +@media print { + .op-silent-print-frame { + opacity: 1; + z-index: 999999; + top: 0 !important; + } +} diff --git a/openprint/src/core/headless/createHeadless.ts b/openprint/src/core/headless/createHeadless.ts index 2233645..585afbe 100644 --- a/openprint/src/core/headless/createHeadless.ts +++ b/openprint/src/core/headless/createHeadless.ts @@ -161,8 +161,8 @@ function parsePaperSize(html: string): { w: string; h: string } { /** * 静默打印:瞬态 iframe + srcdoc + window.print(),完成后立即移除。 - * 注意:iframe 必须带实际纸张尺寸并移出屏幕(不可 0×0,否则 Chrome/Edge - * 打印对话框以 0 视口渲染,预览为空白), + * 注意:iframe 必须有实际纸张尺寸;将其放在屏幕下方并通过 CSS 在 screen 下隐藏、 + * print 下显示,避免 left:-9999px 导致某些 Chrome/Edge 版本的打印对话框预览空白。 */ function silentPrint(html: string): Promise { return new Promise((resolve, reject) => { @@ -173,21 +173,31 @@ function silentPrint(html: string): Promise { const { w, h } = parsePaperSize(html) const iframe = document.createElement('iframe') iframe.setAttribute('aria-hidden', 'true') - // 移到屏幕外但保留纸张尺寸视口,保证打印对话框预览可见 - iframe.style.cssText = `position:fixed;left:-9999px;top:0;width:${w};height:${h};border:0;` + // 通过 class + @media 在 screen 下隐藏、print 下显示,避免屏幕外渲染问题 + iframe.className = 'op-silent-print-frame' + iframe.style.cssText = `position:fixed;left:0;top:100vh;width:${w};height:${h};border:0;` let removed = false const cleanup = () => { if (removed) return removed = true setTimeout(() => iframe.remove(), 300) } - iframe.onload = () => { + iframe.onload = async () => { const cw = iframe.contentWindow - if (!cw) { + const cd = iframe.contentDocument + if (!cw || !cd) { cleanup() reject(new Error('iframe 无 contentWindow')) return } + // 等待字体就绪,避免打印预览空白/缺字 + if (cd.fonts && cd.fonts.ready) { + try { + await Promise.race([cd.fonts.ready, new Promise((r) => setTimeout(r, 2000))]) + } catch { + /* ignore */ + } + } // onafterprint 在用户确认打印或取消时都会触发 cw.onafterprint = () => { cleanup() diff --git a/server/scripts/verify-print-visual.mjs b/server/scripts/verify-print-visual.mjs new file mode 100644 index 0000000..16596a2 --- /dev/null +++ b/server/scripts/verify-print-visual.mjs @@ -0,0 +1,87 @@ +// 视觉验证:打开打印页,等待 iframe 就绪 + 字体加载,截图 iframe 区域 +import { createRequire } from 'module' +import fs from 'fs' + +const PW = 'C:/Users/范先生/AppData/Roaming/npm/node_modules/@playwright/cli/node_modules/playwright-core' +const require = createRequire(PW + '/package.json') +const { chromium } = require(PW) + +const B = 'http://localhost:5136' +const token = fs.readFileSync(new URL('_token.txt', import.meta.url), 'utf8').trim() +const url = `http://localhost:5227/?print=1&template=2&table=RawMaterial_OutStock&row=2&token=${encodeURIComponent(token)}&api=${B}` + +const browser = await chromium.launch({ headless: true, channel: 'msedge' }) +const page = await browser.newPage({ viewport: { width: 900, height: 500 } }) +page.on('pageerror', (e) => console.log('[pageerror]', e.message)) +page.on('console', (m) => { if (m.type() === 'error') console.log('[console]', m.text()) }) +await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 }) + +for (let i = 0; i < 40; i++) { + const ok = await page.evaluate(() => { + const f = document.querySelector('iframe') + return !!(f && f.srcdoc && f.srcdoc.length > 1000) + }) + if (ok) break + await new Promise((r) => setTimeout(r, 500)) +} + +// 等字体与图片加载 +await page.evaluate(async () => { + const f = document.querySelector('iframe') + if (f && f.contentDocument) { + await f.contentDocument.fonts.ready.catch(() => {}) + await new Promise((r) => setTimeout(r, 800)) + } +}) + +// 模拟浏览器进入打印预览/打印媒体,验证 iframe 在 @media print 下可见 +await page.emulateMedia({ media: 'print' }) +await page.waitForTimeout(500) +await page.screenshot({ path: 'print-preview.png' }) +const buf = fs.statSync('print-preview.png') +console.log('SHOT_SAVED size=' + buf.size) + +// 用 canvas 判断内容像素比例(主页面同源 iframe 可访问) +const stats = await page.evaluate(async () => { + const f = document.querySelector('iframe') + if (!f || !f.contentDocument) return { err: 'no-iframe' } + const d = f.contentDocument + const w = 210 * 96 / 25.4 // 793px + const h = 99 * 96 / 25.4 // 374px + const canvas = document.createElement('canvas') + canvas.width = w + canvas.height = h + const ctx = canvas.getContext('2d') + ctx.fillStyle = '#fff' + ctx.fillRect(0, 0, w, h) + // 将 iframe body 克隆到独立容器渲染后截图(drawWindow 不可用,改用 HTML 转图) + const container = document.createElement('div') + container.style.position = 'fixed' + container.style.left = '0' + container.style.top = '0' + container.style.zIndex = '99999' + container.appendChild(d.body.cloneNode(true)) + document.body.appendChild(container) + // 用 SVG foreignObject 序列化渲染 + const svg = `
${d.body.innerHTML}
` + const img = new Image() + const ok = await new Promise((resolve) => { + img.onload = () => resolve(true) + img.onerror = () => resolve(false) + img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg) + }) + document.body.removeChild(container) + if (!ok) return { err: 'svg-fail' } + ctx.drawImage(img, 0, 0, w, h) + const data = ctx.getImageData(0, 0, w, h).data + let nonWhite = 0 + for (let i = 0; i < data.length; i += 40) { + const r = data[i], g = data[i + 1], b = data[i + 2] + if (r < 245 || g < 245 || b < 245) nonWhite++ + } + const total = Math.floor(data.length / 4 / 10) + return { nonWhiteRatio: (nonWhite / total).toFixed(4) } +}) +console.log('PIXEL=' + JSON.stringify(stats)) +await browser.close() +console.log('DONE')