mirror of
https://github.com/duhanbalci/dreport.git
synced 2026-07-01 18:39:16 +00:00
167 lines
5.9 KiB
TypeScript
167 lines
5.9 KiB
TypeScript
/**
|
||
* Cross-renderer visual test: compares HTML render (browser) vs PDF render (Rust).
|
||
*
|
||
* Both renderers consume the same LayoutResult from the same layout engine.
|
||
* This test verifies that the visual output is consistent between:
|
||
* - LayoutRenderer.vue (HTML divs in browser)
|
||
* - pdf_render.rs → pdftoppm (PDF rasterized to PNG)
|
||
*
|
||
* Prerequisites:
|
||
* cargo test -p dreport-layout --test visual_test -- generate_cross_renderer --ignored
|
||
* (or: just visual-refs)
|
||
*/
|
||
|
||
import { test, expect } from '@playwright/test'
|
||
import fs from 'fs'
|
||
import path from 'path'
|
||
import { fileURLToPath } from 'url'
|
||
import { PNG } from 'pngjs'
|
||
import pixelmatch from 'pixelmatch'
|
||
|
||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||
const FIXTURES_DIR = path.resolve(__dirname, '../../../layout-engine/tests/fixtures')
|
||
const REFS_DIR = path.resolve(__dirname, 'cross-renderer-refs')
|
||
const DIFFS_DIR = path.resolve(__dirname, 'cross-renderer-diffs')
|
||
|
||
interface TestFixture {
|
||
name: string
|
||
templateFile: string
|
||
dataFile: string
|
||
/** Max allowed pixel diff ratio (0.0 – 1.0) */
|
||
maxDiffRatio: number
|
||
}
|
||
|
||
const FIXTURES: TestFixture[] = [
|
||
{
|
||
name: 'visual_test',
|
||
templateFile: 'visual_test_template.json',
|
||
dataFile: 'visual_test_data.json',
|
||
// Text rendering differences between browser and PDF are expected.
|
||
// Font hinting, anti-aliasing, and line-height handling differ.
|
||
maxDiffRatio: 0.05,
|
||
},
|
||
{
|
||
name: 'chart_test',
|
||
templateFile: 'chart_test_template.json',
|
||
dataFile: 'chart_test_data.json',
|
||
// Charts have more visual complexity (SVG vs PDF primitives)
|
||
maxDiffRatio: 0.08,
|
||
},
|
||
{
|
||
name: 'comprehensive_test',
|
||
templateFile: 'comprehensive_test_template.json',
|
||
dataFile: 'comprehensive_test_data.json',
|
||
// All element types: text, rich_text, table, charts, barcodes, shapes,
|
||
// checkboxes, calculated_text, current_date, page_number, lines.
|
||
// Barcodes render differently (canvas vs PDF) so higher tolerance.
|
||
maxDiffRatio: 0.08,
|
||
},
|
||
]
|
||
|
||
test.describe('Cross-renderer: HTML vs PDF', () => {
|
||
test.beforeAll(() => {
|
||
fs.mkdirSync(DIFFS_DIR, { recursive: true })
|
||
})
|
||
|
||
for (const fixture of FIXTURES) {
|
||
test(`${fixture.name}: HTML render matches PDF render`, async ({ page }) => {
|
||
const refPath = path.join(REFS_DIR, `${fixture.name}.png`)
|
||
if (!fs.existsSync(refPath)) {
|
||
test.skip(true, `PDF reference not found: ${refPath}. Run: just visual-refs`)
|
||
return
|
||
}
|
||
|
||
// Read fixture files
|
||
const templateJson = fs.readFileSync(path.join(FIXTURES_DIR, fixture.templateFile), 'utf-8')
|
||
const dataJson = fs.readFileSync(path.join(FIXTURES_DIR, fixture.dataFile), 'utf-8')
|
||
|
||
// Inject fixture data before page loads
|
||
await page.addInitScript((fixtureData: { template: string; data: string }) => {
|
||
;(window as any).__DREPORT_FIXTURE__ = fixtureData
|
||
}, { template: templateJson, data: dataJson })
|
||
|
||
// Navigate to render test page
|
||
await page.goto('/render-test.html')
|
||
|
||
// Wait for layout to compute and render
|
||
await page.waitForSelector('[data-render-ready]', { timeout: 20000 })
|
||
|
||
// Small delay for font rendering and any async canvas operations
|
||
await page.waitForTimeout(500)
|
||
|
||
// Screenshot the rendered page (first page only)
|
||
const pageEl = page.locator('.layout-page').first()
|
||
const htmlScreenshot = await pageEl.screenshot({ type: 'png' })
|
||
|
||
// Load PDF reference PNG
|
||
const refBuffer = fs.readFileSync(refPath)
|
||
const refPng = PNG.sync.read(refBuffer)
|
||
const htmlPng = PNG.sync.read(htmlScreenshot)
|
||
|
||
// Handle dimension mismatch by resizing to the smaller of the two
|
||
const width = Math.min(htmlPng.width, refPng.width)
|
||
const height = Math.min(htmlPng.height, refPng.height)
|
||
|
||
// Crop both images to common size
|
||
const croppedHtml = cropPng(htmlPng, width, height)
|
||
const croppedRef = cropPng(refPng, width, height)
|
||
|
||
// Pixel comparison
|
||
const diffPng = new PNG({ width, height })
|
||
const numDiffPixels = pixelmatch(
|
||
croppedHtml.data,
|
||
croppedRef.data,
|
||
diffPng.data,
|
||
width,
|
||
height,
|
||
{
|
||
threshold: 0.15, // Per-pixel color distance threshold
|
||
alpha: 0.3,
|
||
includeAA: false, // Ignore anti-aliasing differences
|
||
},
|
||
)
|
||
|
||
const totalPixels = width * height
|
||
const diffRatio = numDiffPixels / totalPixels
|
||
|
||
// Save diff image for debugging
|
||
const diffPath = path.join(DIFFS_DIR, `${fixture.name}_diff.png`)
|
||
const htmlPath = path.join(DIFFS_DIR, `${fixture.name}_html.png`)
|
||
fs.writeFileSync(diffPath, PNG.sync.write(diffPng))
|
||
fs.writeFileSync(htmlPath, htmlScreenshot)
|
||
|
||
// Dimension info
|
||
const dimInfo = htmlPng.width !== refPng.width || htmlPng.height !== refPng.height
|
||
? ` (HTML: ${htmlPng.width}x${htmlPng.height}, PDF: ${refPng.width}x${refPng.height}, compared: ${width}x${height})`
|
||
: ` (${width}x${height})`
|
||
|
||
console.log(
|
||
`[${fixture.name}] diff: ${(diffRatio * 100).toFixed(2)}% pixels${dimInfo}`,
|
||
)
|
||
|
||
expect(
|
||
diffRatio,
|
||
`Visual diff too large for ${fixture.name}: ${(diffRatio * 100).toFixed(2)}% pixels differ (max: ${(fixture.maxDiffRatio * 100).toFixed(0)}%). Check diff at: ${diffPath}`,
|
||
).toBeLessThanOrEqual(fixture.maxDiffRatio)
|
||
})
|
||
}
|
||
})
|
||
|
||
/** Crop a PNG to the given width and height (top-left origin) */
|
||
function cropPng(src: PNG, width: number, height: number): PNG {
|
||
if (src.width === width && src.height === height) return src
|
||
|
||
const cropped = new PNG({ width, height })
|
||
for (let y = 0; y < height; y++) {
|
||
for (let x = 0; x < width; x++) {
|
||
const srcIdx = (y * src.width + x) * 4
|
||
const dstIdx = (y * width + x) * 4
|
||
cropped.data[dstIdx] = src.data[srcIdx]
|
||
cropped.data[dstIdx + 1] = src.data[srcIdx + 1]
|
||
cropped.data[dstIdx + 2] = src.data[srcIdx + 2]
|
||
cropped.data[dstIdx + 3] = src.data[srcIdx + 3]
|
||
}
|
||
}
|
||
return cropped
|
||
}
|