mirror of
https://github.com/duhanbalci/dreport.git
synced 2026-07-02 02:49:16 +00:00
visual testing
This commit is contained in:
@@ -19,10 +19,13 @@
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/pngjs": "^6.0.5",
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"@vue/tsconfig": "^0.9.0",
|
||||
"happy-dom": "^20.8.9",
|
||||
"pixelmatch": "^7.1.0",
|
||||
"pngjs": "^7.0.0",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.0.1",
|
||||
"vitest": "^4.1.2",
|
||||
@@ -235,6 +238,8 @@
|
||||
|
||||
"@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
|
||||
|
||||
"@types/pngjs": ["@types/pngjs@6.0.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ=="],
|
||||
|
||||
"@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="],
|
||||
|
||||
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
||||
@@ -471,12 +476,16 @@
|
||||
|
||||
"pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="],
|
||||
|
||||
"pixelmatch": ["pixelmatch@7.1.0", "", { "dependencies": { "pngjs": "^7.0.0" }, "bin": { "pixelmatch": "bin/pixelmatch" } }, "sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng=="],
|
||||
|
||||
"pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="],
|
||||
|
||||
"playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="],
|
||||
|
||||
"playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="],
|
||||
|
||||
"pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="],
|
||||
|
||||
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
|
||||
|
||||
"postcss-load-config": ["postcss-load-config@6.0.1", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="],
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
"test:visual": "playwright test"
|
||||
"test:visual": "playwright test",
|
||||
"test:visual:cross": "playwright test --project=cross-renderer"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.20.1",
|
||||
@@ -26,10 +27,13 @@
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/pngjs": "^6.0.5",
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"@vue/tsconfig": "^0.9.0",
|
||||
"happy-dom": "^20.8.9",
|
||||
"pixelmatch": "^7.1.0",
|
||||
"pngjs": "^7.0.0",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.0.1",
|
||||
"vitest": "^4.1.2",
|
||||
|
||||
@@ -6,6 +6,8 @@ export default defineConfig({
|
||||
use: {
|
||||
baseURL: 'http://localhost:5173',
|
||||
viewport: { width: 1400, height: 900 },
|
||||
// Disable HiDPI scaling for pixel-exact comparison
|
||||
deviceScaleFactor: 1,
|
||||
},
|
||||
webServer: {
|
||||
command: 'bun run dev',
|
||||
@@ -18,4 +20,19 @@ export default defineConfig({
|
||||
maxDiffPixelRatio: 0.01,
|
||||
},
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'editor',
|
||||
testMatch: 'editor.spec.ts',
|
||||
},
|
||||
{
|
||||
name: 'cross-renderer',
|
||||
testMatch: 'cross-renderer.spec.ts',
|
||||
use: {
|
||||
// Render test page needs larger viewport for A4 at 150 DPI
|
||||
// A4 = 210mm x 297mm → 1240 x 1754 px at 150 DPI
|
||||
viewport: { width: 1300, height: 1800 },
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
Binary file not shown.
48
frontend/render-test.html
Normal file
48
frontend/render-test.html
Normal file
@@ -0,0 +1,48 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="tr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>dreport - Render Test</title>
|
||||
<style>
|
||||
/* @font-face declarations — must match the fonts used by PDF renderer */
|
||||
@font-face {
|
||||
font-family: 'Noto Sans';
|
||||
src: url('/fonts/NotoSans-Regular.ttf') format('truetype');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Noto Sans';
|
||||
src: url('/fonts/NotoSans-Bold.ttf') format('truetype');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Noto Sans';
|
||||
src: url('/fonts/NotoSans-Italic.ttf') format('truetype');
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Noto Sans';
|
||||
src: url('/fonts/NotoSans-BoldItalic.ttf') format('truetype');
|
||||
font-weight: 700;
|
||||
font-style: italic;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Noto Sans Mono';
|
||||
src: url('/fonts/NotoSansMono-Regular.ttf') format('truetype');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { background: white; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/render-test/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -604,11 +604,7 @@ async function downloadPdf() {
|
||||
const blob = await editorRef.value?.exportPdf()
|
||||
if (!blob) return
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${template.value.name || 'belge'}.pdf`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
window.open(url, '_blank')
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'PDF olusturulamadi')
|
||||
} finally {
|
||||
@@ -668,7 +664,7 @@ function resetTemplate() {
|
||||
<!-- Output -->
|
||||
<button class="header-btn" :disabled="pdfLoading" @click="downloadPdf">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="1" width="10" height="14" rx="1.5" /><path d="M6 5h4M6 8h4M6 11h2" /></svg>
|
||||
{{ pdfLoading ? 'Hazirlaniyor...' : 'PDF Indir' }}
|
||||
{{ pdfLoading ? 'Hazirlaniyor...' : 'PDF Onizle' }}
|
||||
</button>
|
||||
</header>
|
||||
<DreportEditor
|
||||
|
||||
@@ -3,12 +3,15 @@ import { computed } from 'vue'
|
||||
import { useTemplateStore } from '../../stores/template'
|
||||
import { useEditorStore } from '../../stores/editor'
|
||||
import { isContainer } from '../../core/types'
|
||||
import type { ContainerElement, TextStyle, RepeatingTableElement, TableStyle } from '../../core/types'
|
||||
import type { ElementLayout } from '../../core/layout-types'
|
||||
import type { ContainerElement, TextStyle, RepeatingTableElement, TableStyle, ChartElement, ChartType } from '../../core/types'
|
||||
import type { LayoutMapEntry } from '../../core/layout-types'
|
||||
|
||||
const PAGE_GAP_PX = 24
|
||||
|
||||
const props = defineProps<{
|
||||
scale: number
|
||||
layoutMap: Record<string, ElementLayout>
|
||||
layoutMap: Record<string, LayoutMapEntry>
|
||||
pageHeightPx?: number
|
||||
}>()
|
||||
|
||||
const templateStore = useTemplateStore()
|
||||
@@ -36,6 +39,15 @@ const isTable = computed(() => selected.value?.type === 'repeating_table')
|
||||
const tableEl = computed(() => isTable.value ? selected.value as RepeatingTableElement : null)
|
||||
const tableStyle = computed(() => tableEl.value?.style as TableStyle | undefined)
|
||||
|
||||
const isChart = computed(() => selected.value?.type === 'chart')
|
||||
const chartEl = computed(() => isChart.value ? selected.value as ChartElement : null)
|
||||
|
||||
function pageYOffset(pageIndex: number): number {
|
||||
if (pageIndex <= 0) return 0
|
||||
const pageH = props.pageHeightPx ?? (templateStore.template.page.height * props.scale)
|
||||
return pageIndex * (pageH + PAGE_GAP_PX)
|
||||
}
|
||||
|
||||
const toolbarStyle = computed(() => {
|
||||
const el = selected.value
|
||||
if (!el) return { display: 'none' }
|
||||
@@ -43,10 +55,11 @@ const toolbarStyle = computed(() => {
|
||||
if (!l) return { display: 'none' }
|
||||
|
||||
const s = props.scale
|
||||
const pYOff = pageYOffset(l.pageIndex)
|
||||
return {
|
||||
position: 'absolute' as const,
|
||||
left: `${l.x_mm * s}px`,
|
||||
top: `${l.y_mm * s - 30}px`,
|
||||
top: `${l.y_mm * s - 30 + pYOff}px`,
|
||||
zIndex: 1100,
|
||||
}
|
||||
})
|
||||
@@ -76,6 +89,13 @@ function updateTableStyle(key: string, value: unknown) {
|
||||
if (!selected.value) return
|
||||
update({ style: { ...selected.value.style, [key]: value } })
|
||||
}
|
||||
|
||||
// Chart
|
||||
function setChartType(t: ChartType) { update({ chartType: t }) }
|
||||
function updateChartStyle(key: string, value: unknown) {
|
||||
if (!selected.value) return
|
||||
update({ style: { ...selected.value.style, [key]: value } })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -300,6 +320,73 @@ function updateTableStyle(key: string, value: unknown) {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ===== Chart ===== -->
|
||||
<template v-if="isChart && chartEl">
|
||||
<!-- Chart type -->
|
||||
<div class="et__group">
|
||||
<button class="et__btn" :class="{ 'et__btn--active': chartEl.chartType === 'bar' }" data-tip="Cubuk" @click="setChartType('bar')">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<rect x="2" y="6" width="3" height="6" rx="0.5" fill="currentColor"/>
|
||||
<rect x="5.5" y="3" width="3" height="9" rx="0.5" fill="currentColor"/>
|
||||
<rect x="9" y="5" width="3" height="7" rx="0.5" fill="currentColor"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="et__btn" :class="{ 'et__btn--active': chartEl.chartType === 'line' }" data-tip="Cizgi" @click="setChartType('line')">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<polyline points="2,10 5,5 8,7 12,3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
<circle cx="2" cy="10" r="1.2" fill="currentColor"/><circle cx="5" cy="5" r="1.2" fill="currentColor"/><circle cx="8" cy="7" r="1.2" fill="currentColor"/><circle cx="12" cy="3" r="1.2" fill="currentColor"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="et__btn" :class="{ 'et__btn--active': chartEl.chartType === 'pie' }" data-tip="Pasta" @click="setChartType('pie')">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<path d="M7 2a5 5 0 1 1-3.54 1.46" stroke="currentColor" stroke-width="1.3" fill="none"/>
|
||||
<path d="M7 7V2A5 5 0 0 0 3.46 3.46Z" fill="currentColor"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="et__sep" />
|
||||
|
||||
<!-- Show labels -->
|
||||
<div class="et__group">
|
||||
<button class="et__btn" :class="{ 'et__btn--active': chartEl.labels?.show !== false }" data-tip="Etiketler" @click="update({ labels: { ...chartEl.labels, show: chartEl.labels?.show === false ? true : false } })">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<rect x="2" y="8" width="3" height="4" rx="0.5" fill="currentColor" opacity="0.4"/>
|
||||
<rect x="5.5" y="5" width="3" height="7" rx="0.5" fill="currentColor" opacity="0.4"/>
|
||||
<rect x="9" y="6" width="3" height="6" rx="0.5" fill="currentColor" opacity="0.4"/>
|
||||
<text x="3.5" y="7" font-size="4" fill="currentColor" text-anchor="middle" font-weight="bold">3</text>
|
||||
<text x="7" y="4" font-size="4" fill="currentColor" text-anchor="middle" font-weight="bold">7</text>
|
||||
<text x="10.5" y="5" font-size="4" fill="currentColor" text-anchor="middle" font-weight="bold">5</text>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="et__sep" />
|
||||
|
||||
<!-- Show grid -->
|
||||
<div class="et__group">
|
||||
<button class="et__btn" :class="{ 'et__btn--active': chartEl.axis?.showGrid !== false }" data-tip="Izgara" @click="update({ axis: { ...chartEl.axis, showGrid: chartEl.axis?.showGrid === false ? true : false } })">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<line x1="2" y1="3" x2="12" y2="3" stroke="currentColor" stroke-width="0.8" stroke-dasharray="2 1.5"/>
|
||||
<line x1="2" y1="7" x2="12" y2="7" stroke="currentColor" stroke-width="0.8" stroke-dasharray="2 1.5"/>
|
||||
<line x1="2" y1="11" x2="12" y2="11" stroke="currentColor" stroke-width="0.8" stroke-dasharray="2 1.5"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="et__sep" />
|
||||
|
||||
<!-- Background color -->
|
||||
<div class="et__group">
|
||||
<label class="et__color-wrap" data-tip="Arka Plan">
|
||||
<input type="color" class="et__color" :value="chartEl.style.backgroundColor ?? '#ffffff'" @input="(e) => updateChartStyle('backgroundColor', (e.target as HTMLInputElement).value)" />
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<rect x="2" y="2" width="10" height="10" rx="1.5" :fill="chartEl.style.backgroundColor ?? '#ffffff'" stroke="#94a3b8" stroke-width="0.8"/>
|
||||
</svg>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ===== Line ===== -->
|
||||
<template v-if="isLine">
|
||||
<!-- Stroke width -->
|
||||
|
||||
@@ -713,6 +713,7 @@ const isAnyDragActive = computed(() =>
|
||||
v-if="!isDragging && !isResizing"
|
||||
:scale="scale"
|
||||
:layout-map="layoutMap"
|
||||
:page-height-px="pageHeightPx"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
121
frontend/src/render-test/RenderTestPage.vue
Normal file
121
frontend/src/render-test/RenderTestPage.vue
Normal file
@@ -0,0 +1,121 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, provide, onMounted } from 'vue'
|
||||
import LayoutRenderer from '../components/editor/LayoutRenderer.vue'
|
||||
import { useLayoutEngine } from '../composables/useLayoutEngine'
|
||||
import type { Template } from '../core/types'
|
||||
|
||||
// Fixture data is injected by Playwright via window.__DREPORT_FIXTURE__
|
||||
declare global {
|
||||
interface Window {
|
||||
__DREPORT_FIXTURE__?: { template: string; data: string }
|
||||
}
|
||||
}
|
||||
|
||||
// DPI matching: 150 DPI = 150/25.4 px/mm (must match pdftoppm -r 150)
|
||||
const DPI = 150
|
||||
const SCALE = DPI / 25.4
|
||||
|
||||
const ready = ref(false)
|
||||
const errorMsg = ref<string | null>(null)
|
||||
|
||||
const template = ref<Template>({
|
||||
id: 'empty',
|
||||
name: 'empty',
|
||||
page: { width: 210, height: 297 },
|
||||
fonts: ['Noto Sans'],
|
||||
root: {
|
||||
id: 'root', type: 'container',
|
||||
position: { type: 'flow' },
|
||||
size: { width: { type: 'auto' }, height: { type: 'auto' } },
|
||||
direction: 'column', gap: 0,
|
||||
padding: { top: 0, right: 0, bottom: 0, left: 0 },
|
||||
align: 'stretch', justify: 'start',
|
||||
style: {}, children: [],
|
||||
},
|
||||
})
|
||||
const data = ref<Record<string, unknown>>({})
|
||||
|
||||
const { layout, generateBarcode, error } = useLayoutEngine(template, data)
|
||||
|
||||
// Provide barcode generator for LayoutRenderer
|
||||
provide('generateBarcode', generateBarcode)
|
||||
|
||||
// Watch for layout computation to complete
|
||||
const checkReady = setInterval(() => {
|
||||
if (layout.value && layout.value.pages.length > 0) {
|
||||
ready.value = true
|
||||
clearInterval(checkReady)
|
||||
}
|
||||
if (error.value) {
|
||||
errorMsg.value = error.value
|
||||
clearInterval(checkReady)
|
||||
}
|
||||
}, 50)
|
||||
|
||||
// Timeout after 20 seconds
|
||||
setTimeout(() => {
|
||||
clearInterval(checkReady)
|
||||
if (!ready.value) {
|
||||
errorMsg.value = 'Layout computation timed out'
|
||||
}
|
||||
}, 20000)
|
||||
|
||||
onMounted(() => {
|
||||
const fixture = window.__DREPORT_FIXTURE__
|
||||
if (!fixture) {
|
||||
errorMsg.value = 'No fixture data found. Set window.__DREPORT_FIXTURE__ = { template, data }'
|
||||
return
|
||||
}
|
||||
try {
|
||||
template.value = JSON.parse(fixture.template)
|
||||
data.value = JSON.parse(fixture.data)
|
||||
} catch (e) {
|
||||
errorMsg.value = `Failed to parse fixture data: ${e}`
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="render-test-root"
|
||||
:data-render-ready="ready || undefined"
|
||||
:data-render-error="errorMsg || undefined"
|
||||
>
|
||||
<LayoutRenderer
|
||||
v-if="layout"
|
||||
:layout="layout"
|
||||
:scale="SCALE"
|
||||
/>
|
||||
<div v-else-if="errorMsg" class="error">{{ errorMsg }}</div>
|
||||
<div v-else class="loading">Computing layout...</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.render-test-root {
|
||||
/* No padding/margin — pixel-exact rendering */
|
||||
background: white;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: red;
|
||||
padding: 20px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.loading {
|
||||
color: #999;
|
||||
padding: 20px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* Override layout-page styles for test — no shadow, no margin */
|
||||
.layout-page {
|
||||
box-shadow: none !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.layout-page + .layout-page {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
</style>
|
||||
4
frontend/src/render-test/main.ts
Normal file
4
frontend/src/render-test/main.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { createApp } from 'vue'
|
||||
import RenderTestPage from './RenderTestPage.vue'
|
||||
|
||||
createApp(RenderTestPage).mount('#app')
|
||||
166
frontend/tests/visual/cross-renderer.spec.ts
Normal file
166
frontend/tests/visual/cross-renderer.spec.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
@@ -1,8 +1,17 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import { resolve } from 'path'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: resolve(__dirname, 'index.html'),
|
||||
'render-test': resolve(__dirname, 'render-test.html'),
|
||||
},
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
dedupe: [
|
||||
'@codemirror/state',
|
||||
|
||||
Reference in New Issue
Block a user