Files
dreport/frontend/tests/visual/cross-renderer.spec.ts
2026-04-06 03:17:30 +03:00

167 lines
5.9 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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
}