bug fixes & improvements & missing features & font loader

This commit is contained in:
2026-04-07 00:36:21 +03:00
parent ad0d2fda0a
commit b6287906a9
50 changed files with 4087 additions and 1843 deletions

View File

@@ -6,6 +6,7 @@ import { useEditorStore } from '../../stores/editor'
import { useLayoutEngine } from '../../composables/useLayoutEngine'
import LayoutRenderer from './LayoutRenderer.vue'
import InteractionOverlay from './InteractionOverlay.vue'
import RulerBar from './RulerBar.vue'
const props = withDefaults(defineProps<{
handleErrors?: boolean
@@ -204,6 +205,15 @@ function onPointerUp(e: PointerEvent) {
<template>
<div class="editor-canvas-wrapper">
<!-- Cetvel -->
<RulerBar
:page-width="templateStore.template.page.width"
:page-height="templateStore.template.page.height"
:scale="scale"
:pan-x="editorStore.panX"
:pan-y="editorStore.panY"
/>
<!-- Scroll alanı -->
<div
class="editor-canvas"
@@ -252,6 +262,8 @@ function onPointerUp(e: PointerEvent) {
align-items: flex-start;
justify-content: center;
padding: 40px;
padding-top: 60px; /* cetvel için üstten ek boşluk */
padding-left: 60px; /* cetvel için soldan ek boşluk */
}
.editor-canvas__pages {

View File

@@ -13,7 +13,7 @@ const props = defineProps<{
const editorStore = useEditorStore()
const templateStore = useTemplateStore()
const isSelected = computed(() => editorStore.selectedElementId === props.element.id)
const isSelected = computed(() => editorStore.isSelected(props.element.id))
const isContainerEl = computed(() => isContainer(props.element))
const isAbsolute = computed(() => props.element.position.type === 'absolute')

View File

@@ -96,6 +96,12 @@ function updateChartStyle(key: string, value: unknown) {
if (!selected.value) return
update({ style: { ...selected.value.style, [key]: value } })
}
// Z-order
function bringForward() { if (selected.value) templateStore.bringForward(selected.value.id) }
function sendBackward() { if (selected.value) templateStore.sendBackward(selected.value.id) }
function bringToFront() { if (selected.value) templateStore.bringToFront(selected.value.id) }
function sendToBack() { if (selected.value) templateStore.sendToBack(selected.value.id) }
</script>
<template>
@@ -409,6 +415,37 @@ function updateChartStyle(key: string, value: unknown) {
</label>
</div>
</template>
<!-- ===== Z-Order (tüm elemanlar) ===== -->
<template v-if="selected">
<div class="et__sep" />
<div class="et__group">
<button class="et__btn" data-tip="Arkaya Gonder" @click="sendToBack">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<rect x="5" y="5" width="7" height="7" rx="1" fill="currentColor" opacity="0.3"/>
<rect x="2" y="2" width="7" height="7" rx="1" fill="currentColor"/>
</svg>
</button>
<button class="et__btn" data-tip="Bir Geri" @click="sendBackward">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<rect x="5" y="5" width="7" height="7" rx="1" fill="currentColor" opacity="0.3"/>
<rect x="2" y="2" width="7" height="7" rx="1" stroke="currentColor" stroke-width="1.2" fill="none"/>
</svg>
</button>
<button class="et__btn" data-tip="Bir Ileri" @click="bringForward">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<rect x="2" y="2" width="7" height="7" rx="1" fill="currentColor" opacity="0.3"/>
<rect x="5" y="5" width="7" height="7" rx="1" stroke="currentColor" stroke-width="1.2" fill="none"/>
</svg>
</button>
<button class="et__btn" data-tip="One Getir" @click="bringToFront">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<rect x="2" y="2" width="7" height="7" rx="1" fill="currentColor" opacity="0.3"/>
<rect x="5" y="5" width="7" height="7" rx="1" fill="currentColor"/>
</svg>
</button>
</div>
</template>
</div>
</template>

View File

@@ -98,7 +98,11 @@ function getElementStyle(el: TemplateElement) {
function onElementClick(e: PointerEvent, id: string) {
e.stopPropagation()
if (didDrag.value) return
editorStore.selectElement(id)
if (e.shiftKey) {
editorStore.toggleSelection(id)
} else {
editorStore.selectElement(id)
}
}
function onCanvasClick() {
@@ -637,7 +641,7 @@ const isAnyDragActive = computed(() =>
:key="el.id"
class="element-handle"
:class="{
'element-handle--selected': editorStore.selectedElementId === el.id,
'element-handle--selected': editorStore.isSelected(el.id),
'element-handle--container': isContainer(el),
'element-handle--dragging': isDragging && dragElementId === el.id,
'element-handle--drop-target': isContainer(el) && dropTargetContainerId === el.id && isAnyDragActive,
@@ -646,10 +650,10 @@ const isAnyDragActive = computed(() =>
@pointerdown="(e: PointerEvent) => { onElementClick(e, el.id); onDragStart(e, el) }"
>
<!-- Selection border -->
<div v-if="editorStore.selectedElementId === el.id" class="selection-border" />
<div v-if="editorStore.isSelected(el.id)" class="selection-border" />
<!-- Resize handles -->
<template v-if="editorStore.selectedElementId === el.id && !isResizing && el.type !== 'page_break'">
<!-- Resize handles (sadece tek seçimde) -->
<template v-if="editorStore.isSelected(el.id) && editorStore.selectedElementIds.size === 1 && !isResizing && el.type !== 'page_break'">
<template v-if="el.type === 'barcode' || el.type === 'image'">
<!-- Barkod/Görsel: sadece yatay resize (aspect ratio korunur) -->
<div class="resize-handle resize-handle--e" @pointerdown="(e: PointerEvent) => onResizeStart(e, el.id, 'e')" />

View File

@@ -234,7 +234,7 @@ watch(
:style="{
width: '100%',
height: '100%',
objectFit: 'fill',
objectFit: el.style.objectFit || 'fill',
}"
/>
<div v-else class="layout-el__placeholder">Görsel</div>

View File

@@ -0,0 +1,231 @@
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
const props = defineProps<{
/** Sayfa genişliği mm */
pageWidth: number
/** Sayfa yüksekliği mm */
pageHeight: number
/** mm → px dönüşüm katsayısı (scale * zoom) */
scale: number
/** Pan offset X (px) */
panX: number
/** Pan offset Y (px) */
panY: number
/** Cetvel kalınlığı px */
rulerSize?: number
}>()
const RULER_SIZE = computed(() => props.rulerSize ?? 20)
const hCanvas = ref<HTMLCanvasElement | null>(null)
const vCanvas = ref<HTMLCanvasElement | null>(null)
function drawRuler(
canvas: HTMLCanvasElement | null,
direction: 'horizontal' | 'vertical',
) {
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const dpr = window.devicePixelRatio || 1
const size = RULER_SIZE.value
if (direction === 'horizontal') {
const w = canvas.clientWidth
canvas.width = w * dpr
canvas.height = size * dpr
ctx.scale(dpr, dpr)
ctx.clearRect(0, 0, w, size)
ctx.fillStyle = '#f1f5f9'
ctx.fillRect(0, 0, w, size)
ctx.strokeStyle = '#e2e8f0'
ctx.lineWidth = 1
ctx.beginPath()
ctx.moveTo(0, size - 0.5)
ctx.lineTo(w, size - 0.5)
ctx.stroke()
drawTicks(ctx, direction, w, size)
} else {
const h = canvas.clientHeight
canvas.width = size * dpr
canvas.height = h * dpr
ctx.scale(dpr, dpr)
ctx.clearRect(0, 0, size, h)
ctx.fillStyle = '#f1f5f9'
ctx.fillRect(0, 0, size, h)
ctx.strokeStyle = '#e2e8f0'
ctx.lineWidth = 1
ctx.beginPath()
ctx.moveTo(size - 0.5, 0)
ctx.lineTo(size - 0.5, h)
ctx.stroke()
drawTicks(ctx, direction, h, size)
}
}
function drawTicks(
ctx: CanvasRenderingContext2D,
direction: 'horizontal' | 'vertical',
length: number,
size: number,
) {
const s = props.scale
const pageMm = direction === 'horizontal' ? props.pageWidth : props.pageHeight
const pan = direction === 'horizontal' ? props.panX : props.panY
// Sayfa başlangıcı: ortaya hizalı + pan
// EditorCanvas sayfayı ortalar, ruler da buna uymalı
// Yatay: canvas ortası - sayfa genişliği/2
// Sayfanın canvas üzerindeki orijin px'i
const canvasCenter = direction === 'horizontal'
? (length / 2) // flex centering approximation
: 40 // EditorCanvas padding-top: 40px
const pageStartPx = canvasCenter - (pageMm * s) / 2 + pan
// Tick aralığı belirleme (zoom'a göre)
const mmPerPx = 1 / s
let tickMm = 1
if (mmPerPx > 2) tickMm = 50
else if (mmPerPx > 1) tickMm = 20
else if (mmPerPx > 0.5) tickMm = 10
else if (mmPerPx > 0.2) tickMm = 5
else tickMm = 1
ctx.fillStyle = '#94a3b8'
ctx.strokeStyle = '#94a3b8'
ctx.lineWidth = 0.5
ctx.font = '9px system-ui, sans-serif'
ctx.textBaseline = 'top'
// Sayfanın mm aralığını çiz
const startMm = 0
const endMm = pageMm
for (let mm = startMm; mm <= endMm; mm += tickMm) {
const px = pageStartPx + mm * s
if (px < -10 || px > length + 10) continue
const isMajor = mm % 10 === 0
const isMedium = mm % 5 === 0
let tickLen = 4
if (isMajor) tickLen = size * 0.6
else if (isMedium) tickLen = size * 0.35
ctx.beginPath()
if (direction === 'horizontal') {
ctx.moveTo(px, size)
ctx.lineTo(px, size - tickLen)
} else {
ctx.moveTo(size, px)
ctx.lineTo(size - tickLen, px)
}
ctx.stroke()
// Sayı etiketi (her 10mm'de bir)
if (isMajor && mm > 0) {
const label = String(mm)
if (direction === 'horizontal') {
ctx.textAlign = 'center'
ctx.fillText(label, px, 2)
} else {
ctx.save()
ctx.translate(2, px)
ctx.rotate(-Math.PI / 2)
ctx.textAlign = 'center'
ctx.fillText(label, 0, 0)
ctx.restore()
}
}
}
// Sayfa kenar çizgileri (margin göstergesi)
ctx.strokeStyle = 'rgba(59, 130, 246, 0.3)'
ctx.lineWidth = 1
const startPx = pageStartPx
const endPx = pageStartPx + pageMm * s
ctx.beginPath()
if (direction === 'horizontal') {
ctx.moveTo(startPx, 0)
ctx.lineTo(startPx, size)
ctx.moveTo(endPx, 0)
ctx.lineTo(endPx, size)
} else {
ctx.moveTo(0, startPx)
ctx.lineTo(size, startPx)
ctx.moveTo(0, endPx)
ctx.lineTo(size, endPx)
}
ctx.stroke()
}
function redraw() {
drawRuler(hCanvas.value, 'horizontal')
drawRuler(vCanvas.value, 'vertical')
}
watch(() => [props.scale, props.panX, props.panY, props.pageWidth, props.pageHeight], redraw)
let resizeObserver: ResizeObserver | null = null
onMounted(() => {
redraw()
const parent = hCanvas.value?.parentElement?.parentElement
if (parent) {
resizeObserver = new ResizeObserver(() => redraw())
resizeObserver.observe(parent)
}
})
onBeforeUnmount(() => {
resizeObserver?.disconnect()
})
</script>
<template>
<div class="ruler-corner" :style="{ width: `${RULER_SIZE}px`, height: `${RULER_SIZE}px` }" />
<canvas
ref="hCanvas"
class="ruler-h"
:style="{ height: `${RULER_SIZE}px` }"
/>
<canvas
ref="vCanvas"
class="ruler-v"
:style="{ width: `${RULER_SIZE}px` }"
/>
</template>
<style scoped>
.ruler-corner {
position: absolute;
top: 0;
left: 0;
background: #f1f5f9;
border-right: 1px solid #e2e8f0;
border-bottom: 1px solid #e2e8f0;
z-index: 50;
}
.ruler-h {
position: absolute;
top: 0;
left: 20px;
right: 0;
z-index: 50;
pointer-events: none;
}
.ruler-v {
position: absolute;
top: 20px;
left: 0;
bottom: 0;
z-index: 50;
pointer-events: none;
}
</style>

View File

@@ -38,11 +38,15 @@ const templateStore = useTemplateStore()
const editorStore = useEditorStore()
const selectedElement = computed(() => {
const id = editorStore.selectedElementId
const ids = editorStore.selectedElementIds
if (ids.size !== 1) return null
const id = ids.values().next().value
if (!id) return null
return templateStore.getElementById(id) ?? null
})
const multipleSelected = computed(() => editorStore.selectedElementIds.size > 1)
const elementTypeLabel = computed(() => {
const el = selectedElement.value
if (!el) return ''
@@ -87,11 +91,24 @@ function deleteElement() {
editorStore.clearSelection()
templateStore.removeElement(id)
}
function deleteSelected() {
const ids = [...editorStore.selectedElementIds]
editorStore.clearSelection()
for (const id of ids) {
if (id !== 'root') templateStore.removeElement(id)
}
}
</script>
<template>
<div class="properties-panel">
<div v-if="!selectedElement" class="properties-panel__empty">
<div v-if="multipleSelected" class="properties-panel__empty">
{{ editorStore.selectedElementIds.size }} eleman secili
<button class="prop-delete-btn" style="margin-top: 12px" @click="deleteSelected">Secilenleri Sil</button>
</div>
<div v-else-if="!selectedElement" class="properties-panel__empty">
Bir eleman secin
</div>

View File

@@ -1,12 +1,18 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useTemplateStore } from '../../stores/template'
import { useEditorStore } from '../../stores/editor'
import { useSchemaStore } from '../../stores/schema'
import type { ImageElement, TemplateElement } from '../../core/types'
import '../../styles/properties.css'
const props = defineProps<{ element: ImageElement }>()
const templateStore = useTemplateStore()
const editorStore = useEditorStore()
const schemaStore = useSchemaStore()
/** Statik mi dinamik mi? */
const isDynamic = computed(() => !!props.element.binding)
function update(updates: Partial<TemplateElement>) {
const id = editorStore.selectedElementId
@@ -24,30 +30,86 @@ function onImageFileSelect(e: Event) {
if (!file) return
const reader = new FileReader()
reader.onload = () => {
update({ src: reader.result as string } as Partial<TemplateElement>)
update({ src: reader.result as string, binding: undefined } as Partial<TemplateElement>)
}
reader.readAsDataURL(file)
}
function setMode(mode: 'static' | 'dynamic') {
if (mode === 'static') {
update({ binding: undefined } as Partial<TemplateElement>)
} else {
// Dinamik moda geç — ilk uygun alanı seç veya boş bırak
const imageFields = schemaStore.scalarFields.filter(f => f.format === 'image' || f.type === 'string')
const path = imageFields.length > 0 ? imageFields[0].path : ''
update({ src: undefined, binding: { type: 'scalar', path } } as Partial<TemplateElement>)
}
}
function setBindingPath(path: string) {
update({ binding: { type: 'scalar', path } } as Partial<TemplateElement>)
}
/** Schema'dan görsel olabilecek alanlar (format: image veya string) */
const imageScalarFields = computed(() => {
return schemaStore.scalarFields.filter(f => f.format === 'image' || f.type === 'string')
})
</script>
<template>
<div class="prop-section">
<div class="prop-section__title">Gorsel</div>
<div class="prop-row" data-tip="Gorsel dosyasi secin (PNG, JPG, SVG)">
<label class="prop-label">Kaynak</label>
<label class="prop-file-btn">
Dosya Sec
<input type="file" accept="image/*" style="display: none" @change="onImageFileSelect" />
</label>
</div>
<div v-if="element.src" class="prop-row" data-tip="Yuklenen gorsel onizlemesi">
<label class="prop-label">Onizleme</label>
<img :src="element.src" class="prop-image-preview" />
</div>
<div v-if="element.src" class="prop-row" data-tip="Gorseli kaldirmak icin tiklayin">
<label class="prop-label"></label>
<button class="prop-clear" @click="update({ src: undefined } as any)">Gorseli kaldir</button>
<!-- Statik / Dinamik toggle -->
<div class="prop-row" data-tip="Gorsel kaynagi: dosya veya veri alanından">
<label class="prop-label">Mod</label>
<div class="prop-toggle-group">
<button class="prop-toggle-btn" :class="{ 'prop-toggle-btn--active': !isDynamic }" @click="setMode('static')">Statik</button>
<button class="prop-toggle-btn" :class="{ 'prop-toggle-btn--active': isDynamic }" @click="setMode('dynamic')">Dinamik</button>
</div>
</div>
<!-- Statik: dosya seçimi -->
<template v-if="!isDynamic">
<div class="prop-row" data-tip="Gorsel dosyasi secin (PNG, JPG, SVG)">
<label class="prop-label">Kaynak</label>
<label class="prop-file-btn">
Dosya Sec
<input type="file" accept="image/*" style="display: none" @change="onImageFileSelect" />
</label>
</div>
<div v-if="element.src" class="prop-row" data-tip="Yuklenen gorsel onizlemesi">
<label class="prop-label">Onizleme</label>
<img :src="element.src" class="prop-image-preview" />
</div>
<div v-if="element.src" class="prop-row" data-tip="Gorseli kaldirmak icin tiklayin">
<label class="prop-label"></label>
<button class="prop-clear" @click="update({ src: undefined } as any)">Gorseli kaldir</button>
</div>
</template>
<!-- Dinamik: schema alan seçimi -->
<template v-else>
<div class="prop-row" data-tip="Gorsel URL'sinin gelecegi veri alani">
<label class="prop-label">Veri Alani</label>
<select class="prop-input prop-select"
:value="element.binding?.path ?? ''"
@change="(e) => setBindingPath((e.target as HTMLSelectElement).value)">
<option value="" disabled>Secin...</option>
<option
v-for="field in imageScalarFields"
:key="field.path"
:value="field.path"
>{{ field.title }} ({{ field.path }})</option>
</select>
</div>
<div v-if="element.binding?.path" class="prop-row">
<label class="prop-label">Path</label>
<span class="prop-info">{{ element.binding.path }}</span>
</div>
</template>
<!-- Sığdırma modu (ortak) -->
<div class="prop-row" data-tip="Gorselin alana sigdirma modu">
<label class="prop-label">Sigdirma</label>
<select class="prop-input prop-select"
@@ -60,3 +122,42 @@ function onImageFileSelect(e: Event) {
</div>
</div>
</template>
<style scoped>
.prop-toggle-group {
display: flex;
gap: 0;
}
.prop-toggle-btn {
flex: 1;
padding: 3px 8px;
border: 1px solid #e2e8f0;
background: white;
color: #64748b;
font-size: 11px;
cursor: pointer;
transition: background 0.1s, color 0.1s;
}
.prop-toggle-btn:first-child {
border-radius: 4px 0 0 4px;
}
.prop-toggle-btn:last-child {
border-radius: 0 4px 4px 0;
border-left: none;
}
.prop-toggle-btn--active {
background: #3b82f6;
color: white;
border-color: #3b82f6;
}
.prop-info {
font-size: 11px;
color: #94a3b8;
word-break: break-all;
}
</style>

View File

@@ -4,10 +4,16 @@ import type { LayoutResult, LayoutMapEntry } from '../core/layout-types'
export type { LayoutMapEntry }
export interface LayoutEngineOptions {
/** Font API base URL. Default: '/api/fonts' */
fontApiBase?: string
}
export function useLayoutEngine(
template: Ref<Template>,
data: Ref<Record<string, unknown>>,
layoutVersion?: Ref<number>,
options?: LayoutEngineOptions,
) {
const layout = ref<LayoutResult | null>(null)
const error = ref<string | null>(null)
@@ -24,6 +30,11 @@ export function useLayoutEngine(
type: 'module',
})
// Configure font API base if provided
if (options?.fontApiBase) {
worker.postMessage({ type: 'configure', fontApiBase: options.fontApiBase })
}
worker.onmessage = (e: MessageEvent<any>) => {
const msg = e.data
@@ -105,8 +116,15 @@ export function useLayoutEngine(
if (!worker) initWorker()
return new Promise(resolve => {
barcodeReqId++
const id = barcodeReqId + 100000 // compile id'leriyle çakışmasın
barcodeCallbacks.set(id, resolve)
const id = barcodeReqId
const timeout = setTimeout(() => {
barcodeCallbacks.delete(id)
resolve(null)
}, 5000)
barcodeCallbacks.set(id, (result) => {
clearTimeout(timeout)
resolve(result)
})
worker!.postMessage({ type: 'barcode', format, value, width, height, includeText, id })
})
}
@@ -124,6 +142,10 @@ export function useLayoutEngine(
function dispose() {
worker?.terminate()
worker = null
// Bekleyen barcode promise'lerini null ile resolve et
for (const cb of barcodeCallbacks.values()) {
cb(null)
}
barcodeCallbacks.clear()
}

View File

@@ -49,7 +49,7 @@ export function useUndoRedo<T>(source: Ref<T>, maxHistory = 50) {
function applySnapshot(snap: string) {
skipWatch = true
Object.assign(source.value as object, JSON.parse(snap))
source.value = JSON.parse(snap)
skipWatch = false
}

View File

@@ -312,7 +312,7 @@ export interface Template {
// --- Editor state ---
export interface EditorState {
selectedElementId: string | null
selectedElementIds: Set<string>
zoom: number // 0.25 - 4.0
panX: number
panY: number

View File

@@ -87,14 +87,14 @@ function onKeyDown(e: KeyboardEvent) {
const tag = target?.tagName
const isInput = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target?.isContentEditable
// Delete / Backspace
if ((e.key === 'Delete' || e.key === 'Backspace') && editorStore.selectedElementId) {
// Delete / Backspace — çoklu seçim desteği
if ((e.key === 'Delete' || e.key === 'Backspace') && editorStore.selectedElementIds.size > 0) {
if (isInput) return
e.preventDefault()
const id = editorStore.selectedElementId
if (id && id !== 'root') {
editorStore.clearSelection()
templateStore.removeElement(id)
const ids = [...editorStore.selectedElementIds]
editorStore.clearSelection()
for (const id of ids) {
if (id !== 'root') templateStore.removeElement(id)
}
}
@@ -114,6 +114,23 @@ function onKeyDown(e: KeyboardEvent) {
e.preventDefault()
templateStore.redo()
}
// Z-Order kısayolları
if ((e.ctrlKey || e.metaKey) && editorStore.selectedElementId && editorStore.selectedElementId !== 'root') {
if (e.key === ']' && e.shiftKey) {
e.preventDefault()
templateStore.bringToFront(editorStore.selectedElementId)
} else if (e.key === ']') {
e.preventDefault()
templateStore.bringForward(editorStore.selectedElementId)
} else if (e.key === '[' && e.shiftKey) {
e.preventDefault()
templateStore.sendToBack(editorStore.selectedElementId)
} else if (e.key === '[') {
e.preventDefault()
templateStore.sendBackward(editorStore.selectedElementId)
}
}
}
// Browser'ın native pinch-zoom'unu editör alanında engelle

View File

@@ -0,0 +1,523 @@
/**
* IMPROVEMENTS.md bölüm 1, 2, 3 implementasyonlarının testleri.
*
* Bölüm 1: Kritik Buglar (1.11.4)
* Bölüm 2: Önemli Teknik Sorunlar (2.9, 2.11)
* Bölüm 3: Eksik Özellikler (3.1, 3.2)
*
* Not: Rust tarafı testleri layout-engine/tests/improvements_test.rs dosyasındadır.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useTemplateStore } from '../template'
import { useEditorStore } from '../editor'
import type { Template, StaticTextElement, ContainerElement, ImageElement, TemplateElement } from '../../core/types'
import { sz } from '../../core/types'
function createTestTemplate(): Template {
return {
id: 'test',
name: 'Test',
page: { width: 210, height: 297 },
fonts: ['Noto Sans'],
root: {
id: 'root',
type: 'container' as const,
position: { type: 'flow' as const },
size: { width: sz.auto(), height: sz.auto() },
direction: 'column' as const,
gap: 5,
padding: { top: 10, right: 10, bottom: 10, left: 10 },
align: 'stretch' as const,
justify: 'start' as const,
style: {},
children: [],
},
}
}
function createTextElement(id: string, content: string): StaticTextElement {
return {
id,
type: 'static_text',
position: { type: 'flow' },
size: { width: sz.auto(), height: sz.auto() },
style: { fontSize: 12 },
content,
}
}
// =============================================================================
// 1.1 Undo/Redo — Object.assign yerine reference replacement
// =============================================================================
describe('1.1 Undo/Redo reference replacement', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('undo properly removes keys that were added after snapshot', async () => {
vi.useFakeTimers()
const store = useTemplateStore()
store.template = createTestTemplate()
// Snapshot al (debounce beklenmeli)
await vi.advanceTimersByTimeAsync(400)
// Header ekle
store.enableHeader()
expect(store.template.header).toBeDefined()
// Snapshot al
await vi.advanceTimersByTimeAsync(400)
// Undo: header eklenmeden önceki state'e dön
store.undo()
expect(store.template.header).toBeUndefined()
vi.useRealTimers()
})
it('undo properly removes footer key', async () => {
vi.useFakeTimers()
const store = useTemplateStore()
store.template = createTestTemplate()
await vi.advanceTimersByTimeAsync(400)
store.enableFooter()
expect(store.template.footer).toBeDefined()
await vi.advanceTimersByTimeAsync(400)
store.undo()
expect(store.template.footer).toBeUndefined()
vi.useRealTimers()
})
it('redo restores the removed key after undo', async () => {
vi.useFakeTimers()
const store = useTemplateStore()
store.template = createTestTemplate()
await vi.advanceTimersByTimeAsync(400)
store.enableHeader()
await vi.advanceTimersByTimeAsync(400)
store.undo()
expect(store.template.header).toBeUndefined()
store.redo()
expect(store.template.header).toBeDefined()
expect(store.template.header!.id).toBe('header')
vi.useRealTimers()
})
})
// =============================================================================
// 1.3 Image objectFit — LayoutRenderer'da style.objectFit okunmalı
// (Birim test olarak ImageElement tipi üzerinden doğrulanır)
// =============================================================================
describe('1.3 Image objectFit', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('ImageElement stores objectFit in style', () => {
const store = useTemplateStore()
store.template = createTestTemplate()
const img: ImageElement = {
id: 'img_1',
type: 'image',
position: { type: 'flow' },
size: { width: sz.fixed(50), height: sz.fixed(30) },
src: 'data:image/png;base64,abc',
style: { objectFit: 'contain' },
}
store.addChild('root', img as unknown as TemplateElement)
const el = store.getElementById('img_1') as ImageElement
expect(el.style.objectFit).toBe('contain')
})
it('updateElement changes objectFit', () => {
const store = useTemplateStore()
store.template = createTestTemplate()
const img: ImageElement = {
id: 'img_2',
type: 'image',
position: { type: 'flow' },
size: { width: sz.fixed(50), height: sz.fixed(30) },
src: 'data:image/png;base64,abc',
style: { objectFit: 'contain' },
}
store.addChild('root', img as unknown as TemplateElement)
store.updateElement('img_2', { style: { objectFit: 'cover' } } as Partial<TemplateElement>)
const el = store.getElementById('img_2') as ImageElement
expect(el.style.objectFit).toBe('cover')
})
})
// =============================================================================
// 2.9 importTemplate validasyon
// =============================================================================
describe('2.9 importTemplate validation', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('throws on invalid JSON', () => {
const store = useTemplateStore()
expect(() => store.importTemplate('not json')).toThrow('Geçersiz JSON')
})
it('throws on missing root', () => {
const store = useTemplateStore()
const bad = JSON.stringify({ page: { width: 210, height: 297 } })
expect(() => store.importTemplate(bad)).toThrow('root')
})
it('throws on root that is not container', () => {
const store = useTemplateStore()
const bad = JSON.stringify({
root: { type: 'text', id: 'r' },
page: { width: 210, height: 297 },
})
expect(() => store.importTemplate(bad)).toThrow('container')
})
it('throws on missing page', () => {
const store = useTemplateStore()
const bad = JSON.stringify({
root: { type: 'container', id: 'root', children: [] },
})
expect(() => store.importTemplate(bad)).toThrow('page')
})
it('throws on invalid page dimensions', () => {
const store = useTemplateStore()
const bad = JSON.stringify({
root: { type: 'container', id: 'root', children: [] },
page: { width: 'abc', height: 297 },
})
expect(() => store.importTemplate(bad)).toThrow('page')
})
it('preserves previous state on failed import', () => {
const store = useTemplateStore()
store.template = createTestTemplate()
store.addChild('root', createTextElement('keep_me', 'Keep'))
try {
store.importTemplate('invalid json')
} catch {
// beklenen
}
// Önceki state korunmuş olmalı
expect(store.getElementById('keep_me')).toBeDefined()
})
it('accepts valid template JSON', () => {
const store = useTemplateStore()
const tpl = createTestTemplate()
tpl.name = 'Valid Import'
const json = JSON.stringify(tpl)
store.importTemplate(json)
expect(store.template.name).toBe('Valid Import')
})
})
// =============================================================================
// 2.11 moveElement — tek layoutVersion bump
// =============================================================================
describe('2.11 moveElement single layoutVersion bump', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('moveElement increments layoutVersion exactly once', () => {
const store = useTemplateStore()
store.template = createTestTemplate()
// İç içe container yapısı oluştur
const child: ContainerElement = {
id: 'child_container',
type: 'container',
position: { type: 'flow' },
size: { width: sz.auto(), height: sz.auto() },
direction: 'column',
gap: 0,
padding: { top: 0, right: 0, bottom: 0, left: 0 },
align: 'stretch',
justify: 'start',
style: {},
children: [],
}
store.addChild('root', child as unknown as TemplateElement)
store.addChild('root', createTextElement('el_move', 'Move me'))
const versionBefore = store.layoutVersion
store.moveElement('el_move', 'child_container')
// Tek bump: tam olarak 1 artmalı
expect(store.layoutVersion).toBe(versionBefore + 1)
// Eleman taşınmış olmalı
const moved = store.getElementById('el_move')
expect(moved).toBeDefined()
const parent = store.getParent('el_move')
expect(parent?.id).toBe('child_container')
})
})
// =============================================================================
// 3.1 Çoklu Seçim (Multi-Selection)
// =============================================================================
describe('3.1 Multi-Selection', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('selectedElementIds starts empty', () => {
const store = useEditorStore()
expect(store.selectedElementIds.size).toBe(0)
expect(store.selectedElementId).toBeNull()
})
it('selectElement sets single selection', () => {
const store = useEditorStore()
store.selectElement('el_1')
expect(store.selectedElementIds.size).toBe(1)
expect(store.selectedElementId).toBe('el_1')
})
it('selectElement clears previous selection', () => {
const store = useEditorStore()
store.selectElement('el_1')
store.selectElement('el_2')
expect(store.selectedElementIds.size).toBe(1)
expect(store.selectedElementId).toBe('el_2')
expect(store.isSelected('el_1')).toBe(false)
})
it('toggleSelection adds to selection', () => {
const store = useEditorStore()
store.selectElement('el_1')
store.toggleSelection('el_2')
expect(store.selectedElementIds.size).toBe(2)
expect(store.isSelected('el_1')).toBe(true)
expect(store.isSelected('el_2')).toBe(true)
})
it('toggleSelection removes from selection', () => {
const store = useEditorStore()
store.selectElement('el_1')
store.toggleSelection('el_2')
store.toggleSelection('el_1')
expect(store.selectedElementIds.size).toBe(1)
expect(store.isSelected('el_1')).toBe(false)
expect(store.isSelected('el_2')).toBe(true)
})
it('clearSelection clears all', () => {
const store = useEditorStore()
store.selectElement('el_1')
store.toggleSelection('el_2')
store.toggleSelection('el_3')
expect(store.selectedElementIds.size).toBe(3)
store.clearSelection()
expect(store.selectedElementIds.size).toBe(0)
expect(store.selectedElementId).toBeNull()
})
it('isSelected returns correct state', () => {
const store = useEditorStore()
expect(store.isSelected('el_1')).toBe(false)
store.selectElement('el_1')
expect(store.isSelected('el_1')).toBe(true)
expect(store.isSelected('el_2')).toBe(false)
})
it('selectedElementId returns first selected (backward compat)', () => {
const store = useEditorStore()
store.selectElement('el_1')
store.toggleSelection('el_2')
// İlk eklenen eleman
expect(store.selectedElementId).toBe('el_1')
})
it('selectElement(null) clears selection', () => {
const store = useEditorStore()
store.selectElement('el_1')
store.selectElement(null)
expect(store.selectedElementIds.size).toBe(0)
})
})
// =============================================================================
// 3.2 Z-Order Kontrolleri
// =============================================================================
describe('3.2 Z-Order controls', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
function setupThreeElements() {
const store = useTemplateStore()
store.template = createTestTemplate()
store.addChild('root', createTextElement('a', 'A'))
store.addChild('root', createTextElement('b', 'B'))
store.addChild('root', createTextElement('c', 'C'))
return store
}
it('bringForward moves element one step up', () => {
const store = setupThreeElements()
// Sıra: [a, b, c] → bringForward(a) → [b, a, c]
store.bringForward('a')
expect(store.template.root.children.map(c => c.id)).toEqual(['b', 'a', 'c'])
})
it('sendBackward moves element one step down', () => {
const store = setupThreeElements()
// Sıra: [a, b, c] → sendBackward(c) → [a, c, b]
store.sendBackward('c')
expect(store.template.root.children.map(c => c.id)).toEqual(['a', 'c', 'b'])
})
it('bringToFront moves element to end', () => {
const store = setupThreeElements()
// Sıra: [a, b, c] → bringToFront(a) → [b, c, a]
store.bringToFront('a')
expect(store.template.root.children.map(c => c.id)).toEqual(['b', 'c', 'a'])
})
it('sendToBack moves element to beginning', () => {
const store = setupThreeElements()
// Sıra: [a, b, c] → sendToBack(c) → [c, a, b]
store.sendToBack('c')
expect(store.template.root.children.map(c => c.id)).toEqual(['c', 'a', 'b'])
})
it('bringForward on last element is no-op', () => {
const store = setupThreeElements()
store.bringForward('c')
expect(store.template.root.children.map(c => c.id)).toEqual(['a', 'b', 'c'])
})
it('sendBackward on first element is no-op', () => {
const store = setupThreeElements()
store.sendBackward('a')
expect(store.template.root.children.map(c => c.id)).toEqual(['a', 'b', 'c'])
})
it('bringToFront on last element is no-op', () => {
const store = setupThreeElements()
store.bringToFront('c')
expect(store.template.root.children.map(c => c.id)).toEqual(['a', 'b', 'c'])
})
it('sendToBack on first element is no-op', () => {
const store = setupThreeElements()
store.sendToBack('a')
expect(store.template.root.children.map(c => c.id)).toEqual(['a', 'b', 'c'])
})
})
// =============================================================================
// 3.3 Dinamik Image Binding
// =============================================================================
describe('3.3 Dynamic Image Binding', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('ImageElement supports binding field', () => {
const store = useTemplateStore()
store.template = createTestTemplate()
const img: ImageElement = {
id: 'img_dyn',
type: 'image',
position: { type: 'flow' },
size: { width: sz.fixed(40), height: sz.fixed(40) },
binding: { type: 'scalar', path: 'firma.logo' },
style: { objectFit: 'contain' },
}
store.addChild('root', img as unknown as TemplateElement)
const el = store.getElementById('img_dyn') as ImageElement
expect(el.binding).toBeDefined()
expect(el.binding!.path).toBe('firma.logo')
expect(el.src).toBeUndefined()
})
it('can switch from static to dynamic mode', () => {
const store = useTemplateStore()
store.template = createTestTemplate()
const img: ImageElement = {
id: 'img_switch',
type: 'image',
position: { type: 'flow' },
size: { width: sz.fixed(40), height: sz.fixed(40) },
src: 'data:image/png;base64,abc',
style: {},
}
store.addChild('root', img as unknown as TemplateElement)
// Dinamik moda geç
store.updateElement('img_switch', {
src: undefined,
binding: { type: 'scalar', path: 'firma.logo' },
} as Partial<TemplateElement>)
const el = store.getElementById('img_switch') as ImageElement
expect(el.binding).toBeDefined()
expect(el.binding!.path).toBe('firma.logo')
})
it('can switch from dynamic to static mode', () => {
const store = useTemplateStore()
store.template = createTestTemplate()
const img: ImageElement = {
id: 'img_back',
type: 'image',
position: { type: 'flow' },
size: { width: sz.fixed(40), height: sz.fixed(40) },
binding: { type: 'scalar', path: 'firma.logo' },
style: {},
}
store.addChild('root', img as unknown as TemplateElement)
store.updateElement('img_back', {
binding: undefined,
src: 'data:image/png;base64,xyz',
} as Partial<TemplateElement>)
const el = store.getElementById('img_back') as ImageElement
expect(el.src).toBe('data:image/png;base64,xyz')
})
})

View File

@@ -3,7 +3,8 @@ import { ref, computed } from 'vue'
import type { TemplateElement } from '../core/types'
export const useEditorStore = defineStore('editor', () => {
const selectedElementId = ref<string | null>(null)
/** Seçili eleman ID'leri — çoklu seçim desteği */
const selectedElementIds = ref<Set<string>>(new Set())
const zoom = ref(1)
const panX = ref(0)
const panY = ref(0)
@@ -15,12 +16,36 @@ export const useEditorStore = defineStore('editor', () => {
const zoomPercent = computed(() => Math.round(zoom.value * 100))
/** Geriye uyumluluk: tek seçili eleman ID'si (ilk seçili veya null) */
const selectedElementId = computed<string | null>(() => {
const ids = selectedElementIds.value
if (ids.size === 0) return null
return ids.values().next().value ?? null
})
/** Tek eleman seç (önceki seçimi temizler) */
function selectElement(id: string | null) {
selectedElementId.value = id
selectedElementIds.value = id ? new Set([id]) : new Set()
}
/** Shift+click: seçime ekle/çıkar (toggle) */
function toggleSelection(id: string) {
const next = new Set(selectedElementIds.value)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
selectedElementIds.value = next
}
/** Eleman seçili mi? */
function isSelected(id: string): boolean {
return selectedElementIds.value.has(id)
}
function clearSelection() {
selectedElementId.value = null
selectedElementIds.value = new Set()
}
function setZoom(value: number) {
@@ -56,6 +81,7 @@ export const useEditorStore = defineStore('editor', () => {
}
return {
selectedElementIds,
selectedElementId,
zoom,
panX,
@@ -65,6 +91,8 @@ export const useEditorStore = defineStore('editor', () => {
dropTargetContainerId,
zoomPercent,
selectElement,
toggleSelection,
isSelected,
clearSelection,
setZoom,
setPan,

View File

@@ -139,14 +139,27 @@ export const useTemplateStore = defineStore('template', () => {
}
}
/** Element'i başka bir container'a taşı */
/** Element'i başka bir container'a taşı (tek layoutVersion bump) */
function moveElement(elementId: string, targetParentId: string, index?: number) {
const el = getElementById(elementId)
if (!el) return
// removeElement bump'lar, addChild de bump'lar — ama tek mantıksal operasyon.
// Fazladan 1 bump sorun değil (debounce var), ama istersek optimize edebiliriz.
removeElement(elementId)
addChild(targetParentId, el, index)
// Ağaçtan kaldır (bump'sız)
const parent = getParent(elementId)
if (parent) {
const idx = parent.children.findIndex(c => c.id === elementId)
if (idx !== -1) parent.children.splice(idx, 1)
}
// Hedef container'a ekle (bump'sız)
const target = getElementById(targetParentId)
if (target && isContainer(target)) {
if (index !== undefined) {
target.children.splice(index, 0, el)
} else {
target.children.push(el)
}
}
// Tek bump
bumpLayoutVersion()
}
/** Absolute pozisyon güncelle */
@@ -185,14 +198,62 @@ export const useTemplateStore = defineStore('template', () => {
bumpLayoutVersion()
}
/** Bir adım öne getir */
function bringForward(elementId: string) {
const parent = getParent(elementId)
if (!parent) return
const idx = parent.children.findIndex(c => c.id === elementId)
if (idx < 0 || idx >= parent.children.length - 1) return
reorderChild(parent.id, idx, idx + 1)
}
/** Bir adım arkaya gönder */
function sendBackward(elementId: string) {
const parent = getParent(elementId)
if (!parent) return
const idx = parent.children.findIndex(c => c.id === elementId)
if (idx <= 0) return
reorderChild(parent.id, idx, idx - 1)
}
/** En öne getir */
function bringToFront(elementId: string) {
const parent = getParent(elementId)
if (!parent) return
const idx = parent.children.findIndex(c => c.id === elementId)
if (idx < 0 || idx >= parent.children.length - 1) return
reorderChild(parent.id, idx, parent.children.length - 1)
}
/** En arkaya gönder */
function sendToBack(elementId: string) {
const parent = getParent(elementId)
if (!parent) return
const idx = parent.children.findIndex(c => c.id === elementId)
if (idx <= 0) return
reorderChild(parent.id, idx, 0)
}
/** Şablonu JSON olarak dışa aktar */
function exportTemplate(): string {
return JSON.stringify(template.value, null, 2)
}
/** JSON'dan şablon yükle */
/** JSON'dan şablon yükle (validasyonlu) */
function importTemplate(json: string) {
const parsed = JSON.parse(json) as Template
let parsed: Template
try {
parsed = JSON.parse(json) as Template
} catch (e) {
throw new Error(`Geçersiz JSON: ${e instanceof Error ? e.message : String(e)}`)
}
// Minimum schema doğrulaması
if (!parsed.root || parsed.root.type !== 'container') {
throw new Error('Geçersiz şablon: root alanı eksik veya container değil')
}
if (!parsed.page || typeof parsed.page.width !== 'number' || typeof parsed.page.height !== 'number') {
throw new Error('Geçersiz şablon: page alanı eksik veya geçersiz')
}
template.value = parsed
bumpLayoutVersion()
}
@@ -269,6 +330,10 @@ export const useTemplateStore = defineStore('template', () => {
updateElementSize,
updateElement,
reorderChild,
bringForward,
sendBackward,
bringToFront,
sendToBack,
exportTemplate,
importTemplate,
resetTemplate,

View File

@@ -1,38 +1,126 @@
/// Layout Engine Web Worker
/// Template JSON + Data JSON → Layout WASM → LayoutResult
/// Font loading is dynamic — fetches from backend API based on template needs.
import init, { loadFonts, computeLayout, generateBarcode } from '../core/wasm-layout/dreport_layout.js'
import init, { loadFonts, addFonts, computeLayout, generateBarcode } from '../core/wasm-layout/dreport_layout.js'
import type { LayoutResult } from '../core/layout-types'
let initPromise: Promise<void> | null = null
const FONT_FILES = [
{ path: '/fonts/NotoSans-Regular.ttf', family: 'Noto Sans' },
{ path: '/fonts/NotoSans-Bold.ttf', family: 'Noto Sans' },
{ path: '/fonts/NotoSans-Italic.ttf', family: 'Noto Sans' },
{ path: '/fonts/NotoSans-BoldItalic.ttf', family: 'Noto Sans' },
{ path: '/fonts/NotoSansMono-Regular.ttf', family: 'Noto Sans Mono' },
]
/** Configurable font API base URL. Default: same origin /api/fonts */
let fontApiBase = '/api/fonts'
/** Font catalog from backend API */
interface FontVariantInfo {
weight: number
italic: boolean
}
interface FontFamilyInfo {
family: string
variants: FontVariantInfo[]
}
let fontCatalog: FontFamilyInfo[] = []
/** Track which font families are already loaded into WASM */
const loadedFamilies = new Set<string>()
async function doInit() {
console.log('[layout-worker] WASM başlatılıyor...')
await init({ module_or_path: '/wasm/dreport_layout_bg.wasm' })
console.log('[layout-worker] Fontlar yükleniyor...')
const families: string[] = []
const buffers: Uint8Array[] = []
// Fetch font catalog from backend
try {
const res = await fetch(fontApiBase)
if (res.ok) {
fontCatalog = await res.json()
console.log(`[layout-worker] Font kataloğu yüklendi (${fontCatalog.length} aile)`)
} else {
console.warn(`[layout-worker] Font kataloğu alınamadı (HTTP ${res.status}), static fallback deneniyor`)
await loadStaticFallback()
return
}
} catch {
console.warn('[layout-worker] Font API erişilemedi, static fallback deneniyor')
await loadStaticFallback()
return
}
// Load default fonts (Noto Sans + Noto Sans Mono)
await ensureFamiliesLoaded(['Noto Sans', 'Noto Sans Mono'])
console.log('[layout-worker] Hazır')
}
/** Fallback: load fonts from static /fonts/ directory (backwards compat) */
async function loadStaticFallback() {
const STATIC_FONTS = [
'/fonts/NotoSans-Regular.ttf',
'/fonts/NotoSans-Bold.ttf',
'/fonts/NotoSans-Italic.ttf',
'/fonts/NotoSans-BoldItalic.ttf',
'/fonts/NotoSansMono-Regular.ttf',
]
const buffers: Uint8Array[] = []
await Promise.all(
FONT_FILES.map(async (f) => {
const res = await fetch(new URL(f.path, self.location.origin).href)
const buf = await res.arrayBuffer()
families.push(f.family)
buffers.push(new Uint8Array(buf))
STATIC_FONTS.map(async (path) => {
const url = new URL(path, self.location.origin).href
const res = await fetch(url)
if (res.ok) {
buffers.push(new Uint8Array(await res.arrayBuffer()))
}
})
)
loadFonts(JSON.stringify(families), buffers)
console.log('[layout-worker] Hazır')
if (buffers.length > 0) {
loadFonts(buffers)
loadedFamilies.add('noto sans')
loadedFamilies.add('noto sans mono')
console.log(`[layout-worker] Static fallback: ${buffers.length} font yüklendi`)
}
}
/** Load all variants of given families from the API into WASM */
async function ensureFamiliesLoaded(families: string[]): Promise<void> {
const toLoad = families.filter(f => !loadedFamilies.has(f.toLowerCase()))
if (toLoad.length === 0) return
const buffers: Uint8Array[] = []
for (const family of toLoad) {
const info = fontCatalog.find(f => f.family.toLowerCase() === family.toLowerCase())
if (!info) {
console.warn(`[layout-worker] Font ailesi bulunamadı: ${family}`)
continue
}
const fetches = info.variants.map(async (v) => {
const url = `${fontApiBase}/${encodeURIComponent(info.family)}/${v.weight}/${v.italic}`
const res = await fetch(url)
if (res.ok) {
return new Uint8Array(await res.arrayBuffer())
}
return null
})
const results = await Promise.all(fetches)
for (const buf of results) {
if (buf && buf.byteLength > 0) {
buffers.push(buf)
}
}
loadedFamilies.add(family.toLowerCase())
}
if (buffers.length > 0) {
if (loadedFamilies.size <= toLoad.length) {
// First load — use loadFonts
loadFonts(buffers)
} else {
// Subsequent loads — use addFonts
addFonts(buffers)
}
console.log(`[layout-worker] ${toLoad.join(', ')} yüklendi (${buffers.length} variant)`)
}
}
function ensureInit(): Promise<void> {
@@ -45,14 +133,32 @@ function ensureInit(): Promise<void> {
type WorkerMessage =
| { type: 'compile'; templateJson: string; dataJson: string; id: number }
| { type: 'barcode'; format: string; value: string; width: number; height: number; includeText: boolean; id: number }
| { type: 'configure'; fontApiBase?: string }
self.onmessage = async (e: MessageEvent<WorkerMessage>) => {
const msg = e.data
if (msg.type === 'configure') {
if (msg.fontApiBase) {
fontApiBase = msg.fontApiBase
}
return
}
if (msg.type === 'compile') {
try {
await ensureInit()
// Extract font families from template and ensure they're loaded
try {
const tpl = JSON.parse(msg.templateJson)
if (Array.isArray(tpl.fonts) && tpl.fonts.length > 0) {
await ensureFamiliesLoaded(tpl.fonts)
}
} catch {
// Template parse failure will be caught by computeLayout below
}
const t0 = performance.now()
const resultJson = computeLayout(msg.templateJson, msg.dataJson)
const layout: LayoutResult = JSON.parse(resultJson)