add elements

This commit is contained in:
2026-04-03 01:26:54 +03:00
parent f0a1835fa2
commit 7684a2a871
29 changed files with 3600 additions and 177 deletions

View File

@@ -81,7 +81,7 @@ Cok sayfali belgelerde otomatik sayfa numarasi. Format sablonu destekler (or: "S
## Planlanmis Elemanlar ## Planlanmis Elemanlar
### `rich_text` — Zengin Metin [Henuz implemente edilmedi] ### `rich_text` — Zengin Metin [Yapildi]
Tek bir metin blogu icinde karisik formatlama destekleyen eleman. Kalin, italik, farkli font boyutu, renk gibi stilleri ayni paragraf icinde kullanmayi saglar. Tek bir metin blogu icinde karisik formatlama destekleyen eleman. Kalin, italik, farkli font boyutu, renk gibi stilleri ayni paragraf icinde kullanmayi saglar.
@@ -103,7 +103,7 @@ Tek bir metin blogu icinde karisik formatlama destekleyen eleman. Kalin, italik,
--- ---
### `shape` — Sekil (Dikdortgen / Elips) [Henuz implemente edilmedi] ### `shape` — Sekil (Dikdortgen / Elips) [Yapildi]
Cocuk eleman barindirmayan sade gorsel element. Vurgu kutulari, dekoratif cerceveler, arka plan alanlari icin kullanilir. Container'dan farki: layout'a katilmaz, sadece gorsel amaclidir. Cocuk eleman barindirmayan sade gorsel element. Vurgu kutulari, dekoratif cerceveler, arka plan alanlari icin kullanilir. Container'dan farki: layout'a katilmaz, sadece gorsel amaclidir.
@@ -128,7 +128,7 @@ Cocuk eleman barindirmayan sade gorsel element. Vurgu kutulari, dekoratif cercev
--- ---
### `checkbox` — Onay Kutusu [Henuz implemente edilmedi] ### `checkbox` — Onay Kutusu [Yapildi]
Boolean deger gosteren isaret kutusu. Isaretsiz kare veya isaretli (checkmark) kare olarak render edilir. Veri baglantisi ile dinamik calisan veya statik olarak kullanilabilen basit bir element. Boolean deger gosteren isaret kutusu. Isaretsiz kare veya isaretli (checkmark) kare olarak render edilir. Veri baglantisi ile dinamik calisan veya statik olarak kullanilabilen basit bir element.
@@ -147,7 +147,7 @@ Boolean deger gosteren isaret kutusu. Isaretsiz kare veya isaretli (checkmark) k
--- ---
### `calculated_text` — Hesaplanmis Alan [Henuz implemente edilmedi] ### `calculated_text` — Hesaplanmis Alan [Yapildi]
Basit ifadeler (expression) ile hesaplanmis deger gosteren metin elemani. Aritmetik islemler, string birlestirme ve kosullu metin destekler. Basit ifadeler (expression) ile hesaplanmis deger gosteren metin elemani. Aritmetik islemler, string birlestirme ve kosullu metin destekler.
@@ -168,7 +168,7 @@ Basit ifadeler (expression) ile hesaplanmis deger gosteren metin elemani. Aritme
--- ---
### `current_date` — Tarih / Zaman [Henuz implemente edilmedi] ### `current_date` — Tarih / Zaman [Yapildi]
Belgenin basilma/render anindaki tarihi otomatik gosteren element. `page_number` gibi otomatik deger uretir, veri baglantisi gerektirmez. Belgenin basilma/render anindaki tarihi otomatik gosteren element. `page_number` gibi otomatik deger uretir, veri baglantisi gerektirmez.
@@ -188,7 +188,7 @@ Belgenin basilma/render anindaki tarihi otomatik gosteren element. `page_number`
--- ---
### `page_break` — Sayfa Sonu [Henuz implemente edilmedi] ### `page_break` — Sayfa Sonu [Yapildi]
Kullanicinin belirli bir noktada yeni sayfaya gecmesini saglayan kontrol elemani. Otomatik sayfa sonu (page_break.rs) zaten mevcut, bu element manuel kontrol saglar. Kullanicinin belirli bir noktada yeni sayfaya gecmesini saglayan kontrol elemani. Otomatik sayfa sonu (page_break.rs) zaten mevcut, bu element manuel kontrol saglar.
@@ -236,22 +236,22 @@ Veri gorselIestirme icin basit grafik elemani. Rapor ciktilari icin degerli, fat
Toolbar Toolbar
├── Duzen ├── Duzen
│ ├── Container (mevcut) │ ├── Container (mevcut)
│ └── Page Break (planlanmis) │ └── Page Break (mevcut)
├── Metin ├── Metin
│ ├── Statik Metin (mevcut) │ ├── Statik Metin (mevcut)
│ ├── Rich Text (planlanmis) │ ├── Rich Text (mevcut)
│ └── Hesaplanmis Alan (planlanmis) │ └── Hesaplanmis Alan (mevcut)
├── Veri ├── Veri
│ ├── Tekrarlayan Tablo (mevcut) │ ├── Tekrarlayan Tablo (mevcut)
│ └── Checkbox (planlanmis) │ └── Checkbox (mevcut)
├── Gorsel ├── Gorsel
│ ├── Gorsel (mevcut) │ ├── Gorsel (mevcut)
│ ├── Cizgi (mevcut) │ ├── Cizgi (mevcut)
│ ├── Sekil (planlanmis) │ ├── Sekil (mevcut)
│ └── Barkod / QR (mevcut) │ └── Barkod / QR (mevcut)
├── Otomatik ├── Otomatik
│ ├── Sayfa No (mevcut) │ ├── Sayfa No (mevcut)
│ └── Tarih (planlanmis) │ └── Tarih (mevcut)
└── Rapor └── Rapor
└── Grafik (planlanmis) └── Grafik (planlanmis)
``` ```
@@ -260,12 +260,12 @@ Toolbar
## Oncelik Sirasi ## Oncelik Sirasi
| Oncelik | Element | Gerekce | | Oncelik | Element | Gerekce | Durum |
|---------|---------|---------| |---------|---------|---------|-------|
| 1 | `rich_text` | Karisik formatlama en cok talep edilen ozellik, cosmic-text uyumlu | | 1 | `rich_text` | Karisik formatlama en cok talep edilen ozellik, cosmic-text uyumlu | Yapildi |
| 2 | `shape` | Basit implementasyon, gorsel zenginlik katiyor | | 2 | `shape` | Basit implementasyon, gorsel zenginlik katiyor | Yapildi |
| 3 | `checkbox` | Boolean gosterim, form/irsaliye icin onemli | | 3 | `checkbox` | Boolean gosterim, form/irsaliye icin onemli | Yapildi |
| 4 | `calculated_text` | Hesaplama ihtiyaci fatura/rapor icin kritik | | 4 | `calculated_text` | Hesaplama ihtiyaci fatura/rapor icin kritik | Yapildi |
| 5 | `current_date` | Kucuk ama kullanisli, hizli implemente edilir | | 5 | `current_date` | Kucuk ama kullanisli, hizli implemente edilir | Yapildi |
| 6 | `page_break` | Manuel sayfa kontrolu, rapor senaryolari icin | | 6 | `page_break` | Manuel sayfa kontrolu, rapor senaryolari icin | Yapildi |
| 7 | `chart` | En karmasik, rapor fazinda ele alinabilir | | 7 | `chart` | En karmasik, rapor fazinda ele alinabilir | |

View File

@@ -145,6 +145,19 @@ pub struct BarcodeStyle {
pub include_text: Option<bool>, pub include_text: Option<bool>,
} }
// --- Rich Text ---
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RichTextSpan {
#[serde(default)]
pub text: Option<String>,
#[serde(default)]
pub binding: Option<ScalarBinding>,
#[serde(default)]
pub style: TextStyle,
}
// --- Element tipleri --- // --- Element tipleri ---
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
@@ -172,6 +185,18 @@ pub enum TemplateElement {
PageNumber(PageNumberElement), PageNumber(PageNumberElement),
#[serde(rename = "barcode")] #[serde(rename = "barcode")]
Barcode(BarcodeElement), Barcode(BarcodeElement),
#[serde(rename = "page_break")]
PageBreak(PageBreakElement),
#[serde(rename = "current_date")]
CurrentDate(CurrentDateElement),
#[serde(rename = "shape")]
Shape(ShapeElement),
#[serde(rename = "checkbox")]
Checkbox(CheckboxElement),
#[serde(rename = "calculated_text")]
CalculatedText(CalculatedTextElement),
#[serde(rename = "rich_text")]
RichText(RichTextElement),
} }
impl TemplateElement { impl TemplateElement {
@@ -185,6 +210,12 @@ impl TemplateElement {
Self::Image(e) => &e.id, Self::Image(e) => &e.id,
Self::PageNumber(e) => &e.id, Self::PageNumber(e) => &e.id,
Self::Barcode(e) => &e.id, Self::Barcode(e) => &e.id,
Self::PageBreak(e) => &e.id,
Self::CurrentDate(e) => &e.id,
Self::Shape(e) => &e.id,
Self::Checkbox(e) => &e.id,
Self::CalculatedText(e) => &e.id,
Self::RichText(e) => &e.id,
} }
} }
@@ -198,10 +229,24 @@ impl TemplateElement {
Self::Image(e) => &e.position, Self::Image(e) => &e.position,
Self::PageNumber(e) => &e.position, Self::PageNumber(e) => &e.position,
Self::Barcode(e) => &e.position, Self::Barcode(e) => &e.position,
Self::PageBreak(_) => &PositionMode::Flow,
Self::CurrentDate(e) => &e.position,
Self::Shape(e) => &e.position,
Self::Checkbox(e) => &e.position,
Self::CalculatedText(e) => &e.position,
Self::RichText(e) => &e.position,
} }
} }
pub fn size(&self) -> &SizeConstraint { pub fn size(&self) -> &SizeConstraint {
static DEFAULT_SIZE: SizeConstraint = SizeConstraint {
width: SizeValue::Auto,
height: SizeValue::Auto,
min_width: None,
min_height: None,
max_width: None,
max_height: None,
};
match self { match self {
Self::Container(e) => &e.size, Self::Container(e) => &e.size,
Self::StaticText(e) => &e.size, Self::StaticText(e) => &e.size,
@@ -211,10 +256,27 @@ impl TemplateElement {
Self::Image(e) => &e.size, Self::Image(e) => &e.size,
Self::PageNumber(e) => &e.size, Self::PageNumber(e) => &e.size,
Self::Barcode(e) => &e.size, Self::Barcode(e) => &e.size,
Self::PageBreak(_) => &DEFAULT_SIZE,
Self::CurrentDate(e) => &e.size,
Self::Shape(e) => &e.size,
Self::Checkbox(e) => &e.size,
Self::CalculatedText(e) => &e.size,
Self::RichText(e) => &e.size,
} }
} }
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RichTextElement {
pub id: String,
pub position: PositionMode,
pub size: SizeConstraint,
#[serde(default)]
pub style: TextStyle, // varsayilan stil (span'lar override edebilir)
pub content: Vec<RichTextSpan>,
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ContainerElement { pub struct ContainerElement {
@@ -237,8 +299,12 @@ pub struct ContainerElement {
pub style: ContainerStyle, pub style: ContainerStyle,
#[serde(default)] #[serde(default)]
pub children: Vec<TemplateElement>, pub children: Vec<TemplateElement>,
#[serde(default = "default_auto")]
pub break_inside: String,
} }
fn default_auto() -> String { "auto".to_string() }
fn default_column() -> String { "column".to_string() } fn default_column() -> String { "column".to_string() }
fn default_stretch() -> String { "stretch".to_string() } fn default_stretch() -> String { "stretch".to_string() }
fn default_start() -> String { "start".to_string() } fn default_start() -> String { "start".to_string() }
@@ -315,6 +381,67 @@ pub struct RepeatingTableElement {
pub data_source: ArrayBinding, pub data_source: ArrayBinding,
pub columns: Vec<TableColumn>, pub columns: Vec<TableColumn>,
pub style: TableStyle, pub style: TableStyle,
#[serde(default = "default_true")]
pub repeat_header: Option<bool>,
}
fn default_true() -> Option<bool> { Some(true) }
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PageBreakElement {
pub id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CurrentDateElement {
pub id: String,
pub position: PositionMode,
pub size: SizeConstraint,
pub style: TextStyle,
pub format: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ShapeElement {
pub id: String,
pub position: PositionMode,
pub size: SizeConstraint,
pub shape_type: String, // rectangle, ellipse, rounded_rectangle
pub style: ContainerStyle,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct CheckboxStyle {
pub size: Option<f64>, // mm — kare boyutu
pub check_color: Option<String>, // checkmark rengi
pub border_color: Option<String>, // kare kenar rengi
pub border_width: Option<f64>, // kenar kalınlığı
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CheckboxElement {
pub id: String,
pub position: PositionMode,
pub size: SizeConstraint,
pub checked: Option<bool>, // statik değer
pub binding: Option<ScalarBinding>, // dinamik boolean binding
pub style: CheckboxStyle,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CalculatedTextElement {
pub id: String,
pub position: PositionMode,
pub size: SizeConstraint,
pub style: TextStyle,
pub expression: String,
pub format: Option<String>,
} }
// --- Template --- // --- Template ---
@@ -325,5 +452,9 @@ pub struct Template {
pub name: String, pub name: String,
pub page: PageSettings, pub page: PageSettings,
pub fonts: Vec<String>, pub fonts: Vec<String>,
#[serde(default)]
pub header: Option<ContainerElement>,
#[serde(default)]
pub footer: Option<ContainerElement>,
pub root: ContainerElement, pub root: ContainerElement,
} }

View File

@@ -37,14 +37,24 @@ const scale = computed(() => {
return (containerWidth.value / templateStore.template.page.width) * editorStore.zoom return (containerWidth.value / templateStore.template.page.width) * editorStore.zoom
}) })
// Sayfa boyutu px cinsinden + margin CSS variables // Layout sayfaları
const pageStyle = computed(() => { const layoutPages = computed(() => layout.value?.pages ?? [])
// Sayfa yüksekliği px cinsinden
const pageHeightPx = computed(() => templateStore.template.page.height * scale.value)
// Sayfalar container stili — tüm sayfaları kapsayan dış kutu
const pagesContainerStyle = computed(() => {
const w = templateStore.template.page.width * scale.value const w = templateStore.template.page.width * scale.value
const h = templateStore.template.page.height * scale.value
const m = templateStore.template.root.padding const m = templateStore.template.root.padding
const pageCount = Math.max(1, layoutPages.value.length)
const pageGap = 24
const totalH = pageHeightPx.value * pageCount + pageGap * (pageCount - 1)
return { return {
width: `${w}px`, width: `${w}px`,
height: `${h}px`, height: `${totalH}px`,
position: 'relative' as const,
flexShrink: 0,
'--page-margin-top': `${m.top * scale.value}px`, '--page-margin-top': `${m.top * scale.value}px`,
'--page-margin-right': `${m.right * scale.value}px`, '--page-margin-right': `${m.right * scale.value}px`,
'--page-margin-bottom': `${m.bottom * scale.value}px`, '--page-margin-bottom': `${m.bottom * scale.value}px`,
@@ -204,10 +214,10 @@ function onPointerUp(e: PointerEvent) {
@pointermove="onPointerMove" @pointermove="onPointerMove"
@pointerup="onPointerUp" @pointerup="onPointerUp"
> >
<!-- Sayfa --> <!-- Sayfalar -->
<div ref="pageRef" class="editor-canvas__page" :style="[pageStyle, panTransform ? { transform: panTransform } : {}]"> <div ref="pageRef" class="editor-canvas__pages" :style="[pagesContainerStyle, panTransform ? { transform: panTransform } : {}]">
<LayoutRenderer :layout="layout" :scale="scale" /> <LayoutRenderer :layout="layout" :scale="scale" />
<InteractionOverlay :scale="scale" :layout-map="layoutMap" /> <InteractionOverlay :scale="scale" :layout-map="layoutMap" :page-count="layoutPages.length" :page-height-px="pageHeightPx" />
</div> </div>
</div> </div>
@@ -244,9 +254,7 @@ function onPointerUp(e: PointerEvent) {
padding: 40px; padding: 40px;
} }
.editor-canvas__page { .editor-canvas__pages {
background: white;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
position: relative; position: relative;
flex-shrink: 0; flex-shrink: 0;
} }

View File

@@ -2,15 +2,19 @@
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useTemplateStore } from '../../stores/template' import { useTemplateStore } from '../../stores/template'
import { useEditorStore } from '../../stores/editor' import { useEditorStore } from '../../stores/editor'
import type { ElementLayout } from '../../core/layout-types' import type { LayoutMapEntry } from '../../core/layout-types'
import type { TemplateElement, SizeValue, ContainerElement } from '../../core/types' import type { TemplateElement, SizeValue, ContainerElement } from '../../core/types'
import { isContainer, sz } from '../../core/types' import { isContainer, sz } from '../../core/types'
import ElementToolbar from './ElementToolbar.vue' import ElementToolbar from './ElementToolbar.vue'
import { useSnapGuides } from '../../composables/useSnapGuides' import { useSnapGuides } from '../../composables/useSnapGuides'
const PAGE_GAP_PX = 24
const props = defineProps<{ const props = defineProps<{
scale: number scale: number
layoutMap: Record<string, ElementLayout> layoutMap: Record<string, LayoutMapEntry>
pageCount?: number
pageHeightPx?: number
}>() }>()
const templateStore = useTemplateStore() const templateStore = useTemplateStore()
@@ -28,7 +32,16 @@ const flatElements = computed(() => {
} }
} }
} }
// Header ve footer container'larını ve elemanlarını dahil et
if (templateStore.template.header) {
result.push(templateStore.template.header as unknown as TemplateElement)
walk(templateStore.template.header as unknown as TemplateElement)
}
walk(templateStore.template.root) walk(templateStore.template.root)
if (templateStore.template.footer) {
result.push(templateStore.template.footer as unknown as TemplateElement)
walk(templateStore.template.footer as unknown as TemplateElement)
}
return result return result
}) })
@@ -41,10 +54,25 @@ const allContainers = computed(() => {
for (const child of el.children) walk(child) for (const child of el.children) walk(child)
} }
} }
if (templateStore.template.header) {
result.push(templateStore.template.header)
for (const child of templateStore.template.header.children) walk(child)
}
for (const child of templateStore.template.root.children) walk(child) for (const child of templateStore.template.root.children) walk(child)
if (templateStore.template.footer) {
result.push(templateStore.template.footer)
for (const child of templateStore.template.footer.children) walk(child)
}
return result return result
}) })
/** Sayfa index'ine göre y offset hesapla (sayfalar arası gap dahil) */
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)
}
function getElementStyle(el: TemplateElement) { function getElementStyle(el: TemplateElement) {
const l = props.layoutMap[el.id] const l = props.layoutMap[el.id]
if (!l) return { display: 'none' } if (!l) return { display: 'none' }
@@ -53,12 +81,13 @@ function getElementStyle(el: TemplateElement) {
const h = l.height_mm * s const h = l.height_mm * s
const minH = 8 const minH = 8
const actualH = Math.max(h, minH) const actualH = Math.max(h, minH)
const yOffset = h < minH ? (minH - h) / 2 : 0 const yOff = h < minH ? (minH - h) / 2 : 0
const pYOff = pageYOffset(l.pageIndex)
return { return {
position: 'absolute' as const, position: 'absolute' as const,
left: `${l.x_mm * s}px`, left: `${l.x_mm * s}px`,
top: `${l.y_mm * s - yOffset}px`, top: `${l.y_mm * s - yOff + pYOff}px`,
width: `${l.width_mm * s}px`, width: `${l.width_mm * s}px`,
height: `${actualH}px`, height: `${actualH}px`,
} }
@@ -113,7 +142,7 @@ function findDeepestContainer(mouseX: number, mouseY: number, excludeId?: string
/** Container içinde drop index hesapla */ /** Container içinde drop index hesapla */
function computeDropIndex(container: ContainerElement, mouseX: number, mouseY: number, excludeId?: string) { function computeDropIndex(container: ContainerElement, mouseX: number, mouseY: number, excludeId?: string) {
const s = props.scale const s = props.scale
const flowChildren = container.children.filter(c => c.position.type !== 'absolute' && c.id !== excludeId) const flowChildren = container.children.filter(c => c.type !== 'page_break' && c.position.type !== 'absolute' && c.id !== excludeId)
const isRow = container.direction === 'row' const isRow = container.direction === 'row'
let visualIdx = flowChildren.length let visualIdx = flowChildren.length
@@ -133,7 +162,7 @@ function computeDropIndex(container: ContainerElement, mouseX: number, mouseY: n
// Mantıksal index: excludeId aynı container'daysa offset hesapla // Mantıksal index: excludeId aynı container'daysa offset hesapla
let logicalIdx = visualIdx let logicalIdx = visualIdx
if (excludeId) { if (excludeId) {
const allFlow = container.children.filter(c => c.position.type !== 'absolute') const allFlow = container.children.filter(c => c.type !== 'page_break' && c.position.type !== 'absolute')
const currentIdx = allFlow.findIndex(c => c.id === excludeId) const currentIdx = allFlow.findIndex(c => c.id === excludeId)
if (currentIdx >= 0) { if (currentIdx >= 0) {
// visualIdx, excludeId çıkarılmış listede. Gerçek listedeki pozisyona çevir. // visualIdx, excludeId çıkarılmış listede. Gerçek listedeki pozisyona çevir.
@@ -186,7 +215,7 @@ const dropIndicatorStyle = computed(() => {
// Sürüklenen elemanı çıkar // Sürüklenen elemanı çıkar
const dragId = dragElementId.value const dragId = dragElementId.value
const flowChildren = container.children.filter(c => c.position.type !== 'absolute' && c.id !== dragId) const flowChildren = container.children.filter(c => c.type !== 'page_break' && c.position.type !== 'absolute' && c.id !== dragId)
const cl = props.layoutMap[container.id] const cl = props.layoutMap[container.id]
if (!cl) return { display: 'none' } if (!cl) return { display: 'none' }
@@ -288,6 +317,7 @@ const dragOffset = ref({ x: 0, y: 0 })
const dragGhost = ref({ x: 0, y: 0, width: 0, height: 0 }) const dragGhost = ref({ x: 0, y: 0, width: 0, height: 0 })
function onDragStart(e: PointerEvent, el: TemplateElement) { function onDragStart(e: PointerEvent, el: TemplateElement) {
if (el.type === 'page_break') return
if (el.position.type === 'absolute') { if (el.position.type === 'absolute') {
onAbsoluteDragStart(e, el) onAbsoluteDragStart(e, el)
return return
@@ -617,7 +647,7 @@ const isAnyDragActive = computed(() =>
<div v-if="editorStore.selectedElementId === el.id" class="selection-border" /> <div v-if="editorStore.selectedElementId === el.id" class="selection-border" />
<!-- Resize handles --> <!-- Resize handles -->
<template v-if="editorStore.selectedElementId === el.id && !isResizing"> <template v-if="editorStore.selectedElementId === el.id && !isResizing && el.type !== 'page_break'">
<template v-if="el.type === 'barcode' || el.type === 'image'"> <template v-if="el.type === 'barcode' || el.type === 'image'">
<!-- Barkod/Görsel: sadece yatay resize (aspect ratio korunur) --> <!-- Barkod/Görsel: sadece yatay resize (aspect ratio korunur) -->
<div class="resize-handle resize-handle--e" @pointerdown="(e: PointerEvent) => onResizeStart(e, el.id, 'e')" /> <div class="resize-handle resize-handle--e" @pointerdown="(e: PointerEvent) => onResizeStart(e, el.id, 'e')" />

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, inject, watch, nextTick } from 'vue' import { inject, watch, nextTick } from 'vue'
import type { ElementLayout, LayoutResult } from '../../core/layout-types' import type { ElementLayout, PageLayout, LayoutResult } from '../../core/layout-types'
const props = defineProps<{ const props = defineProps<{
layout: LayoutResult | null layout: LayoutResult | null
@@ -10,10 +10,14 @@ const props = defineProps<{
// WASM barcode üretme fonksiyonu (EditorCanvas'tan provide edilir) // WASM barcode üretme fonksiyonu (EditorCanvas'tan provide edilir)
const generateBarcode = inject<(format: string, value: string, width: number, height: number, includeText: boolean) => Promise<{ width: number; height: number; rgba: ArrayBuffer } | null>>('generateBarcode') const generateBarcode = inject<(format: string, value: string, width: number, height: number, includeText: boolean) => Promise<{ width: number; height: number; rgba: ArrayBuffer } | null>>('generateBarcode')
const pageElements = computed(() => { function pageContainerStyle(page: PageLayout): Record<string, string> {
if (!props.layout || props.layout.pages.length === 0) return [] const s = props.scale
return props.layout.pages[0].elements return {
}) position: 'relative',
width: `${page.width_mm * s}px`,
height: `${page.height_mm * s}px`,
}
}
function elStyle(el: ElementLayout): Record<string, string> { function elStyle(el: ElementLayout): Record<string, string> {
const s = props.scale const s = props.scale
@@ -58,6 +62,25 @@ function containerStyle(el: ElementLayout): Record<string, string> {
return result return result
} }
function shapeStyle(el: ElementLayout): Record<string, string> {
const st = el.style
const result: Record<string, string> = {}
if (st.backgroundColor) result.backgroundColor = st.backgroundColor
if (st.borderColor && st.borderWidth) {
result.border = `${st.borderWidth * props.scale}px ${st.borderStyle ?? 'solid'} ${st.borderColor}`
}
if (st.borderRadius) result.borderRadius = `${st.borderRadius * props.scale}px`
// Ellipse: CSS border-radius 50%
const shapeType = el.content?.type === 'shape' ? el.content.shapeType : 'rectangle'
if (shapeType === 'ellipse') {
result.borderRadius = '50%'
}
return result
}
function lineStyle(el: ElementLayout): Record<string, string> { function lineStyle(el: ElementLayout): Record<string, string> {
const st = el.style const st = el.style
return { return {
@@ -146,17 +169,39 @@ watch(
<template> <template>
<div class="layout-renderer" v-if="layout"> <div class="layout-renderer" v-if="layout">
<template v-for="el in pageElements" :key="el.id"> <div
v-for="(page, pageIdx) in layout.pages"
:key="pageIdx"
class="layout-page"
:style="pageContainerStyle(page)"
>
<template v-for="el in page.elements" :key="el.id">
<!-- Page break: dashed horizontal line -->
<div
v-if="el.element_type === 'page_break'"
class="layout-el layout-el--page-break"
:style="elStyle(el)"
>
<div style="border-top: 1px dashed #9ca3af; width: 100%; height: 0;" />
</div>
<!-- Container --> <!-- Container -->
<div <div
v-if="el.element_type === 'container'" v-else-if="el.element_type === 'container'"
class="layout-el layout-el--container" class="layout-el layout-el--container"
:class="{
'layout-el--header': el.id === 'header' || el.id.startsWith('header_p'),
'layout-el--footer': el.id === 'footer' || el.id.startsWith('footer_p'),
}"
:style="{ ...elStyle(el), ...containerStyle(el) }" :style="{ ...elStyle(el), ...containerStyle(el) }"
/> >
<span v-if="el.id === 'header' || el.id.startsWith('header_p')" class="layout-el__section-label">Üst Bilgi</span>
<span v-else-if="el.id === 'footer' || el.id.startsWith('footer_p')" class="layout-el__section-label">Alt Bilgi</span>
</div>
<!-- Static text / Text / Page number --> <!-- Static text / Text / Page number -->
<div <div
v-else-if="el.element_type === 'static_text' || el.element_type === 'text' || el.element_type === 'page_number'" v-else-if="el.element_type === 'static_text' || el.element_type === 'text' || el.element_type === 'page_number' || el.element_type === 'current_date' || el.element_type === 'calculated_text'"
class="layout-el layout-el--text" class="layout-el layout-el--text"
:style="{ ...elStyle(el), ...textStyle(el) }" :style="{ ...elStyle(el), ...textStyle(el) }"
> >
@@ -209,9 +254,57 @@ watch(
{{ el.content?.type === 'barcode' ? `[${el.content.format}]` : '[barcode]' }} {{ el.content?.type === 'barcode' ? `[${el.content.format}]` : '[barcode]' }}
</div> </div>
</div> </div>
<!-- Checkbox -->
<div
v-else-if="el.element_type === 'checkbox'"
class="layout-el layout-el--checkbox"
:style="elStyle(el)"
>
<svg viewBox="0 0 20 20" :style="{ width: '100%', height: '100%' }">
<rect x="1" y="1" width="18" height="18" fill="none"
:stroke="el.style.borderColor ?? '#333'"
:stroke-width="el.style.borderWidth ? el.style.borderWidth * 3 : 1.5" />
<path v-if="el.content?.type === 'checkbox' && el.content.checked"
d="M4 10 L8 15 L16 5"
fill="none"
:stroke="el.style.color ?? '#000'"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round" />
</svg>
</div>
<!-- Rich Text -->
<div
v-else-if="el.element_type === 'rich_text'"
class="layout-el layout-el--text layout-el--rich-text"
:style="{ ...elStyle(el), ...textStyle(el) }"
>
<template v-if="el.content?.type === 'rich_text'">
<span
v-for="(span, idx) in el.content.spans"
:key="idx"
:style="{
fontSize: span.fontSize ? `${span.fontSize * 0.3528 * scale}px` : undefined,
fontWeight: span.fontWeight || undefined,
fontFamily: span.fontFamily || undefined,
color: span.color || undefined,
}"
>{{ span.text }}</span>
</template> </template>
</div> </div>
<!-- Shape -->
<div
v-else-if="el.element_type === 'shape'"
class="layout-el layout-el--shape"
:style="{ ...elStyle(el), ...shapeStyle(el) }"
/>
</template>
</div>
</div>
<div class="layout-renderer layout-renderer--empty" v-else> <div class="layout-renderer layout-renderer--empty" v-else>
<span>Hesaplanıyor...</span> <span>Hesaplanıyor...</span>
</div> </div>
@@ -219,12 +312,20 @@ watch(
<style scoped> <style scoped>
.layout-renderer { .layout-renderer {
position: absolute;
inset: 0;
pointer-events: none; pointer-events: none;
user-select: none; user-select: none;
} }
.layout-page {
overflow: hidden;
background: white;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
}
.layout-page + .layout-page {
margin-top: 24px;
}
.layout-renderer--empty { .layout-renderer--empty {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -247,6 +348,27 @@ watch(
align-items: center; align-items: center;
} }
.layout-el--page-break {
display: flex;
align-items: center;
}
.layout-el--header,
.layout-el--footer {
border: 1px dashed #94a3b8;
background: rgba(148, 163, 184, 0.05);
}
.layout-el__section-label {
position: absolute;
top: 2px;
left: 4px;
font-size: 9px;
color: #94a3b8;
pointer-events: none;
user-select: none;
}
.layout-el__placeholder { .layout-el__placeholder {
width: 100%; width: 100%;
height: 100%; height: 100%;

View File

@@ -10,6 +10,11 @@ import type {
PageNumberElement, PageNumberElement,
BarcodeElement, BarcodeElement,
RepeatingTableElement, RepeatingTableElement,
CurrentDateElement,
ShapeElement,
CheckboxElement,
CalculatedTextElement,
RichTextElement,
} from '../../core/types' } from '../../core/types'
import PositioningProperties from '../properties/PositioningProperties.vue' import PositioningProperties from '../properties/PositioningProperties.vue'
import SizeProperties from '../properties/SizeProperties.vue' import SizeProperties from '../properties/SizeProperties.vue'
@@ -18,6 +23,11 @@ import LineProperties from '../properties/LineProperties.vue'
import ImageProperties from '../properties/ImageProperties.vue' import ImageProperties from '../properties/ImageProperties.vue'
import PageNumberProperties from '../properties/PageNumberProperties.vue' import PageNumberProperties from '../properties/PageNumberProperties.vue'
import BarcodeProperties from '../properties/BarcodeProperties.vue' import BarcodeProperties from '../properties/BarcodeProperties.vue'
import CurrentDateProperties from '../properties/CurrentDateProperties.vue'
import ShapeProperties from '../properties/ShapeProperties.vue'
import CheckboxProperties from '../properties/CheckboxProperties.vue'
import CalculatedTextProperties from '../properties/CalculatedTextProperties.vue'
import RichTextProperties from '../properties/RichTextProperties.vue'
import ContainerProperties from '../properties/ContainerProperties.vue' import ContainerProperties from '../properties/ContainerProperties.vue'
import RepeatingTableProperties from '../properties/RepeatingTableProperties.vue' import RepeatingTableProperties from '../properties/RepeatingTableProperties.vue'
import '../../styles/properties.css' import '../../styles/properties.css'
@@ -35,7 +45,10 @@ const elementTypeLabel = computed(() => {
const el = selectedElement.value const el = selectedElement.value
if (!el) return '' if (!el) return ''
switch (el.type) { switch (el.type) {
case 'container': return 'Container' case 'container':
if (el.id === 'header') return 'Üst Bilgi'
if (el.id === 'footer') return 'Alt Bilgi'
return 'Container'
case 'static_text': return 'Metin' case 'static_text': return 'Metin'
case 'text': return 'Metin' case 'text': return 'Metin'
case 'line': return 'Cizgi' case 'line': return 'Cizgi'
@@ -43,10 +56,28 @@ const elementTypeLabel = computed(() => {
case 'image': return 'Gorsel' case 'image': return 'Gorsel'
case 'page_number': return 'Sayfa No' case 'page_number': return 'Sayfa No'
case 'barcode': return 'Barkod' case 'barcode': return 'Barkod'
case 'checkbox': return 'Onay Kutusu'
case 'shape': return 'Sekil'
case 'current_date': return 'Tarih'
case 'calculated_text': return 'Hesaplanan Metin'
case 'rich_text': return 'Zengin Metin'
case 'page_break': return 'Sayfa Sonu'
default: return 'Eleman' default: return 'Eleman'
} }
}) })
function toggleHeader(e: Event) {
const checked = (e.target as HTMLInputElement).checked
if (checked) templateStore.enableHeader()
else templateStore.disableHeader()
}
function toggleFooter(e: Event) {
const checked = (e.target as HTMLInputElement).checked
if (checked) templateStore.enableFooter()
else templateStore.disableFooter()
}
function deleteElement() { function deleteElement() {
const id = editorStore.selectedElementId const id = editorStore.selectedElementId
if (!id || id === 'root') return if (!id || id === 'root') return
@@ -70,6 +101,14 @@ function deleteElement() {
</div> </div>
</div> </div>
<!-- Page break: minimal info, just delete -->
<template v-if="selectedElement.type === 'page_break'">
<div class="prop-section">
<button class="prop-delete-btn" @click="deleteElement">Sil</button>
</div>
</template>
<template v-else>
<PositioningProperties :element="selectedElement" /> <PositioningProperties :element="selectedElement" />
<SizeProperties :element="selectedElement" /> <SizeProperties :element="selectedElement" />
@@ -93,6 +132,26 @@ function deleteElement() {
v-if="selectedElement.type === 'barcode'" v-if="selectedElement.type === 'barcode'"
:element="(selectedElement as BarcodeElement)" /> :element="(selectedElement as BarcodeElement)" />
<CurrentDateProperties
v-if="selectedElement.type === 'current_date'"
:element="(selectedElement as CurrentDateElement)" />
<CheckboxProperties
v-if="selectedElement.type === 'checkbox'"
:element="(selectedElement as CheckboxElement)" />
<CalculatedTextProperties
v-if="selectedElement.type === 'calculated_text'"
:element="(selectedElement as CalculatedTextElement)" />
<RichTextProperties
v-if="selectedElement.type === 'rich_text'"
:element="(selectedElement as RichTextElement)" />
<ShapeProperties
v-if="selectedElement.type === 'shape'"
:element="(selectedElement as ShapeElement)" />
<ContainerProperties <ContainerProperties
v-if="isContainer(selectedElement)" v-if="isContainer(selectedElement)"
:element="(selectedElement as ContainerElement)" /> :element="(selectedElement as ContainerElement)" />
@@ -101,11 +160,27 @@ function deleteElement() {
v-if="selectedElement.type === 'repeating_table'" v-if="selectedElement.type === 'repeating_table'"
:element="(selectedElement as RepeatingTableElement)" /> :element="(selectedElement as RepeatingTableElement)" />
<!-- Header/Footer toggles for root element -->
<div v-if="selectedElement.id === 'root'" class="prop-section">
<div class="prop-section__title">Sayfa Ust/Alt Bilgi</div>
<div class="prop-row">
<label class="prop-label">Ust Bilgi (Header)</label>
<input type="checkbox" :checked="!!templateStore.template.header"
@change="toggleHeader" />
</div>
<div class="prop-row">
<label class="prop-label">Alt Bilgi (Footer)</label>
<input type="checkbox" :checked="!!templateStore.template.footer"
@change="toggleFooter" />
</div>
</div>
<!-- Delete --> <!-- Delete -->
<div v-if="selectedElement.id !== 'root'" class="prop-section"> <div v-if="selectedElement.id !== 'root'" class="prop-section">
<button class="prop-delete-btn" @click="deleteElement">Sil</button> <button class="prop-delete-btn" @click="deleteElement">Sil</button>
</div> </div>
</template> </template>
</template>
</div> </div>
</template> </template>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useEditorStore } from '../../stores/editor' import { useEditorStore } from '../../stores/editor'
import { useSchemaStore } from '../../stores/schema' import { useSchemaStore } from '../../stores/schema'
import type { TemplateElement, RepeatingTableElement, TableColumn, ImageElement, PageNumberElement, BarcodeElement } from '../../core/types' import type { TemplateElement, RepeatingTableElement, TableColumn, ImageElement, PageNumberElement, BarcodeElement, PageBreakElement, CurrentDateElement, ShapeElement, CheckboxElement, CalculatedTextElement, RichTextElement } from '../../core/types'
import { sz } from '../../core/types' import { sz } from '../../core/types'
import { schemaFormatToFormatType, defaultAlignForSchema } from '../../core/schema-parser' import { schemaFormatToFormatType, defaultAlignForSchema } from '../../core/schema-parser'
@@ -32,6 +32,21 @@ const tools: ToolItem[] = [
content: 'Yeni metin', content: 'Yeni metin',
}), }),
}, },
{
label: 'Zengin Metin',
icon: 'R',
create: (): RichTextElement => ({
id: nextId('rt'),
type: 'rich_text',
position: { type: 'flow' },
size: { width: sz.auto(), height: sz.auto() },
style: { fontSize: 11, color: '#000000' },
content: [
{ text: 'Zengin ', style: {} },
{ text: 'metin', style: { fontWeight: 'bold' } },
],
}),
},
{ {
label: 'Container', label: 'Container',
icon: '▢', icon: '▢',
@@ -96,6 +111,7 @@ const tools: ToolItem[] = [
fontSize: 10, fontSize: 10,
headerFontSize: 10, headerFontSize: 10,
}, },
repeatHeader: true,
} }
}, },
}, },
@@ -135,6 +151,62 @@ const tools: ToolItem[] = [
style: {}, style: {},
}), }),
}, },
{
label: 'Onay Kutusu',
icon: '☑',
create: (): CheckboxElement => ({
id: nextId('cb'),
type: 'checkbox',
position: { type: 'flow' },
size: { width: sz.auto(), height: sz.auto() },
checked: false,
style: { size: 4, checkColor: '#000000', borderColor: '#333333', borderWidth: 0.3 },
}),
},
{
label: 'Sekil',
icon: '⬜',
create: (): ShapeElement => ({
id: nextId('shp'),
type: 'shape',
position: { type: 'flow' },
size: { width: sz.fr(1), height: sz.fixed(20) },
shapeType: 'rectangle',
style: { backgroundColor: '#f0f0f0', borderColor: '#333333', borderWidth: 0.5 },
}),
},
{
label: 'Hesaplanan',
icon: 'ƒ',
create: (): CalculatedTextElement => ({
id: nextId('calc'),
type: 'calculated_text',
position: { type: 'flow' },
size: { width: sz.auto(), height: sz.auto() },
expression: '0',
style: { fontSize: 11, color: '#000000' },
}),
},
{
label: 'Tarih',
icon: '📅',
create: (): CurrentDateElement => ({
id: nextId('dt'),
type: 'current_date',
position: { type: 'flow' },
size: { width: sz.auto(), height: sz.auto() },
style: { fontSize: 10, color: '#666666' },
format: 'DD.MM.YYYY',
}),
},
{
label: 'Sayfa Sonu',
icon: '⏎',
create: (): PageBreakElement => ({
id: nextId('pb'),
type: 'page_break',
}),
},
] ]
function onDragStart(e: DragEvent, tool: ToolItem) { function onDragStart(e: DragEvent, tool: ToolItem) {

View File

@@ -0,0 +1,75 @@
<script setup lang="ts">
import { useTemplateStore } from '../../stores/template'
import { useEditorStore } from '../../stores/editor'
import type { CalculatedTextElement, TextStyle, TemplateElement } from '../../core/types'
import '../../styles/properties.css'
const props = defineProps<{ element: CalculatedTextElement }>()
const templateStore = useTemplateStore()
const editorStore = useEditorStore()
function update(updates: Partial<TemplateElement>) {
const id = editorStore.selectedElementId
if (!id) return
templateStore.updateElement(id, updates)
}
function updateStyle(key: string, value: unknown) {
update({ style: { ...props.element.style, [key]: value } } as Partial<TemplateElement>)
}
</script>
<template>
<div class="prop-section">
<div class="prop-section__title">Hesaplanan Metin</div>
<div class="prop-row">
<label class="prop-label">Ifade</label>
<input class="prop-input" type="text"
:value="element.expression"
@change="(e) => update({ expression: (e.target as HTMLInputElement).value } as any)"
placeholder="toplamlar.kdv + toplamlar.araToplam" />
</div>
<div class="prop-row">
<label class="prop-label">Format</label>
<select class="prop-input prop-select"
:value="element.format ?? ''"
@change="(e) => update({ format: (e.target as HTMLSelectElement).value || undefined } as any)">
<option value="">Yok</option>
<option value="currency">Para Birimi</option>
<option value="number">Sayi</option>
<option value="percentage">Yuzde</option>
</select>
</div>
<div class="prop-row">
<label class="prop-label">Boyut (pt)</label>
<input class="prop-input" type="number" step="1" min="1"
:value="(element.style as TextStyle).fontSize ?? 11"
@input="(e) => updateStyle('fontSize', parseFloat((e.target as HTMLInputElement).value) || 11)" />
</div>
<div class="prop-row">
<label class="prop-label">Renk</label>
<input class="prop-input prop-color" type="color"
:value="(element.style as TextStyle).color ?? '#000000'"
@input="(e) => updateStyle('color', (e.target as HTMLInputElement).value)" />
</div>
<div class="prop-row">
<label class="prop-label">Kalinlik</label>
<select class="prop-input prop-select"
:value="(element.style as TextStyle).fontWeight ?? 'normal'"
@change="(e) => updateStyle('fontWeight', (e.target as HTMLSelectElement).value)">
<option value="normal">Normal</option>
<option value="bold">Kalin</option>
</select>
</div>
<div class="prop-row">
<label class="prop-label">Hizalama</label>
<select class="prop-input prop-select"
:value="(element.style as TextStyle).align ?? 'left'"
@change="(e) => updateStyle('align', (e.target as HTMLSelectElement).value)">
<option value="left">Sol</option>
<option value="center">Orta</option>
<option value="right">Sag</option>
</select>
</div>
</div>
</template>

View File

@@ -0,0 +1,50 @@
<script setup lang="ts">
import { useTemplateStore } from '../../stores/template'
import { useEditorStore } from '../../stores/editor'
import type { CheckboxElement, TemplateElement } from '../../core/types'
import '../../styles/properties.css'
const props = defineProps<{ element: CheckboxElement }>()
const templateStore = useTemplateStore()
const editorStore = useEditorStore()
function update(updates: Partial<TemplateElement>) {
const id = editorStore.selectedElementId
if (!id) return
templateStore.updateElement(id, updates)
}
function updateStyle(key: string, value: unknown) {
update({ style: { ...props.element.style, [key]: value } } as Partial<TemplateElement>)
}
</script>
<template>
<div class="prop-section">
<div class="prop-section__title">Onay Kutusu</div>
<div v-if="!element.binding" class="prop-row">
<label class="prop-label">Isaretli</label>
<input type="checkbox"
:checked="element.checked ?? false"
@change="(e) => update({ checked: (e.target as HTMLInputElement).checked } as any)" />
</div>
<div class="prop-row">
<label class="prop-label">Boyut (mm)</label>
<input class="prop-input" type="number" step="0.5" min="1"
:value="element.style.size ?? 4"
@input="(e) => updateStyle('size', parseFloat((e.target as HTMLInputElement).value) || 4)" />
</div>
<div class="prop-row">
<label class="prop-label">Isaret Rengi</label>
<input class="prop-input prop-color" type="color"
:value="element.style.checkColor ?? '#000000'"
@input="(e) => updateStyle('checkColor', (e.target as HTMLInputElement).value)" />
</div>
<div class="prop-row">
<label class="prop-label">Kenar Rengi</label>
<input class="prop-input prop-color" type="color"
:value="element.style.borderColor ?? '#333333'"
@input="(e) => updateStyle('borderColor', (e.target as HTMLInputElement).value)" />
</div>
</div>
</template>

View File

@@ -70,6 +70,16 @@ function updateStyle(key: string, value: unknown) {
@update="(side, value) => update({ padding: { ...element.padding, [side]: value } } as any)" @update="(side, value) => update({ padding: { ...element.padding, [side]: value } } as any)"
/> />
<div class="prop-row">
<label class="prop-label">Sayfa Bolme</label>
<select class="prop-input prop-select"
:value="element.breakInside ?? 'auto'"
@change="(e) => update({ breakInside: (e.target as HTMLSelectElement).value } as any)">
<option value="auto">Izin Ver</option>
<option value="avoid">Bolme</option>
</select>
</div>
<div class="prop-section__subtitle">Stil</div> <div class="prop-section__subtitle">Stil</div>
<div class="prop-row"> <div class="prop-row">
<label class="prop-label">Arka plan</label> <label class="prop-label">Arka plan</label>

View File

@@ -0,0 +1,59 @@
<script setup lang="ts">
import { useTemplateStore } from '../../stores/template'
import { useEditorStore } from '../../stores/editor'
import type { CurrentDateElement, TextStyle, TemplateElement } from '../../core/types'
import '../../styles/properties.css'
const props = defineProps<{ element: CurrentDateElement }>()
const templateStore = useTemplateStore()
const editorStore = useEditorStore()
function update(updates: Partial<TemplateElement>) {
const id = editorStore.selectedElementId
if (!id) return
templateStore.updateElement(id, updates)
}
function updateStyle(key: string, value: unknown) {
update({ style: { ...props.element.style, [key]: value } } as Partial<TemplateElement>)
}
</script>
<template>
<div class="prop-section">
<div class="prop-section__title">Tarih</div>
<div class="prop-row">
<label class="prop-label">Format</label>
<select class="prop-input prop-select"
:value="element.format ?? 'DD.MM.YYYY'"
@change="(e) => update({ format: (e.target as HTMLSelectElement).value } as any)">
<option value="DD.MM.YYYY">30.03.2026</option>
<option value="DD/MM/YYYY">30/03/2026</option>
<option value="YYYY-MM-DD">2026-03-30</option>
<option value="DD.MM.YYYY HH:mm">30.03.2026 14:30</option>
</select>
</div>
<div class="prop-row">
<label class="prop-label">Boyut (pt)</label>
<input class="prop-input" type="number" step="1" min="1"
:value="(element.style as TextStyle).fontSize ?? 10"
@input="(e) => updateStyle('fontSize', parseFloat((e.target as HTMLInputElement).value) || 10)" />
</div>
<div class="prop-row">
<label class="prop-label">Renk</label>
<input class="prop-input prop-color" type="color"
:value="(element.style as TextStyle).color ?? '#666666'"
@input="(e) => updateStyle('color', (e.target as HTMLInputElement).value)" />
</div>
<div class="prop-row">
<label class="prop-label">Hizalama</label>
<select class="prop-input prop-select"
:value="(element.style as TextStyle).align ?? 'left'"
@change="(e) => updateStyle('align', (e.target as HTMLSelectElement).value)">
<option value="left">Sol</option>
<option value="center">Orta</option>
<option value="right">Sag</option>
</select>
</div>
</div>
</template>

View File

@@ -193,6 +193,17 @@ const tableItemFields = computed(() => {
</div> </div>
</div> </div>
<!-- Sayfa bölme ayarları -->
<div class="prop-section">
<div class="prop-section__title">Sayfa Bolme</div>
<div class="prop-row">
<label class="prop-label">Header tekrarla</label>
<input type="checkbox"
:checked="element.repeatHeader !== false"
@change="(e) => update({ repeatHeader: (e.target as HTMLInputElement).checked } as any)" />
</div>
</div>
<!-- Table style --> <!-- Table style -->
<div class="prop-section"> <div class="prop-section">
<div class="prop-section__title">Tablo Stili</div> <div class="prop-section__title">Tablo Stili</div>

View File

@@ -0,0 +1,182 @@
<script setup lang="ts">
import { useTemplateStore } from '../../stores/template'
import { useEditorStore } from '../../stores/editor'
import type { RichTextElement, RichTextSpan, TextStyle } from '../../core/types'
import '../../styles/properties.css'
const props = defineProps<{ element: RichTextElement }>()
const templateStore = useTemplateStore()
const editorStore = useEditorStore()
function update(updates: Partial<RichTextElement>) {
const id = editorStore.selectedElementId
if (!id) return
templateStore.updateElement(id, updates as any)
}
function updateStyle(key: string, value: unknown) {
update({ style: { ...props.element.style, [key]: value } } as Partial<RichTextElement>)
}
function updateSpan(index: number, updates: Partial<RichTextSpan>) {
const content = [...props.element.content]
content[index] = { ...content[index], ...updates }
update({ content })
}
function updateSpanStyle(index: number, key: string, value: unknown) {
const span = props.element.content[index]
updateSpan(index, { style: { ...span.style, [key]: value } })
}
function addSpan() {
const content = [...props.element.content, { text: 'yeni', style: {} }]
update({ content })
}
function removeSpan(index: number) {
if (props.element.content.length <= 1) return
const content = props.element.content.filter((_, i) => i !== index)
update({ content })
}
</script>
<template>
<div class="prop-section">
<div class="prop-section__title">Varsayilan Stil</div>
<div class="prop-row">
<label class="prop-label">Boyut (pt)</label>
<input class="prop-input" type="number" step="1" min="1"
:value="element.style.fontSize ?? 11"
@input="(e) => updateStyle('fontSize', parseFloat((e.target as HTMLInputElement).value) || 11)" />
</div>
<div class="prop-row">
<label class="prop-label">Renk</label>
<input class="prop-input prop-color" type="color"
:value="element.style.color ?? '#000000'"
@input="(e) => updateStyle('color', (e.target as HTMLInputElement).value)" />
</div>
<div class="prop-row">
<label class="prop-label">Hizalama</label>
<select class="prop-input prop-select"
:value="element.style.align ?? 'left'"
@change="(e) => updateStyle('align', (e.target as HTMLSelectElement).value)">
<option value="left">Sol</option>
<option value="center">Orta</option>
<option value="right">Sag</option>
</select>
</div>
</div>
<div class="prop-section">
<div class="prop-section__title">
Span'lar
<button class="prop-add-btn" @click="addSpan" title="Span ekle">+</button>
</div>
<div v-for="(span, idx) in element.content" :key="idx" class="prop-span-card">
<div class="prop-span-card__header">
<span class="prop-span-card__label">Span {{ idx + 1 }}</span>
<button
v-if="element.content.length > 1"
class="prop-span-card__remove"
@click="removeSpan(idx)"
title="Sil"
>&times;</button>
</div>
<div class="prop-row">
<label class="prop-label">Metin</label>
<input class="prop-input" type="text"
:value="span.text ?? ''"
@input="(e) => updateSpan(idx, { text: (e.target as HTMLInputElement).value })" />
</div>
<div class="prop-row">
<label class="prop-label">Boyut</label>
<input class="prop-input" type="number" step="1" min="1"
:value="(span.style as TextStyle).fontSize ?? ''"
placeholder="varsayilan"
@input="(e) => {
const v = (e.target as HTMLInputElement).value
updateSpanStyle(idx, 'fontSize', v ? parseFloat(v) : undefined)
}" />
</div>
<div class="prop-row">
<label class="prop-label">Kalinlik</label>
<select class="prop-input prop-select"
:value="(span.style as TextStyle).fontWeight ?? ''"
@change="(e) => {
const v = (e.target as HTMLSelectElement).value
updateSpanStyle(idx, 'fontWeight', v || undefined)
}">
<option value="">Varsayilan</option>
<option value="normal">Normal</option>
<option value="bold">Kalin</option>
</select>
</div>
<div class="prop-row">
<label class="prop-label">Renk</label>
<input class="prop-input prop-color" type="color"
:value="(span.style as TextStyle).color ?? element.style.color ?? '#000000'"
@input="(e) => updateSpanStyle(idx, 'color', (e.target as HTMLInputElement).value)" />
</div>
</div>
</div>
</template>
<style scoped>
.prop-add-btn {
float: right;
background: #3b82f6;
color: white;
border: none;
border-radius: 4px;
width: 22px;
height: 22px;
font-size: 14px;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.prop-add-btn:hover {
background: #2563eb;
}
.prop-span-card {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 8px;
margin-bottom: 8px;
}
.prop-span-card__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.prop-span-card__label {
font-size: 11px;
font-weight: 600;
color: #64748b;
}
.prop-span-card__remove {
background: none;
border: none;
color: #ef4444;
font-size: 16px;
cursor: pointer;
padding: 0 4px;
line-height: 1;
}
.prop-span-card__remove:hover {
color: #dc2626;
}
</style>

View File

@@ -0,0 +1,60 @@
<script setup lang="ts">
import { useTemplateStore } from '../../stores/template'
import { useEditorStore } from '../../stores/editor'
import type { ShapeElement, TemplateElement } from '../../core/types'
import '../../styles/properties.css'
const props = defineProps<{ element: ShapeElement }>()
const templateStore = useTemplateStore()
const editorStore = useEditorStore()
function update(updates: Partial<TemplateElement>) {
const id = editorStore.selectedElementId
if (!id) return
templateStore.updateElement(id, updates)
}
function updateStyle(key: string, value: unknown) {
update({ style: { ...props.element.style, [key]: value } } as Partial<TemplateElement>)
}
</script>
<template>
<div class="prop-section">
<div class="prop-section__title">Sekil</div>
<div class="prop-row">
<label class="prop-label">Tip</label>
<select class="prop-input prop-select"
:value="element.shapeType"
@change="(e) => update({ shapeType: (e.target as HTMLSelectElement).value } as any)">
<option value="rectangle">Dikdortgen</option>
<option value="rounded_rectangle">Yuvarlak Dikdortgen</option>
<option value="ellipse">Elips</option>
</select>
</div>
<div class="prop-row">
<label class="prop-label">Arka Plan</label>
<input class="prop-input prop-color" type="color"
:value="element.style.backgroundColor ?? '#f0f0f0'"
@input="(e) => updateStyle('backgroundColor', (e.target as HTMLInputElement).value)" />
</div>
<div class="prop-row">
<label class="prop-label">Kenar Rengi</label>
<input class="prop-input prop-color" type="color"
:value="element.style.borderColor ?? '#333333'"
@input="(e) => updateStyle('borderColor', (e.target as HTMLInputElement).value)" />
</div>
<div class="prop-row">
<label class="prop-label">Kenar Kalinligi</label>
<input class="prop-input" type="number" step="0.25" min="0"
:value="element.style.borderWidth ?? 0.5"
@input="(e) => updateStyle('borderWidth', parseFloat((e.target as HTMLInputElement).value) || 0)" />
</div>
<div v-if="element.shapeType === 'rounded_rectangle'" class="prop-row">
<label class="prop-label">Kose Yuvarlakligi</label>
<input class="prop-input" type="number" step="0.5" min="0"
:value="element.style.borderRadius ?? 2"
@input="(e) => updateStyle('borderRadius', parseFloat((e.target as HTMLInputElement).value) || 0)" />
</div>
</div>
</template>

View File

@@ -1,8 +1,8 @@
import { ref, watch, type Ref } from 'vue' import { ref, watch, type Ref } from 'vue'
import type { Template } from '../core/types' import type { Template } from '../core/types'
import type { LayoutResult, ElementLayout } from '../core/layout-types' import type { LayoutResult, LayoutMapEntry } from '../core/layout-types'
export type { ElementLayout } export type { LayoutMapEntry }
export function useLayoutEngine( export function useLayoutEngine(
template: Ref<Template>, template: Ref<Template>,
@@ -14,7 +14,7 @@ export function useLayoutEngine(
const computing = ref(false) const computing = ref(false)
// Uyumluluk: InteractionOverlay'ın beklediği flat layout map (id → ElementLayout) // Uyumluluk: InteractionOverlay'ın beklediği flat layout map (id → ElementLayout)
const layoutMap = ref<Record<string, ElementLayout>>({}) const layoutMap = ref<Record<string, LayoutMapEntry>>({})
let worker: Worker | null = null let worker: Worker | null = null
let requestId = 0 let requestId = 0
@@ -40,11 +40,13 @@ export function useLayoutEngine(
layout.value = msg.layout layout.value = msg.layout
error.value = null error.value = null
// Flat map oluştur: id → ElementLayout // Flat map oluştur: id → LayoutMapEntry (pageIndex dahil)
const map: Record<string, ElementLayout> = {} const map: Record<string, LayoutMapEntry> = {}
for (const page of msg.layout.pages) { for (const page of msg.layout.pages) {
for (const el of page.elements) { for (const el of page.elements) {
map[el.id] = el if (!map[el.id]) {
map[el.id] = { ...el, pageIndex: page.page_index }
}
} }
} }
layoutMap.value = map layoutMap.value = map

View File

@@ -23,12 +23,27 @@ export interface ElementLayout {
children: string[] children: string[]
} }
export interface LayoutMapEntry extends ElementLayout {
pageIndex: number
}
export interface ResolvedRichSpan {
text: string
fontSize?: number
fontWeight?: string
fontFamily?: string
color?: string
}
export type ResolvedContent = export type ResolvedContent =
| { type: 'text'; value: string } | { type: 'text'; value: string }
| { type: 'image'; src: string } | { type: 'image'; src: string }
| { type: 'line' } | { type: 'line' }
| { type: 'barcode'; format: string; value: string } | { type: 'barcode'; format: string; value: string }
| { type: 'page_number'; current: number; total: number } | { type: 'page_number'; current: number; total: number }
| { type: 'shape'; shapeType: string }
| { type: 'checkbox'; checked: boolean }
| { type: 'rich_text'; spans: ResolvedRichSpan[] }
| { type: 'table'; headers: TableHeaderCell[]; rows: TableCell[][]; column_widths_mm: number[] } | { type: 'table'; headers: TableHeaderCell[]; rows: TableCell[][]; column_widths_mm: number[] }
export interface TableHeaderCell { export interface TableHeaderCell {

View File

@@ -163,6 +163,56 @@ export interface BarcodeElement extends BaseElement {
style: BarcodeStyle style: BarcodeStyle
} }
export interface CurrentDateElement extends BaseElement {
type: 'current_date'
style: TextStyle
format?: string // ör: "DD.MM.YYYY", "DD MMMM YYYY", "DD.MM.YYYY HH:mm"
}
export interface ShapeElement extends BaseElement {
type: 'shape'
shapeType: 'rectangle' | 'ellipse' | 'rounded_rectangle'
style: ContainerStyle
}
export interface CheckboxStyle {
size?: number // mm — kare boyutu
checkColor?: string // checkmark rengi
borderColor?: string
borderWidth?: number
}
export interface CheckboxElement extends BaseElement {
type: 'checkbox'
checked?: boolean
binding?: ScalarBinding
style: CheckboxStyle
}
export interface CalculatedTextElement extends BaseElement {
type: 'calculated_text'
expression: string
format?: FormatType
style: TextStyle
}
export interface RichTextSpan {
text?: string
binding?: ScalarBinding
style: TextStyle
}
export interface RichTextElement extends BaseElement {
type: 'rich_text'
content: RichTextSpan[]
style: TextStyle // varsayılan stil
}
export interface PageBreakElement {
type: 'page_break'
id: string
}
export interface ContainerElement extends BaseElement { export interface ContainerElement extends BaseElement {
type: 'container' type: 'container'
direction: 'row' | 'column' direction: 'row' | 'column'
@@ -170,6 +220,7 @@ export interface ContainerElement extends BaseElement {
padding: Padding padding: Padding
align: 'start' | 'center' | 'end' | 'stretch' align: 'start' | 'center' | 'end' | 'stretch'
justify: 'start' | 'center' | 'end' | 'space-between' justify: 'start' | 'center' | 'end' | 'space-between'
breakInside?: 'auto' | 'avoid'
style: ContainerStyle style: ContainerStyle
children: TemplateElement[] children: TemplateElement[]
} }
@@ -179,9 +230,10 @@ export interface RepeatingTableElement extends BaseElement {
dataSource: ArrayBinding dataSource: ArrayBinding
columns: TableColumn[] columns: TableColumn[]
style: TableStyle style: TableStyle
repeatHeader?: boolean
} }
export type LeafElement = StaticTextElement | TextElement | LineElement | RepeatingTableElement | ImageElement | PageNumberElement | BarcodeElement export type LeafElement = StaticTextElement | TextElement | LineElement | RepeatingTableElement | ImageElement | PageNumberElement | BarcodeElement | PageBreakElement | CurrentDateElement | ShapeElement | CheckboxElement | CalculatedTextElement | RichTextElement
export type TemplateElement = LeafElement | ContainerElement export type TemplateElement = LeafElement | ContainerElement
// --- Template --- // --- Template ---
@@ -193,6 +245,8 @@ export interface Template {
page: PageSettings page: PageSettings
fonts: string[] fonts: string[]
root: ContainerElement // kök container = sayfa root: ContainerElement // kök container = sayfa
header?: ContainerElement
footer?: ContainerElement
} }
// --- Editor state --- // --- Editor state ---

View File

@@ -86,11 +86,34 @@ export const useTemplateStore = defineStore('template', () => {
// --- Element CRUD --- // --- Element CRUD ---
function getElementById(id: string): TemplateElement | undefined { function getElementById(id: string): TemplateElement | undefined {
return findElementById(template.value.root, id) const inRoot = findElementById(template.value.root, id)
if (inRoot) return inRoot
if (template.value.header) {
const inHeader = findElementById(template.value.header, id)
if (inHeader) return inHeader
}
if (template.value.footer) {
const inFooter = findElementById(template.value.footer, id)
if (inFooter) return inFooter
}
return undefined
} }
function getParent(id: string): ContainerElement | undefined { function getParent(id: string): ContainerElement | undefined {
return findParent(template.value.root, id) const inRoot = findParent(template.value.root, id)
if (inRoot) return inRoot
if (template.value.header) {
// Check if the header itself is the target element's parent
if (template.value.header.id === id) return undefined
const inHeader = findParent(template.value.header, id)
if (inHeader) return inHeader
}
if (template.value.footer) {
if (template.value.footer.id === id) return undefined
const inFooter = findParent(template.value.footer, id)
if (inFooter) return inFooter
}
return undefined
} }
/** Bir container'a çocuk ekle */ /** Bir container'a çocuk ekle */
@@ -180,6 +203,58 @@ export const useTemplateStore = defineStore('template', () => {
bumpLayoutVersion() bumpLayoutVersion()
} }
/** Header container'ı etkinleştir */
function enableHeader() {
if (template.value.header) return
template.value.header = {
id: 'header',
type: 'container',
position: { type: 'flow' },
size: { width: sz.auto(), height: sz.fixed(10), minHeight: 10 },
direction: 'row',
gap: 0,
padding: { top: 2, right: 5, bottom: 2, left: 5 },
align: 'stretch',
justify: 'start',
style: {},
children: [],
}
bumpLayoutVersion()
}
/** Header container'ı kaldır */
function disableHeader() {
if (!template.value.header) return
template.value.header = undefined
bumpLayoutVersion()
}
/** Footer container'ı etkinleştir */
function enableFooter() {
if (template.value.footer) return
template.value.footer = {
id: 'footer',
type: 'container',
position: { type: 'flow' },
size: { width: sz.auto(), height: sz.fixed(10), minHeight: 10 },
direction: 'row',
gap: 0,
padding: { top: 2, right: 5, bottom: 2, left: 5 },
align: 'stretch',
justify: 'start',
style: {},
children: [],
}
bumpLayoutVersion()
}
/** Footer container'ı kaldır */
function disableFooter() {
if (!template.value.footer) return
template.value.footer = undefined
bumpLayoutVersion()
}
return { return {
template, template,
mockData, mockData,
@@ -202,5 +277,9 @@ export const useTemplateStore = defineStore('template', () => {
redo, redo,
canUndo, canUndo,
canRedo, canRedo,
enableHeader,
disableHeader,
enableFooter,
disableFooter,
} }
}) })

View File

@@ -2,6 +2,72 @@ use dreport_core::models::*;
use serde_json::Value; use serde_json::Value;
use std::collections::HashMap; use std::collections::HashMap;
/// Şu anki tarihi verilen format string'ine göre formatla.
/// Desteklenen tokenlar: YYYY, MM, DD, HH, mm, ss
/// WASM'da js_sys::Date, native'de SystemTime kullanır.
fn format_current_date(fmt: &str) -> String {
let (year, month, day, hour, minute, second) = current_datetime_parts();
fmt.replace("YYYY", &format!("{:04}", year))
.replace("MM", &format!("{:02}", month))
.replace("DD", &format!("{:02}", day))
.replace("HH", &format!("{:02}", hour))
.replace("mm", &format!("{:02}", minute))
.replace("ss", &format!("{:02}", second))
}
#[cfg(target_arch = "wasm32")]
fn current_datetime_parts() -> (i32, u32, u32, u32, u32, u32) {
let d = js_sys::Date::new_0();
(
d.get_full_year() as i32,
d.get_month() as u32 + 1, // JS months are 0-based
d.get_date() as u32,
d.get_hours() as u32,
d.get_minutes() as u32,
d.get_seconds() as u32,
)
}
#[cfg(not(target_arch = "wasm32"))]
fn current_datetime_parts() -> (i32, u32, u32, u32, u32, u32) {
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
// Simple UTC date calculation (no timezone dependency)
let days = (secs / 86400) as i64;
let time_of_day = secs % 86400;
let hour = (time_of_day / 3600) as u32;
let minute = ((time_of_day % 3600) / 60) as u32;
let second = (time_of_day % 60) as u32;
// Days since 1970-01-01 → year/month/day (civil calendar)
// Algorithm from Howard Hinnant's chrono-compatible date library
let z = days + 719468;
let era = (if z >= 0 { z } else { z - 146096 }) / 146097;
let doe = (z - era * 146097) as u32;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
(y as i32, m, d, hour, minute, second)
}
/// Çözümlenmiş rich text span'ı
#[derive(Debug, Clone)]
pub struct ResolvedRichSpan {
pub text: String,
pub font_size: Option<f64>,
pub font_weight: Option<String>,
pub font_family: Option<String>,
pub color: Option<String>,
}
/// Her element ID'si için çözümlenmiş text içeriğini tutar. /// Her element ID'si için çözümlenmiş text içeriğini tutar.
/// Table ve barcode gibi özel tipler de burada çözülür. /// Table ve barcode gibi özel tipler de burada çözülür.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -14,6 +80,10 @@ pub struct ResolvedData {
pub barcodes: HashMap<String, String>, pub barcodes: HashMap<String, String>,
/// element_id → çözümlenmiş image src /// element_id → çözümlenmiş image src
pub images: HashMap<String, String>, pub images: HashMap<String, String>,
/// page_number element_id → format string (sayfa bölme sonrası çözülecek)
pub page_number_formats: HashMap<String, String>,
/// element_id → çözümlenmiş rich text span listesi
pub rich_texts: HashMap<String, Vec<ResolvedRichSpan>>,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -51,8 +121,16 @@ pub fn resolve_template(template: &Template, data: &Value) -> ResolvedData {
tables: HashMap::new(), tables: HashMap::new(),
barcodes: HashMap::new(), barcodes: HashMap::new(),
images: HashMap::new(), images: HashMap::new(),
page_number_formats: HashMap::new(),
rich_texts: HashMap::new(),
}; };
if let Some(ref header) = template.header {
resolve_element(&TemplateElement::Container(header.clone()), data, &mut resolved);
}
resolve_element(&TemplateElement::Container(template.root.clone()), data, &mut resolved); resolve_element(&TemplateElement::Container(template.root.clone()), data, &mut resolved);
if let Some(ref footer) = template.footer {
resolve_element(&TemplateElement::Container(footer.clone()), data, &mut resolved);
}
resolved resolved
} }
@@ -70,8 +148,10 @@ fn resolve_element(el: &TemplateElement, data: &Value, resolved: &mut ResolvedDa
resolved.texts.insert(e.id.clone(), text); resolved.texts.insert(e.id.clone(), text);
} }
TemplateElement::PageNumber(e) => { TemplateElement::PageNumber(e) => {
// Sayfa numarası layout sonrasında çözülecek, placeholder koy // Format string'i sakla — sayfa bölme sonrası gerçek değerlerle çözülecek
let fmt = e.format.as_deref().unwrap_or("{current} / {total}"); let fmt = e.format.as_deref().unwrap_or("{current} / {total}").to_string();
resolved.page_number_formats.insert(e.id.clone(), fmt.clone());
// Placeholder koy (tek sayfalık fallback)
resolved.texts.insert(e.id.clone(), fmt.replace("{current}", "1").replace("{total}", "1")); resolved.texts.insert(e.id.clone(), fmt.replace("{current}", "1").replace("{total}", "1"));
} }
TemplateElement::Barcode(e) => { TemplateElement::Barcode(e) => {
@@ -116,7 +196,59 @@ fn resolve_element(el: &TemplateElement, data: &Value, resolved: &mut ResolvedDa
resolve_element(child, data, resolved); resolve_element(child, data, resolved);
} }
} }
TemplateElement::CurrentDate(e) => {
let fmt = e.format.as_deref().unwrap_or("DD.MM.YYYY");
let text = format_current_date(fmt);
resolved.texts.insert(e.id.clone(), text);
}
TemplateElement::Checkbox(e) => {
let checked = if let Some(binding) = &e.binding {
let val = resolve_path(data, &binding.path);
match val {
Value::Bool(b) => *b,
Value::Number(n) => n.as_f64().unwrap_or(0.0) != 0.0,
Value::String(s) => s == "true" || s == "1",
_ => false,
}
} else {
e.checked.unwrap_or(false)
};
// Store as "true"/"false" string in texts map
resolved.texts.insert(e.id.clone(), checked.to_string());
}
TemplateElement::CalculatedText(e) => {
let result = crate::expr_eval::evaluate_expression(&e.expression, data);
let formatted = crate::expr_eval::apply_format(&result, e.format.as_deref());
resolved.texts.insert(e.id.clone(), formatted);
}
TemplateElement::RichText(e) => {
let spans: Vec<ResolvedRichSpan> = e
.content
.iter()
.map(|span| {
let text = if let Some(ref binding) = span.binding {
let bound = value_to_string(resolve_path(data, &binding.path));
match &span.text {
Some(prefix) if !prefix.is_empty() => format!("{}{}", prefix, bound),
_ => bound,
}
} else {
span.text.clone().unwrap_or_default()
};
ResolvedRichSpan {
text,
font_size: span.style.font_size.or(e.style.font_size),
font_weight: span.style.font_weight.clone().or(e.style.font_weight.clone()),
font_family: span.style.font_family.clone().or(e.style.font_family.clone()),
color: span.style.color.clone().or(e.style.color.clone()),
}
})
.collect();
resolved.rich_texts.insert(e.id.clone(), spans);
}
TemplateElement::Line(_) => {} TemplateElement::Line(_) => {}
TemplateElement::Shape(_) => {}
TemplateElement::PageBreak(_) => {}
} }
} }
@@ -192,6 +324,8 @@ mod tests {
name: "Test".to_string(), name: "Test".to_string(),
page: PageSettings { width: 210.0, height: 297.0 }, page: PageSettings { width: 210.0, height: 297.0 },
fonts: vec![], fonts: vec![],
header: None,
footer: None,
root: ContainerElement { root: ContainerElement {
id: "root".to_string(), id: "root".to_string(),
position: PositionMode::Flow, position: PositionMode::Flow,
@@ -202,6 +336,7 @@ mod tests {
align: "stretch".to_string(), align: "stretch".to_string(),
justify: "start".to_string(), justify: "start".to_string(),
style: ContainerStyle::default(), style: ContainerStyle::default(),
break_inside: "auto".to_string(),
children: vec![ children: vec![
TemplateElement::Text(TextElement { TemplateElement::Text(TextElement {
id: "el_name".to_string(), id: "el_name".to_string(),
@@ -233,6 +368,8 @@ mod tests {
name: "Test".to_string(), name: "Test".to_string(),
page: PageSettings { width: 210.0, height: 297.0 }, page: PageSettings { width: 210.0, height: 297.0 },
fonts: vec![], fonts: vec![],
header: None,
footer: None,
root: ContainerElement { root: ContainerElement {
id: "root".to_string(), id: "root".to_string(),
position: PositionMode::Flow, position: PositionMode::Flow,
@@ -243,6 +380,7 @@ mod tests {
align: "stretch".to_string(), align: "stretch".to_string(),
justify: "start".to_string(), justify: "start".to_string(),
style: ContainerStyle::default(), style: ContainerStyle::default(),
break_inside: "auto".to_string(),
children: vec![ children: vec![
TemplateElement::Text(TextElement { TemplateElement::Text(TextElement {
id: "el_no".to_string(), id: "el_no".to_string(),
@@ -274,6 +412,8 @@ mod tests {
name: "Test".to_string(), name: "Test".to_string(),
page: PageSettings { width: 210.0, height: 297.0 }, page: PageSettings { width: 210.0, height: 297.0 },
fonts: vec![], fonts: vec![],
header: None,
footer: None,
root: ContainerElement { root: ContainerElement {
id: "root".to_string(), id: "root".to_string(),
position: PositionMode::Flow, position: PositionMode::Flow,
@@ -284,6 +424,7 @@ mod tests {
align: "stretch".to_string(), align: "stretch".to_string(),
justify: "start".to_string(), justify: "start".to_string(),
style: ContainerStyle::default(), style: ContainerStyle::default(),
break_inside: "auto".to_string(),
children: vec![ children: vec![
TemplateElement::StaticText(StaticTextElement { TemplateElement::StaticText(StaticTextElement {
id: "title".to_string(), id: "title".to_string(),
@@ -307,6 +448,8 @@ mod tests {
name: "Test".to_string(), name: "Test".to_string(),
page: PageSettings { width: 210.0, height: 297.0 }, page: PageSettings { width: 210.0, height: 297.0 },
fonts: vec![], fonts: vec![],
header: None,
footer: None,
root: ContainerElement { root: ContainerElement {
id: "root".to_string(), id: "root".to_string(),
position: PositionMode::Flow, position: PositionMode::Flow,
@@ -317,6 +460,7 @@ mod tests {
align: "stretch".to_string(), align: "stretch".to_string(),
justify: "start".to_string(), justify: "start".to_string(),
style: ContainerStyle::default(), style: ContainerStyle::default(),
break_inside: "auto".to_string(),
children: vec![ children: vec![
TemplateElement::RepeatingTable(RepeatingTableElement { TemplateElement::RepeatingTable(RepeatingTableElement {
id: "tbl".to_string(), id: "tbl".to_string(),
@@ -342,6 +486,7 @@ mod tests {
}, },
], ],
style: TableStyle::default(), style: TableStyle::default(),
repeat_header: Some(true),
}), }),
], ],
}, },
@@ -368,6 +513,8 @@ mod tests {
name: "Test".to_string(), name: "Test".to_string(),
page: PageSettings { width: 210.0, height: 297.0 }, page: PageSettings { width: 210.0, height: 297.0 },
fonts: vec![], fonts: vec![],
header: None,
footer: None,
root: ContainerElement { root: ContainerElement {
id: "root".to_string(), id: "root".to_string(),
position: PositionMode::Flow, position: PositionMode::Flow,
@@ -378,6 +525,7 @@ mod tests {
align: "stretch".to_string(), align: "stretch".to_string(),
justify: "start".to_string(), justify: "start".to_string(),
style: ContainerStyle::default(), style: ContainerStyle::default(),
break_inside: "auto".to_string(),
children: vec![ children: vec![
TemplateElement::RepeatingTable(RepeatingTableElement { TemplateElement::RepeatingTable(RepeatingTableElement {
id: "tbl".to_string(), id: "tbl".to_string(),
@@ -395,6 +543,7 @@ mod tests {
}, },
], ],
style: TableStyle::default(), style: TableStyle::default(),
repeat_header: Some(true),
}), }),
], ],
}, },
@@ -413,6 +562,8 @@ mod tests {
name: "Test".to_string(), name: "Test".to_string(),
page: PageSettings { width: 210.0, height: 297.0 }, page: PageSettings { width: 210.0, height: 297.0 },
fonts: vec![], fonts: vec![],
header: None,
footer: None,
root: ContainerElement { root: ContainerElement {
id: "root".to_string(), id: "root".to_string(),
position: PositionMode::Flow, position: PositionMode::Flow,
@@ -423,6 +574,7 @@ mod tests {
align: "stretch".to_string(), align: "stretch".to_string(),
justify: "start".to_string(), justify: "start".to_string(),
style: ContainerStyle::default(), style: ContainerStyle::default(),
break_inside: "auto".to_string(),
children: vec![ children: vec![
TemplateElement::Text(TextElement { TemplateElement::Text(TextElement {
id: "el_missing".to_string(), id: "el_missing".to_string(),

View File

@@ -0,0 +1,510 @@
use serde_json::Value;
/// Expression evaluator for calculated_text elements.
/// This is a safe recursive descent parser — NOT an arbitrary code executor.
/// It only supports arithmetic, string operations, comparisons, and data path lookups.
///
/// Supported syntax:
/// - Path lookup: `firma.unvan`, `toplamlar.kdv`
/// - Arithmetic: `+`, `-`, `*`, `/`
/// - String concatenation: `+` when operand is string
/// - String literals: `"..."` or `'...'`
/// - Number literals: `42`, `3.14`
/// - Comparison: `>`, `<`, `>=`, `<=`, `==`, `!=`
/// - Ternary: `expr ? "a" : "b"`
/// - Parentheses: `(a + b) * c`
pub fn evaluate_expression(expr: &str, data: &Value) -> String {
let tokens = tokenize(expr);
if tokens.is_empty() {
return String::new();
}
let mut parser = Parser {
tokens: &tokens,
pos: 0,
data,
};
match parser.parse_ternary() {
ExprValue::Num(n) => format_number(n),
ExprValue::Str(s) => s,
ExprValue::Bool(b) => b.to_string(),
ExprValue::Null => String::new(),
}
}
fn format_number(n: f64) -> String {
if n == n.floor() && n.abs() < 1e15 {
format!("{}", n as i64)
} else {
format!("{}", n)
}
}
/// Format result with given format type
pub fn apply_format(value: &str, format: Option<&str>) -> String {
match format {
Some("currency") => format_currency(value),
Some("percentage") => format_percentage(value),
Some("number") => format_number_str(value),
_ => value.to_string(),
}
}
fn format_currency(value: &str) -> String {
if let Ok(n) = value.parse::<f64>() {
let abs = n.abs();
let integer = abs.floor() as i64;
let frac = ((abs - abs.floor()) * 100.0).round() as i64;
let int_str = format_with_thousands(integer);
let sign = if n < 0.0 { "-" } else { "" };
format!("{}{},{:02}", sign, int_str, frac)
} else {
value.to_string()
}
}
fn format_percentage(value: &str) -> String {
if let Ok(n) = value.parse::<f64>() {
format!("%{:.2}", n)
} else {
value.to_string()
}
}
fn format_number_str(value: &str) -> String {
if let Ok(n) = value.parse::<f64>() {
if n == n.floor() && n.abs() < 1e15 {
format_with_thousands(n.abs() as i64)
} else {
format!("{:.2}", n)
}
} else {
value.to_string()
}
}
fn format_with_thousands(n: i64) -> String {
let s = n.to_string();
let len = s.len();
if len <= 3 {
return s;
}
let mut result = String::new();
for (i, ch) in s.chars().enumerate() {
if i > 0 && (len - i) % 3 == 0 {
result.push('.');
}
result.push(ch);
}
result
}
// --- Tokenizer ---
#[derive(Debug, Clone, PartialEq)]
enum Token {
Num(f64),
Str(String),
Ident(String),
Plus,
Minus,
Star,
Slash,
LParen,
RParen,
Gt,
Lt,
Gte,
Lte,
Eq,
Neq,
Question,
Colon,
}
fn tokenize(input: &str) -> Vec<Token> {
let mut tokens = Vec::new();
let chars: Vec<char> = input.chars().collect();
let len = chars.len();
let mut i = 0;
while i < len {
match chars[i] {
' ' | '\t' | '\n' | '\r' => i += 1,
'+' => { tokens.push(Token::Plus); i += 1; }
'-' => {
// Negative number: after operator or at start
let is_unary = tokens.is_empty()
|| matches!(tokens.last(), Some(
Token::Plus | Token::Minus | Token::Star | Token::Slash
| Token::LParen | Token::Question | Token::Colon
| Token::Gt | Token::Lt | Token::Gte | Token::Lte
| Token::Eq | Token::Neq
));
if is_unary && i + 1 < len && (chars[i + 1].is_ascii_digit() || chars[i + 1] == '.') {
let start = i;
i += 1;
while i < len && (chars[i].is_ascii_digit() || chars[i] == '.') {
i += 1;
}
let num_str: String = chars[start..i].iter().collect();
if let Ok(n) = num_str.parse::<f64>() {
tokens.push(Token::Num(n));
}
} else {
tokens.push(Token::Minus);
i += 1;
}
}
'*' => { tokens.push(Token::Star); i += 1; }
'/' => { tokens.push(Token::Slash); i += 1; }
'(' => { tokens.push(Token::LParen); i += 1; }
')' => { tokens.push(Token::RParen); i += 1; }
'?' => { tokens.push(Token::Question); i += 1; }
':' => { tokens.push(Token::Colon); i += 1; }
'>' => {
if i + 1 < len && chars[i + 1] == '=' {
tokens.push(Token::Gte); i += 2;
} else {
tokens.push(Token::Gt); i += 1;
}
}
'<' => {
if i + 1 < len && chars[i + 1] == '=' {
tokens.push(Token::Lte); i += 2;
} else {
tokens.push(Token::Lt); i += 1;
}
}
'=' => {
if i + 1 < len && chars[i + 1] == '=' {
tokens.push(Token::Eq); i += 2;
} else {
i += 1;
}
}
'!' => {
if i + 1 < len && chars[i + 1] == '=' {
tokens.push(Token::Neq); i += 2;
} else {
i += 1;
}
}
'"' | '\'' => {
let quote = chars[i];
i += 1;
let start = i;
while i < len && chars[i] != quote {
i += 1;
}
let s: String = chars[start..i].iter().collect();
tokens.push(Token::Str(s));
if i < len { i += 1; }
}
c if c.is_ascii_digit() || (c == '.' && i + 1 < len && chars[i + 1].is_ascii_digit()) => {
let start = i;
while i < len && (chars[i].is_ascii_digit() || chars[i] == '.') {
i += 1;
}
let num_str: String = chars[start..i].iter().collect();
if let Ok(n) = num_str.parse::<f64>() {
tokens.push(Token::Num(n));
}
}
c if c.is_alphanumeric() || c == '_' => {
let start = i;
while i < len && (chars[i].is_alphanumeric() || chars[i] == '_' || chars[i] == '.') {
i += 1;
}
// Trim trailing dots
while i > start && chars[i - 1] == '.' {
i -= 1;
}
let ident: String = chars[start..i].iter().collect();
match ident.as_str() {
"true" => tokens.push(Token::Num(1.0)),
"false" => tokens.push(Token::Num(0.0)),
_ => tokens.push(Token::Ident(ident)),
}
}
_ => i += 1,
}
}
tokens
}
// --- Parser (recursive descent) ---
#[derive(Debug, Clone)]
enum ExprValue {
Num(f64),
Str(String),
Bool(bool),
Null,
}
impl ExprValue {
fn to_num(&self) -> f64 {
match self {
ExprValue::Num(n) => *n,
ExprValue::Str(s) => s.parse().unwrap_or(0.0),
ExprValue::Bool(b) => if *b { 1.0 } else { 0.0 },
ExprValue::Null => 0.0,
}
}
fn to_str(&self) -> String {
match self {
ExprValue::Num(n) => format_number(*n),
ExprValue::Str(s) => s.clone(),
ExprValue::Bool(b) => b.to_string(),
ExprValue::Null => String::new(),
}
}
fn is_truthy(&self) -> bool {
match self {
ExprValue::Num(n) => *n != 0.0,
ExprValue::Str(s) => !s.is_empty(),
ExprValue::Bool(b) => *b,
ExprValue::Null => false,
}
}
fn is_string(&self) -> bool {
matches!(self, ExprValue::Str(_))
}
}
struct Parser<'a> {
tokens: &'a [Token],
pos: usize,
data: &'a Value,
}
impl<'a> Parser<'a> {
fn peek(&self) -> Option<&Token> {
self.tokens.get(self.pos)
}
fn advance(&mut self) -> Option<&Token> {
let tok = self.tokens.get(self.pos);
self.pos += 1;
tok
}
fn parse_ternary(&mut self) -> ExprValue {
let cond = self.parse_comparison();
if self.peek() == Some(&Token::Question) {
self.advance();
let then_val = self.parse_ternary();
if self.peek() == Some(&Token::Colon) {
self.advance();
}
let else_val = self.parse_ternary();
if cond.is_truthy() { then_val } else { else_val }
} else {
cond
}
}
fn parse_comparison(&mut self) -> ExprValue {
let left = self.parse_additive();
match self.peek() {
Some(Token::Gt) => { self.advance(); let r = self.parse_additive(); ExprValue::Bool(left.to_num() > r.to_num()) }
Some(Token::Lt) => { self.advance(); let r = self.parse_additive(); ExprValue::Bool(left.to_num() < r.to_num()) }
Some(Token::Gte) => { self.advance(); let r = self.parse_additive(); ExprValue::Bool(left.to_num() >= r.to_num()) }
Some(Token::Lte) => { self.advance(); let r = self.parse_additive(); ExprValue::Bool(left.to_num() <= r.to_num()) }
Some(Token::Eq) => { self.advance(); let r = self.parse_additive(); ExprValue::Bool(left.to_str() == r.to_str()) }
Some(Token::Neq) => { self.advance(); let r = self.parse_additive(); ExprValue::Bool(left.to_str() != r.to_str()) }
_ => left,
}
}
fn parse_additive(&mut self) -> ExprValue {
let mut left = self.parse_multiplicative();
loop {
match self.peek() {
Some(Token::Plus) => {
self.advance();
let right = self.parse_multiplicative();
if left.is_string() || right.is_string() {
left = ExprValue::Str(format!("{}{}", left.to_str(), right.to_str()));
} else {
left = ExprValue::Num(left.to_num() + right.to_num());
}
}
Some(Token::Minus) => {
self.advance();
let right = self.parse_multiplicative();
left = ExprValue::Num(left.to_num() - right.to_num());
}
_ => break,
}
}
left
}
fn parse_multiplicative(&mut self) -> ExprValue {
let mut left = self.parse_primary();
loop {
match self.peek() {
Some(Token::Star) => {
self.advance();
let right = self.parse_primary();
left = ExprValue::Num(left.to_num() * right.to_num());
}
Some(Token::Slash) => {
self.advance();
let right = self.parse_primary();
let r = right.to_num();
left = ExprValue::Num(if r != 0.0 { left.to_num() / r } else { 0.0 });
}
_ => break,
}
}
left
}
fn parse_primary(&mut self) -> ExprValue {
match self.advance().cloned() {
Some(Token::Num(n)) => ExprValue::Num(n),
Some(Token::Str(s)) => ExprValue::Str(s),
Some(Token::Ident(path)) => {
let val = resolve_path(self.data, &path);
json_to_expr(val)
}
Some(Token::LParen) => {
let val = self.parse_ternary();
if self.peek() == Some(&Token::RParen) {
self.advance();
}
val
}
Some(Token::Minus) => {
let val = self.parse_primary();
ExprValue::Num(-val.to_num())
}
_ => ExprValue::Null,
}
}
}
fn resolve_path<'a>(data: &'a Value, path: &str) -> &'a Value {
let mut current = data;
for key in path.split('.') {
current = match current {
Value::Object(map) => map.get(key).unwrap_or(&Value::Null),
_ => &Value::Null,
};
}
current
}
fn json_to_expr(v: &Value) -> ExprValue {
match v {
Value::Number(n) => ExprValue::Num(n.as_f64().unwrap_or(0.0)),
Value::String(s) => ExprValue::Str(s.clone()),
Value::Bool(b) => ExprValue::Bool(*b),
Value::Null => ExprValue::Null,
_ => ExprValue::Str(v.to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_simple_path() {
let data = json!({"firma": {"unvan": "Acme A.Ş."}});
assert_eq!(evaluate_expression("firma.unvan", &data), "Acme A.Ş.");
}
#[test]
fn test_arithmetic() {
let data = json!({"toplamlar": {"araToplam": 16000, "kdv": 2880}});
assert_eq!(evaluate_expression("toplamlar.araToplam + toplamlar.kdv", &data), "18880");
}
#[test]
fn test_multiplication() {
let data = json!({"toplamlar": {"araToplam": 16000}});
assert_eq!(evaluate_expression("toplamlar.araToplam * 0.20", &data), "3200");
}
#[test]
fn test_string_concat() {
let data = json!({"fatura": {"no": "FTR-001"}});
assert_eq!(evaluate_expression("\"Fatura No: \" + fatura.no", &data), "Fatura No: FTR-001");
}
#[test]
fn test_ternary() {
let data = json!({"fatura": {"tutar": 5000}});
assert_eq!(evaluate_expression("fatura.tutar > 0 ? \"Borclu\" : \"Alacakli\"", &data), "Borclu");
}
#[test]
fn test_ternary_false() {
let data = json!({"fatura": {"tutar": 0}});
assert_eq!(evaluate_expression("fatura.tutar > 0 ? \"Borclu\" : \"Alacakli\"", &data), "Alacakli");
}
#[test]
fn test_parentheses() {
let data = json!({"a": 2, "b": 3, "c": 4});
assert_eq!(evaluate_expression("(a + b) * c", &data), "20");
}
#[test]
fn test_number_literal() {
let data = json!({});
assert_eq!(evaluate_expression("42", &data), "42");
assert_eq!(evaluate_expression("3.14", &data), "3.14");
}
#[test]
fn test_division_by_zero() {
let data = json!({});
assert_eq!(evaluate_expression("10 / 0", &data), "0");
}
#[test]
fn test_missing_path() {
let data = json!({});
assert_eq!(evaluate_expression("missing.path", &data), "");
}
#[test]
fn test_comparison_eq() {
let data = json!({"status": "paid"});
assert_eq!(evaluate_expression("status == \"paid\" ? \"Odendi\" : \"Odenmedi\"", &data), "Odendi");
}
#[test]
fn test_format_currency() {
assert_eq!(apply_format("18880", Some("currency")), "18.880,00 ₺");
assert_eq!(apply_format("1000.5", Some("currency")), "1.000,50 ₺");
}
#[test]
fn test_format_percentage() {
assert_eq!(apply_format("20", Some("percentage")), "%20.00");
}
#[test]
fn test_negative_result() {
let data = json!({"a": 10, "b": 20});
assert_eq!(evaluate_expression("a - b", &data), "-10");
}
#[test]
fn test_empty_expression() {
let data = json!({});
assert_eq!(evaluate_expression("", &data), "");
}
}

View File

@@ -3,6 +3,8 @@ pub mod text_measure;
pub mod data_resolve; pub mod data_resolve;
pub mod table_layout; pub mod table_layout;
pub mod tree; pub mod tree;
pub mod page_break;
pub mod expr_eval;
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
pub mod wasm_api; pub mod wasm_api;
@@ -56,6 +58,12 @@ pub enum ResolvedContent {
Barcode { format: String, value: String }, Barcode { format: String, value: String },
#[serde(rename = "page_number")] #[serde(rename = "page_number")]
PageNumber { current: usize, total: usize }, PageNumber { current: usize, total: usize },
#[serde(rename = "shape")]
Shape { shape_type: String },
#[serde(rename = "checkbox")]
Checkbox { checked: bool },
#[serde(rename = "rich_text")]
RichText { spans: Vec<ResolvedRichSpan> },
#[serde(rename = "table")] #[serde(rename = "table")]
Table { Table {
headers: Vec<TableHeaderCell>, headers: Vec<TableHeaderCell>,
@@ -64,6 +72,16 @@ pub enum ResolvedContent {
}, },
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ResolvedRichSpan {
pub text: String,
pub font_size: Option<f64>,
pub font_weight: Option<String>,
pub font_family: Option<String>,
pub color: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TableHeaderCell { pub struct TableHeaderCell {
pub text: String, pub text: String,

View File

@@ -0,0 +1,995 @@
use std::collections::{HashMap, HashSet};
use crate::{ElementLayout, PageLayout, ResolvedContent};
/// Sayfa bölme girdi yapısı
pub struct PageSplitInput {
/// Body elemanları (sınırsız yükseklikte hesaplanmış, mutlak mm koordinatları)
pub body_elements: Vec<ElementLayout>,
/// Sayfa yüksekliği (mm)
pub page_height_mm: f64,
/// Header yüksekliği (mm) — body'nin başlangıç offset'i
pub header_height_mm: f64,
/// Footer yüksekliği (mm)
pub footer_height_mm: f64,
/// Header elemanları (klonlanacak, her sayfada tekrar)
pub header_elements: Vec<ElementLayout>,
/// Footer elemanları (klonlanacak, her sayfada tekrar)
pub footer_elements: Vec<ElementLayout>,
/// Sayfa genişliği (mm)
pub page_width_mm: f64,
/// Container break modları: element_id → "auto" | "avoid"
pub break_modes: HashMap<String, String>,
/// page_number format string'leri: element_id → format
pub page_number_formats: HashMap<String, String>,
/// Root container'ın üst padding'i (mm) — sayfa 2+ için body offset
pub root_padding_top_mm: f64,
}
/// Body elemanlarını sayfalara böl, header/footer ekle, page number'ları çöz.
pub fn split_into_pages(input: PageSplitInput) -> Vec<PageLayout> {
let content_height = input.page_height_mm - input.header_height_mm - input.footer_height_mm;
if content_height <= 0.0 {
// Header + footer sayfaya sığmıyor — tek sayfa döndür
return vec![assemble_page(
0,
&input.body_elements,
&input.header_elements,
&input.footer_elements,
input.page_width_mm,
input.page_height_mm,
input.header_height_mm,
input.footer_height_mm,
0.0,
input.root_padding_top_mm,
)];
}
// Parent lookup: element_id → parent_id (children alanından)
let parent_map = build_parent_map(&input.body_elements);
// "avoid" grupları: container_id → (top_mm, bottom_mm, tüm descendant id'leri)
let avoid_groups = build_avoid_groups(&input.body_elements, &input.break_modes, &parent_map);
// Tablo yapısı tespiti: table_id → header element id'leri
let table_info = detect_table_structure(&input.body_elements);
// Elemanları sayfalara böl
let page_slices = split_elements(
&input.body_elements,
content_height,
&avoid_groups,
&parent_map,
&table_info,
);
let total_pages = page_slices.len().max(1);
let mut pages: Vec<PageLayout> = Vec::with_capacity(total_pages);
for (page_idx, slice) in page_slices.iter().enumerate() {
let page = assemble_page(
page_idx,
&slice.elements,
&input.header_elements,
&input.footer_elements,
input.page_width_mm,
input.page_height_mm,
input.header_height_mm,
input.footer_height_mm,
slice.y_offset,
input.root_padding_top_mm,
);
pages.push(page);
}
// Boş sayfa koruması
if pages.is_empty() {
pages.push(assemble_page(
0,
&[],
&input.header_elements,
&input.footer_elements,
input.page_width_mm,
input.page_height_mm,
input.header_height_mm,
input.footer_height_mm,
0.0,
input.root_padding_top_mm,
));
}
// Page number çözümleme
let total = pages.len();
for (page_idx, page) in pages.iter_mut().enumerate() {
resolve_page_numbers(&mut page.elements, page_idx + 1, total, &input.page_number_formats);
}
pages
}
/// Bir avoid grubunun bilgisi
struct AvoidGroup {
top_mm: f64,
bottom_mm: f64,
element_ids: HashSet<String>,
}
/// Tablo yapısı bilgisi
struct TableInfo {
/// table_id → header satırının eleman id'leri
_header_element_ids: Vec<String>,
/// table_id → header satırındaki elemanların klonları
header_elements: Vec<ElementLayout>,
/// Header yüksekliği (mm)
header_height_mm: f64,
}
/// Sayfa dilimi
struct PageSlice {
elements: Vec<ElementLayout>,
y_offset: f64, // Bu sayfanın strip'teki başlangıç y koordinatı
}
fn build_parent_map(elements: &[ElementLayout]) -> HashMap<String, String> {
let mut map = HashMap::new();
for el in elements {
for child_id in &el.children {
map.insert(child_id.clone(), el.id.clone());
}
}
map
}
fn build_avoid_groups(
elements: &[ElementLayout],
break_modes: &HashMap<String, String>,
_parent_map: &HashMap<String, String>,
) -> Vec<AvoidGroup> {
// Hangi container'lar avoid?
let avoid_ids: HashSet<&String> = break_modes
.iter()
.filter(|(_, mode)| mode.as_str() == "avoid")
.map(|(id, _)| id)
.collect();
if avoid_ids.is_empty() {
return vec![];
}
let element_map: HashMap<&str, &ElementLayout> =
elements.iter().map(|e| (e.id.as_str(), e)).collect();
let mut groups = Vec::new();
for avoid_id in &avoid_ids {
if let Some(container) = element_map.get(avoid_id.as_str()) {
// Bu container'ın tüm descendant'larını bul
let mut descendant_ids = HashSet::new();
descendant_ids.insert(container.id.clone());
collect_descendants(container.id.as_str(), elements, &mut descendant_ids);
// Grubun top/bottom'ını hesapla
let mut top = container.y_mm;
let mut bottom = container.y_mm + container.height_mm;
for el in elements {
if descendant_ids.contains(&el.id) {
top = top.min(el.y_mm);
bottom = bottom.max(el.y_mm + el.height_mm);
}
}
groups.push(AvoidGroup {
top_mm: top,
bottom_mm: bottom,
element_ids: descendant_ids,
});
}
}
groups
}
fn collect_descendants(
parent_id: &str,
elements: &[ElementLayout],
result: &mut HashSet<String>,
) {
// children alanından recursive olarak topla
for el in elements {
if el.id == parent_id {
for child_id in &el.children {
result.insert(child_id.clone());
collect_descendants(child_id, elements, result);
}
break;
}
}
}
/// Bir elemanın en yakın avoid ancestor'ı var mı?
fn find_avoid_group<'a>(
element_id: &str,
avoid_groups: &'a [AvoidGroup],
) -> Option<&'a AvoidGroup> {
avoid_groups
.iter()
.find(|g| g.element_ids.contains(element_id))
}
fn detect_table_structure(elements: &[ElementLayout]) -> HashMap<String, TableInfo> {
// Tablo yapısını ID pattern'inden tespit et:
// {table_id}_header → header satırı container
// {table_id}_hdr_{N} → header hücreleri
// {table_id}_row_{N} → veri satırı container
// {table_id}_r{N}c{M} → veri hücreleri
let mut tables: HashMap<String, TableInfo> = HashMap::new();
// Önce header container'ları bul
for el in elements {
if el.id.ends_with("_header") && el.element_type == "container" {
let table_id = el.id.trim_end_matches("_header").to_string();
// Bu table_id ile başlayan row'lar var mı kontrol et
let has_rows = elements
.iter()
.any(|e| e.id.starts_with(&format!("{}_row_", table_id)));
if has_rows {
// Header elemanlarını topla (header container + children)
let mut header_ids = vec![el.id.clone()];
for child_id in &el.children {
header_ids.push(child_id.clone());
}
let header_elements: Vec<ElementLayout> = elements
.iter()
.filter(|e| header_ids.contains(&e.id))
.cloned()
.collect();
let header_height = el.height_mm;
tables.insert(
table_id,
TableInfo {
_header_element_ids: header_ids,
header_elements,
header_height_mm: header_height,
},
);
}
}
}
tables
}
/// Hangi tablo'ya ait bir satır elemanı mı?
fn detect_table_row(element_id: &str) -> Option<(String, usize)> {
// Pattern: {table_id}_row_{N}
if let Some(pos) = element_id.rfind("_row_") {
let table_id = element_id[..pos].to_string();
let row_str = &element_id[pos + 5..];
if let Ok(row_idx) = row_str.parse::<usize>() {
return Some((table_id, row_idx));
}
}
None
}
fn split_elements(
elements: &[ElementLayout],
content_height: f64,
avoid_groups: &[AvoidGroup],
_parent_map: &HashMap<String, String>,
table_info: &HashMap<String, TableInfo>,
) -> Vec<PageSlice> {
if elements.is_empty() {
return vec![PageSlice {
elements: vec![],
y_offset: 0.0,
}];
}
let mut pages: Vec<PageSlice> = vec![PageSlice {
elements: Vec::new(),
y_offset: 0.0,
}];
// Yapısal container'ları tespit et: çocukları arasında container olan container'lar.
// Bu container'lar sayfa sınırında bölünebilir (çocukları bireysel sayfa bölmesi yapar).
// Aksine, çocukları sadece leaf olan container'lar (ör. tablo satırı) atomik kalır.
let element_type_map: HashMap<&str, &str> = elements
.iter()
.map(|e| (e.id.as_str(), e.element_type.as_str()))
.collect();
let splittable_containers: HashSet<&str> = elements
.iter()
.filter(|e| e.element_type == "container")
.filter(|e| {
e.children
.iter()
.any(|child_id| element_type_map.get(child_id.as_str()) == Some(&"container"))
})
.map(|e| e.id.as_str())
.collect();
let mut page_top = 0.0; // Mevcut sayfanın strip'teki başlangıç y'si
let mut processed: HashSet<String> = HashSet::new();
// Hangi tablo'ların header'ı bu sayfada zaten var?
let mut table_header_on_page: HashSet<String> = HashSet::new();
for el in elements {
if processed.contains(&el.id) {
continue;
}
// page_break elemanı → mevcut sayfaya ekle, sonra yeni sayfa zorla
if el.element_type == "page_break" {
pages.last_mut().unwrap().elements.push(el.clone());
processed.insert(el.id.clone());
page_top = el.y_mm + el.height_mm;
pages.push(PageSlice {
elements: Vec::new(),
y_offset: page_top,
});
table_header_on_page.clear();
continue;
}
let el_top = el.y_mm;
let el_bottom = el.y_mm + el.height_mm;
let relative_bottom = el_bottom - page_top;
// Avoid group kontrolü
if let Some(group) = find_avoid_group(&el.id, avoid_groups) {
let group_relative_bottom = group.bottom_mm - page_top;
let group_height = group.bottom_mm - group.top_mm;
if group_relative_bottom > content_height && group_height <= content_height {
// Grup mevcut sayfaya sığmıyor ama tek sayfaya sığar → yeni sayfa
page_top = group.top_mm;
pages.push(PageSlice {
elements: Vec::new(),
y_offset: page_top,
});
table_header_on_page.clear();
}
// Grup sayfadan büyükse → normal akışa devam (bölünemez ama mecbur)
}
// Eleman mevcut sayfaya sığıyor mu?
if relative_bottom > content_height && el_top > page_top {
// Yapısal container (çocukları container olan) → bölünebilir.
// Komple yeni sayfaya atmak yerine mevcut sayfada bırak,
// çocuk elemanlar bireysel olarak sayfa bölmesini halledecek.
if splittable_containers.contains(el.id.as_str()) {
pages.last_mut().unwrap().elements.push(el.clone());
processed.insert(el.id.clone());
continue;
}
// Sığmıyor → yeni sayfa
// Tablo satırı mı? Header tekrarı gerekebilir
let mut table_header_to_add: Option<(String, Vec<ElementLayout>, f64)> = None;
if let Some((table_id, _row_idx)) = detect_table_row(&el.id) {
if let Some(info) = table_info.get(&table_id) {
// Yeni sayfada bu tablonun header'ını tekrarla
table_header_to_add =
Some((table_id.clone(), info.header_elements.clone(), info.header_height_mm));
}
}
page_top = el_top;
// Tablo header tekrarı varsa, header yüksekliği kadar offset
if let Some((ref table_id, ref header_els, header_h)) = table_header_to_add {
// Header'ı yeni sayfanın başına koy (offset'li)
page_top = el_top - header_h;
let new_page_idx = pages.len();
pages.push(PageSlice {
elements: Vec::new(),
y_offset: page_top,
});
table_header_on_page.clear();
// Header elemanlarını klonla ve y pozisyonlarını yeni sayfaya taşı.
// Orijinal header elemanları tablonun ilk konumundaki y değerlerine sahip.
// Yeni sayfada page_top'tan başlamaları gerekir.
let orig_header_y = header_els
.iter()
.map(|e| e.y_mm)
.min_by(|a, b| a.partial_cmp(b).unwrap())
.unwrap_or(page_top);
let y_shift = page_top - orig_header_y;
for hdr_el in header_els {
let mut cloned = hdr_el.clone();
cloned.y_mm += y_shift;
cloned.id = format!("{}_p{}", hdr_el.id, new_page_idx);
pages.last_mut().unwrap().elements.push(cloned);
}
table_header_on_page.insert(table_id.clone());
} else {
pages.push(PageSlice {
elements: Vec::new(),
y_offset: page_top,
});
table_header_on_page.clear();
}
}
// Elemanı mevcut sayfaya ekle
pages.last_mut().unwrap().elements.push(el.clone());
processed.insert(el.id.clone());
}
pages
}
fn assemble_page(
page_index: usize,
body_elements: &[ElementLayout],
header_elements: &[ElementLayout],
footer_elements: &[ElementLayout],
page_width_mm: f64,
page_height_mm: f64,
header_height_mm: f64,
footer_height_mm: f64,
body_y_offset: f64,
root_padding_top_mm: f64,
) -> PageLayout {
let mut elements = Vec::new();
// Header elemanları (y = orijinal y, sayfa başında)
for el in header_elements {
let mut cloned = el.clone();
if page_index > 0 {
// Sonraki sayfalarda ID'yi unique yap
cloned.id = format!("{}_p{}", el.id, page_index);
}
elements.push(cloned);
}
// Body elemanları (y offset'li — strip y'den sayfa-relative y'ye)
// Sayfa 2+ için root padding tekrar eklenir (root container sadece sayfa 1'de var)
let extra_top = if page_index > 0 { root_padding_top_mm } else { 0.0 };
for el in body_elements {
let mut adjusted = el.clone();
adjusted.y_mm = el.y_mm - body_y_offset + header_height_mm + extra_top;
elements.push(adjusted);
}
// Footer elemanları (sayfanın altında)
let footer_y_offset = page_height_mm - footer_height_mm;
for el in footer_elements {
let mut cloned = el.clone();
// Footer elemanlarının y'si footer container'ın başlangıcına relative
// Footer'ın orijinal y'si 0'dan başlıyor (ayrı hesaplanıyor)
// Sayfa içi pozisyon: footer_y_offset + orijinal y
cloned.y_mm = el.y_mm + footer_y_offset;
if page_index > 0 {
cloned.id = format!("{}_p{}", el.id, page_index);
}
elements.push(cloned);
}
PageLayout {
page_index,
width_mm: page_width_mm,
height_mm: page_height_mm,
elements,
}
}
fn resolve_page_numbers(
elements: &mut [ElementLayout],
current_page: usize,
total_pages: usize,
formats: &HashMap<String, String>,
) {
for el in elements.iter_mut() {
if el.element_type != "page_number" {
continue;
}
// ID'den orijinal format ID'sini çıkar (sayfa klonları _p{N} ile biter)
let original_id = if let Some(pos) = el.id.rfind("_p") {
let suffix = &el.id[pos + 2..];
if suffix.parse::<usize>().is_ok() {
&el.id[..pos]
} else {
&el.id
}
} else {
&el.id
};
let fmt = formats
.get(original_id)
.map(|s| s.as_str())
.unwrap_or("{current} / {total}");
let text = fmt
.replace("{current}", &current_page.to_string())
.replace("{total}", &total_pages.to_string());
el.content = Some(ResolvedContent::PageNumber {
current: current_page,
total: total_pages,
});
// Ayrıca text content'i de güncelle (LayoutRenderer text olarak render ediyor)
// PageNumber render'da content.type === "text" kontrolü var, text olarak da ekle
el.content = Some(ResolvedContent::Text { value: text });
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ResolvedStyle;
fn make_element(id: &str, y: f64, height: f64, element_type: &str) -> ElementLayout {
ElementLayout {
id: id.to_string(),
x_mm: 0.0,
y_mm: y,
width_mm: 180.0,
height_mm: height,
element_type: element_type.to_string(),
content: None,
style: ResolvedStyle::default(),
children: vec![],
}
}
#[test]
fn test_single_page_no_split() {
let input = PageSplitInput {
body_elements: vec![
make_element("el1", 0.0, 50.0, "text"),
make_element("el2", 50.0, 50.0, "text"),
],
page_height_mm: 297.0,
header_height_mm: 0.0,
footer_height_mm: 0.0,
header_elements: vec![],
footer_elements: vec![],
page_width_mm: 210.0,
break_modes: HashMap::new(),
page_number_formats: HashMap::new(),
root_padding_top_mm: 0.0,
};
let pages = split_into_pages(input);
assert_eq!(pages.len(), 1);
assert_eq!(pages[0].elements.len(), 2);
}
#[test]
fn test_auto_page_break() {
let input = PageSplitInput {
body_elements: vec![
make_element("el1", 0.0, 200.0, "text"),
make_element("el2", 200.0, 200.0, "text"),
],
page_height_mm: 297.0,
header_height_mm: 0.0,
footer_height_mm: 0.0,
header_elements: vec![],
footer_elements: vec![],
page_width_mm: 210.0,
break_modes: HashMap::new(),
page_number_formats: HashMap::new(),
root_padding_top_mm: 0.0,
};
let pages = split_into_pages(input);
assert_eq!(pages.len(), 2);
assert_eq!(pages[0].elements.len(), 1);
assert_eq!(pages[0].elements[0].id, "el1");
assert_eq!(pages[1].elements.len(), 1);
assert_eq!(pages[1].elements[0].id, "el2");
}
#[test]
fn test_manual_page_break() {
let input = PageSplitInput {
body_elements: vec![
make_element("el1", 0.0, 50.0, "text"),
make_element("pb1", 50.0, 0.0, "page_break"),
make_element("el2", 50.0, 50.0, "text"),
],
page_height_mm: 297.0,
header_height_mm: 0.0,
footer_height_mm: 0.0,
header_elements: vec![],
footer_elements: vec![],
page_width_mm: 210.0,
break_modes: HashMap::new(),
page_number_formats: HashMap::new(),
root_padding_top_mm: 0.0,
};
let pages = split_into_pages(input);
assert_eq!(pages.len(), 2);
assert_eq!(pages[0].elements.len(), 2); // el1 + pb1
assert_eq!(pages[1].elements.len(), 1); // el2
}
#[test]
fn test_header_footer_on_all_pages() {
let header = vec![make_element("hdr", 0.0, 15.0, "text")];
let footer = vec![make_element("ftr", 0.0, 10.0, "text")];
let input = PageSplitInput {
body_elements: vec![
make_element("el1", 0.0, 200.0, "text"),
make_element("el2", 200.0, 200.0, "text"),
],
page_height_mm: 297.0,
header_height_mm: 15.0,
footer_height_mm: 10.0,
header_elements: header,
footer_elements: footer,
page_width_mm: 210.0,
break_modes: HashMap::new(),
page_number_formats: HashMap::new(),
root_padding_top_mm: 0.0,
};
let pages = split_into_pages(input);
assert_eq!(pages.len(), 2);
// Her sayfada header + body + footer var
// Sayfa 1: hdr + el1 + ftr = 3
assert!(pages[0].elements.iter().any(|e| e.id == "hdr"));
assert!(pages[0].elements.iter().any(|e| e.id == "el1"));
assert!(pages[0].elements.iter().any(|e| e.id == "ftr"));
// Sayfa 2: hdr_p1 + el2 + ftr_p1 = 3
assert!(pages[1].elements.iter().any(|e| e.id == "hdr_p1"));
assert!(pages[1].elements.iter().any(|e| e.id == "el2"));
assert!(pages[1].elements.iter().any(|e| e.id == "ftr_p1"));
}
#[test]
fn test_page_numbers_resolved() {
let mut formats = HashMap::new();
formats.insert("pn".to_string(), "{current} / {total}".to_string());
let input = PageSplitInput {
body_elements: vec![
make_element("el1", 0.0, 200.0, "text"),
make_element("el2", 200.0, 200.0, "text"),
],
page_height_mm: 297.0,
header_height_mm: 15.0,
footer_height_mm: 10.0,
header_elements: vec![{
let mut el = make_element("pn", 0.0, 10.0, "page_number");
el.content = Some(ResolvedContent::Text {
value: "1 / 1".to_string(),
});
el
}],
footer_elements: vec![],
page_width_mm: 210.0,
break_modes: HashMap::new(),
page_number_formats: formats,
root_padding_top_mm: 0.0,
};
let pages = split_into_pages(input);
assert_eq!(pages.len(), 2);
// Sayfa 1: pn → "1 / 2"
let pn1 = pages[0].elements.iter().find(|e| e.id == "pn").unwrap();
if let Some(ResolvedContent::Text { value }) = &pn1.content {
assert_eq!(value, "1 / 2");
} else {
panic!("page_number content should be text");
}
// Sayfa 2: pn_p1 → "2 / 2"
let pn2 = pages[1]
.elements
.iter()
.find(|e| e.id == "pn_p1")
.unwrap();
if let Some(ResolvedContent::Text { value }) = &pn2.content {
assert_eq!(value, "2 / 2");
} else {
panic!("page_number content should be text");
}
}
#[test]
fn test_table_splits_across_pages_not_jumps() {
// Tablo wrapper container sayfa yüksekliğinden büyük olduğunda,
// komple yeni sayfaya atlamak yerine satırları sayfalara bölmeli.
//
// Senaryo: sayfa 200mm, content 200mm (header/footer yok).
// Tablonun öncesinde 50mm'lik bir eleman var.
// Tablo wrapper: y=50, h=300 (sayfaya sığmaz).
// Tablo satırları: her biri 30mm.
// Beklenen: ilk sayfa = el1 + tbl wrapper + header + ilk ~5 satır,
// ikinci sayfa = kalan satırlar.
let mut tbl_wrapper = make_element("tbl", 50.0, 300.0, "container");
tbl_wrapper.children = vec![
"tbl_header".to_string(),
"tbl_row_0".to_string(),
"tbl_row_1".to_string(),
"tbl_row_2".to_string(),
"tbl_row_3".to_string(),
"tbl_row_4".to_string(),
"tbl_row_5".to_string(),
"tbl_row_6".to_string(),
"tbl_row_7".to_string(),
"tbl_row_8".to_string(),
"tbl_row_9".to_string(),
];
let tbl_header = {
let mut el = make_element("tbl_header", 50.0, 20.0, "container");
el.children = vec!["tbl_hdr_0".to_string()];
el
};
let tbl_hdr_0 = make_element("tbl_hdr_0", 50.0, 20.0, "static_text");
// 10 satır, her biri 28mm (gap dahil), y=70'ten başlıyor
let rows: Vec<ElementLayout> = (0..10)
.flat_map(|i| {
let y = 70.0 + (i as f64) * 28.0;
let mut row = make_element(&format!("tbl_row_{}", i), y, 28.0, "container");
row.children = vec![format!("tbl_r{}c0", i)];
let cell = make_element(&format!("tbl_r{}c0", i), y, 28.0, "static_text");
vec![row, cell]
})
.collect();
let mut body_elements = vec![
make_element("el1", 0.0, 50.0, "text"),
tbl_wrapper,
tbl_header,
tbl_hdr_0,
];
body_elements.extend(rows);
let input = PageSplitInput {
body_elements,
page_height_mm: 200.0,
header_height_mm: 0.0,
footer_height_mm: 0.0,
header_elements: vec![],
footer_elements: vec![],
page_width_mm: 210.0,
break_modes: HashMap::new(),
page_number_formats: HashMap::new(),
root_padding_top_mm: 0.0,
};
let pages = split_into_pages(input);
// Tablo komple 2. sayfaya atlamamalı!
// Sayfa 1'de el1 + tablo başlangıcı olmalı
assert!(
pages[0].elements.iter().any(|e| e.id == "el1"),
"el1 should be on page 1"
);
assert!(
pages[0].elements.iter().any(|e| e.id == "tbl"),
"table wrapper should start on page 1 (not jump to page 2)"
);
assert!(
pages[0].elements.iter().any(|e| e.id == "tbl_header"),
"table header should be on page 1"
);
assert!(
pages[0].elements.iter().any(|e| e.id == "tbl_row_0"),
"first table row should be on page 1"
);
// En az 2 sayfa olmalı (tablo bölünmeli)
assert!(
pages.len() >= 2,
"table should split across at least 2 pages, got {}",
pages.len()
);
// Son satırlar sonraki sayfa(lar)da olmalı
let last_row_id = "tbl_row_9";
let last_row_page = pages
.iter()
.position(|p| p.elements.iter().any(|e| e.id == last_row_id))
.expect("last row should exist somewhere");
assert!(
last_row_page > 0,
"last table row should be on a later page"
);
}
#[test]
fn test_table_header_repeats_on_new_page() {
// Tablo satırı yeni sayfaya geçtiğinde, header tekrar edilmeli.
//
// Senaryo: sayfa 150mm, tablo y=0'dan başlıyor.
// Header: 20mm, her satır 30mm → 4 satır = 120mm + header 20mm = 140mm (1. sayfa)
// 5. satır sığmaz → 2. sayfaya geçer, header tekrar olmalı.
let mut tbl_wrapper = make_element("tbl", 0.0, 200.0, "container");
tbl_wrapper.children = vec![
"tbl_header".to_string(),
"tbl_row_0".to_string(),
"tbl_row_1".to_string(),
"tbl_row_2".to_string(),
"tbl_row_3".to_string(),
"tbl_row_4".to_string(),
"tbl_row_5".to_string(),
];
let tbl_header = {
let mut el = make_element("tbl_header", 0.0, 20.0, "container");
el.children = vec!["tbl_hdr_0".to_string()];
el
};
let tbl_hdr_0 = make_element("tbl_hdr_0", 0.0, 20.0, "static_text");
let rows: Vec<ElementLayout> = (0..6)
.flat_map(|i| {
let y = 20.0 + (i as f64) * 30.0;
let mut row = make_element(&format!("tbl_row_{}", i), y, 30.0, "container");
row.children = vec![format!("tbl_r{}c0", i)];
let cell = make_element(&format!("tbl_r{}c0", i), y, 30.0, "static_text");
vec![row, cell]
})
.collect();
let mut body_elements = vec![tbl_wrapper, tbl_header, tbl_hdr_0];
body_elements.extend(rows);
let input = PageSplitInput {
body_elements,
page_height_mm: 150.0,
header_height_mm: 0.0,
footer_height_mm: 0.0,
header_elements: vec![],
footer_elements: vec![],
page_width_mm: 210.0,
break_modes: HashMap::new(),
page_number_formats: HashMap::new(),
root_padding_top_mm: 0.0,
};
let pages = split_into_pages(input);
assert!(pages.len() >= 2, "should split into at least 2 pages");
// Sayfa 2'de tablo header'ının tekrar edilmiş kopyası olmalı
let page2_has_header = pages[1]
.elements
.iter()
.any(|e| e.id.starts_with("tbl_header"));
assert!(
page2_has_header,
"table header should be repeated on page 2. Page 2 elements: {:?}",
pages[1].elements.iter().map(|e| &e.id).collect::<Vec<_>>()
);
}
#[test]
fn test_repeated_header_no_gap_with_rows() {
// Tekrarlanan header ile ilk satır arasında boşluk olmamalı.
// Header'ın y pozisyonu yeni sayfanın başlangıcına relocate edilmeli.
//
// Senaryo: tablo y=100'de başlıyor, header 10mm, satırlar 8mm.
// Sayfa content_height=80mm.
// Satırlar: y=110, 118, 126, ... → relative_bottom kontrolü.
let mut tbl_wrapper = make_element("tbl", 100.0, 120.0, "container");
tbl_wrapper.children = vec![
"tbl_header".to_string(),
"tbl_row_0".to_string(),
"tbl_row_1".to_string(),
"tbl_row_2".to_string(),
"tbl_row_3".to_string(),
"tbl_row_4".to_string(),
"tbl_row_5".to_string(),
"tbl_row_6".to_string(),
"tbl_row_7".to_string(),
"tbl_row_8".to_string(),
"tbl_row_9".to_string(),
];
let tbl_header = {
let mut el = make_element("tbl_header", 100.0, 10.0, "container");
el.children = vec!["tbl_hdr_0".to_string()];
el
};
let tbl_hdr_0 = make_element("tbl_hdr_0", 100.0, 10.0, "static_text");
let rows: Vec<ElementLayout> = (0..10)
.flat_map(|i| {
let y = 110.0 + (i as f64) * 12.0;
let mut row = make_element(&format!("tbl_row_{}", i), y, 12.0, "container");
row.children = vec![format!("tbl_r{}c0", i)];
let cell = make_element(&format!("tbl_r{}c0", i), y, 12.0, "static_text");
vec![row, cell]
})
.collect();
let mut body_elements = vec![
make_element("el1", 0.0, 50.0, "text"), // 50mm metin
make_element("el2", 50.0, 50.0, "text"), // 50mm metin (toplam 100mm)
tbl_wrapper,
tbl_header,
tbl_hdr_0,
];
body_elements.extend(rows);
// content_height = 200 - 15 - 10 = 175
let doc_header = vec![make_element("doc_hdr", 0.0, 15.0, "text")];
let doc_footer = vec![make_element("doc_ftr", 0.0, 10.0, "text")];
let input = PageSplitInput {
body_elements,
page_height_mm: 200.0,
header_height_mm: 15.0,
footer_height_mm: 10.0,
header_elements: doc_header,
footer_elements: doc_footer,
page_width_mm: 210.0,
break_modes: HashMap::new(),
page_number_formats: HashMap::new(),
root_padding_top_mm: 5.0,
};
let pages = split_into_pages(input);
assert!(pages.len() >= 2, "should have at least 2 pages");
// Sayfa 2'deki elemanlar
let page2 = &pages[1];
// Tekrarlanan header'ı bul
let repeated_header = page2
.elements
.iter()
.find(|e| e.id.starts_with("tbl_header") && e.id != "tbl_header")
.expect("repeated table header should exist on page 2");
// Header'dan sonraki ilk satırı bul
let first_row_on_page2 = page2
.elements
.iter()
.find(|e| e.id.starts_with("tbl_row_"))
.expect("at least one table row should be on page 2");
// Header'ın alt kenarı ile satırın üst kenarı arasında boşluk olmamalı (veya çok az)
let header_bottom = repeated_header.y_mm + repeated_header.height_mm;
let row_top = first_row_on_page2.y_mm;
let gap = (row_top - header_bottom).abs();
assert!(
gap < 1.0,
"gap between repeated header (bottom={:.1}) and first row (top={:.1}) should be < 1mm, got {:.1}mm",
header_bottom,
row_top,
gap
);
// Header y değeri negatif olmamalı
assert!(
repeated_header.y_mm >= 0.0,
"repeated header y should be non-negative, got {:.1}",
repeated_header.y_mm
);
// Header, document header'dan sonra gelmeli
assert!(
repeated_header.y_mm >= 15.0,
"repeated header should be after doc header (15mm), got {:.1}",
repeated_header.y_mm
);
}
}

View File

@@ -208,6 +208,12 @@ fn render_element(
render_container_bg(surface, x, y, w, h, &el.style); render_container_bg(surface, x, y, w, h, &el.style);
} }
// Shape background/border (same visual as container bg but as leaf)
if el.element_type == "shape" {
render_shape(surface, x, y, w, h, &el.style, &el.content);
return;
}
let Some(ref content) = el.content else { let Some(ref content) = el.content else {
return; return;
}; };
@@ -230,11 +236,159 @@ fn render_element(
// Tablolar expand edilerek container + text olarak render edilir. // Tablolar expand edilerek container + text olarak render edilir.
// Bu branch'e normalde düşmemeli. // Bu branch'e normalde düşmemeli.
} }
ResolvedContent::Shape { .. } => {
// Shape zaten yukarıda render_shape() ile çizildi, buraya düşmemeli
}
ResolvedContent::Checkbox { checked } => {
render_checkbox(surface, x, y, w, h, *checked, &el.style);
}
ResolvedContent::Barcode { format, value } => { ResolvedContent::Barcode { format, value } => {
render_barcode(surface, x, y, w, h, format, value, &el.style, font_data); render_barcode(surface, x, y, w, h, format, value, &el.style, font_data);
} }
ResolvedContent::RichText { spans } => {
render_rich_text(surface, x, y, w, h, spans, &el.style, fonts, measurer);
} }
} }
}
fn render_shape(
surface: &mut krilla::surface::Surface<'_>,
x: f32,
y: f32,
w: f32,
h: f32,
style: &ResolvedStyle,
content: &Option<ResolvedContent>,
) {
let has_bg = style.background_color.is_some();
let has_border = style.border_color.is_some() && style.border_width.unwrap_or(0.0) > 0.0;
if !has_bg && !has_border {
return;
}
if let Some(ref bg) = style.background_color {
surface.set_fill(Some(fill_from_color(parse_color(bg))));
} else {
surface.set_fill(None);
}
if has_border {
let border_color = parse_color(style.border_color.as_deref().unwrap_or("#000000"));
let border_width = mm(style.border_width.unwrap_or(0.5));
surface.set_stroke(Some(Stroke {
paint: border_color.into(),
width: border_width,
opacity: NormalizedF32::ONE,
..Default::default()
}));
} else {
surface.set_stroke(None);
}
let shape_type = match content {
Some(ResolvedContent::Shape { shape_type }) => shape_type.as_str(),
_ => "rectangle",
};
let path = match shape_type {
"ellipse" => {
let mut pb = PathBuilder::new();
let cx = x + w / 2.0;
let cy = y + h / 2.0;
let rx = w / 2.0;
let ry = h / 2.0;
// Approximate ellipse with 4 cubic bezier curves
let kx = rx * 0.5522848;
let ky = ry * 0.5522848;
pb.move_to(cx, cy - ry);
pb.cubic_to(cx + kx, cy - ry, cx + rx, cy - ky, cx + rx, cy);
pb.cubic_to(cx + rx, cy + ky, cx + kx, cy + ry, cx, cy + ry);
pb.cubic_to(cx - kx, cy + ry, cx - rx, cy + ky, cx - rx, cy);
pb.cubic_to(cx - rx, cy - ky, cx - kx, cy - ry, cx, cy - ry);
pb.close();
pb.finish()
}
_ => {
// rectangle / rounded_rectangle
let mut pb = PathBuilder::new();
if let Some(rect) = krilla::geom::Rect::from_xywh(x, y, w, h) {
pb.push_rect(rect);
}
pb.finish()
}
};
if let Some(p) = path {
surface.draw_path(&p);
}
surface.set_fill(None);
surface.set_stroke(None);
}
fn render_checkbox(
surface: &mut krilla::surface::Surface<'_>,
x: f32,
y: f32,
w: f32,
h: f32,
checked: bool,
style: &ResolvedStyle,
) {
let border_color = parse_color(style.border_color.as_deref().unwrap_or("#333333"));
let border_width = mm(style.border_width.unwrap_or(0.3));
// Draw box outline
surface.set_fill(None);
surface.set_stroke(Some(Stroke {
paint: border_color.into(),
width: border_width,
opacity: NormalizedF32::ONE,
..Default::default()
}));
let rect_path = {
let mut pb = PathBuilder::new();
if let Some(rect) = krilla::geom::Rect::from_xywh(x, y, w, h) {
pb.push_rect(rect);
}
pb.finish()
};
if let Some(p) = rect_path {
surface.draw_path(&p);
}
// Draw checkmark if checked
if checked {
let check_color = parse_color(style.color.as_deref().unwrap_or("#000000"));
let stroke_w = w.min(h) * 0.12;
surface.set_fill(None);
surface.set_stroke(Some(Stroke {
paint: check_color.into(),
width: stroke_w,
opacity: NormalizedF32::ONE,
..Default::default()
}));
// Checkmark: two lines forming a "✓"
let check_path = {
let mut pb = PathBuilder::new();
let mx = w * 0.2;
let my = h * 0.5;
pb.move_to(x + mx, y + my);
pb.line_to(x + w * 0.4, y + h * 0.75);
pb.line_to(x + w * 0.8, y + h * 0.25);
pb.finish()
};
if let Some(p) = check_path {
surface.draw_path(&p);
}
}
surface.set_fill(None);
surface.set_stroke(None);
}
fn render_container_bg( fn render_container_bg(
surface: &mut krilla::surface::Surface<'_>, surface: &mut krilla::surface::Surface<'_>,
@@ -354,6 +508,92 @@ fn render_text(
); );
} }
fn render_rich_text(
surface: &mut krilla::surface::Surface<'_>,
x: f32,
y: f32,
w: f32,
_h: f32,
spans: &[crate::ResolvedRichSpan],
style: &ResolvedStyle,
fonts: &FontCollection,
measurer: &mut TextMeasurer,
) {
if spans.is_empty() {
return;
}
// Varsayılan stil
let default_font_size = style.font_size.unwrap_or(11.0) as f32;
let default_color = style.color.as_deref().unwrap_or("#000000");
let default_weight = style.font_weight.as_deref();
let default_family = style.font_family.as_deref();
// Hizalama için toplam genişliği hesapla
let total_width = {
let mut tw = 0.0f32;
for span in spans {
let fs = span.font_size.map(|f| f as f32).unwrap_or(default_font_size);
let fw = span.font_weight.as_deref().or(default_weight);
let ff = span.font_family.as_deref().or(default_family);
let (sw, _) = measurer.measure(&span.text, ff, fs, fw, None);
tw += sw;
}
tw
};
let line_start_x = match style.text_align.as_deref() {
Some("center") => x + (w - total_width) / 2.0,
Some("right") => x + w - total_width,
_ => x,
};
// Max font size for baseline
let max_font_size = spans
.iter()
.map(|s| s.font_size.map(|f| f as f32).unwrap_or(default_font_size))
.fold(0.0f32, f32::max);
let baseline_y = y + max_font_size * 0.8;
let mut cursor_x = line_start_x;
for span in spans {
if span.text.is_empty() {
continue;
}
let font_size = span.font_size.map(|f| f as f32).unwrap_or(default_font_size);
let color_str = span.color.as_deref().unwrap_or(default_color);
let weight = span.font_weight.as_deref().or(default_weight);
let family = span.font_family.as_deref().or(default_family);
let color = parse_color(color_str);
let Some(font) = fonts.get(family, weight) else {
continue;
};
surface.set_fill(Some(fill_from_color(color)));
surface.set_stroke(None);
// Span'ın baseline'ı — farklı font boyutları için ayarla
let span_baseline = baseline_y + (max_font_size - font_size) * 0.2;
surface.draw_text(
Point::from_xy(cursor_x, span_baseline),
font.clone(),
font_size,
&span.text,
false,
TextDirection::Auto,
);
// Sonraki span'ın x pozisyonunu hesapla
let (span_width, _) = measurer.measure(&span.text, family, font_size, weight, None);
cursor_x += span_width;
}
}
fn render_line( fn render_line(
surface: &mut krilla::surface::Surface<'_>, surface: &mut krilla::surface::Surface<'_>,
x: f32, x: f32,
@@ -595,6 +835,8 @@ mod tests {
name: "Test".to_string(), name: "Test".to_string(),
page: PageSettings { width: 210.0, height: 297.0 }, page: PageSettings { width: 210.0, height: 297.0 },
fonts: vec!["Noto Sans".to_string()], fonts: vec!["Noto Sans".to_string()],
header: None,
footer: None,
root: ContainerElement { root: ContainerElement {
id: "root".to_string(), id: "root".to_string(),
position: PositionMode::Flow, position: PositionMode::Flow,
@@ -609,6 +851,7 @@ mod tests {
align: "stretch".to_string(), align: "stretch".to_string(),
justify: "start".to_string(), justify: "start".to_string(),
style: ContainerStyle::default(), style: ContainerStyle::default(),
break_inside: "auto".to_string(),
children: vec![ children: vec![
TemplateElement::StaticText(StaticTextElement { TemplateElement::StaticText(StaticTextElement {
id: "title".to_string(), id: "title".to_string(),

View File

@@ -326,6 +326,7 @@ mod tests {
justify: "space-between".to_string(), justify: "space-between".to_string(),
style: ContainerStyle::default(), style: ContainerStyle::default(),
children: vec![], children: vec![],
break_inside: "auto".to_string(),
}; };
let style = container_to_style(&el, None); let style = container_to_style(&el, None);
assert_eq!(style.flex_direction, FlexDirection::Row); assert_eq!(style.flex_direction, FlexDirection::Row);
@@ -346,6 +347,7 @@ mod tests {
justify: "start".to_string(), justify: "start".to_string(),
style: ContainerStyle::default(), style: ContainerStyle::default(),
children: vec![], children: vec![],
break_inside: "auto".to_string(),
}; };
let style = container_to_style(&el, None); let style = container_to_style(&el, None);
assert_eq!(style.position, Position::Absolute); assert_eq!(style.position, Position::Absolute);

View File

@@ -73,6 +73,7 @@ pub fn expand_table(
..Default::default() ..Default::default()
}, },
children: header_cells, children: header_cells,
break_inside: "auto".to_string(),
})); }));
// Header altına ayırıcı çizgi // Header altına ayırıcı çizgi
@@ -163,6 +164,7 @@ pub fn expand_table(
..Default::default() ..Default::default()
}, },
children: cells, children: cells,
break_inside: "auto".to_string(),
})); }));
} }
@@ -187,6 +189,7 @@ pub fn expand_table(
..Default::default() ..Default::default()
}, },
children, children,
break_inside: "auto".to_string(),
} }
} }
@@ -219,6 +222,7 @@ mod tests {
data_source: ArrayBinding { path: "items".to_string() }, data_source: ArrayBinding { path: "items".to_string() },
columns, columns,
style: TableStyle::default(), style: TableStyle::default(),
repeat_header: Some(true),
} }
} }
@@ -230,6 +234,8 @@ mod tests {
tables, tables,
barcodes: HashMap::new(), barcodes: HashMap::new(),
images: HashMap::new(), images: HashMap::new(),
page_number_formats: HashMap::new(),
rich_texts: HashMap::new(),
} }
} }

View File

@@ -4,6 +4,15 @@ use std::hash::Hash;
use crate::FontData; use crate::FontData;
use cosmic_text::{Attrs, Buffer, Family, FontSystem, Metrics, Shaping, Weight}; use cosmic_text::{Attrs, Buffer, Family, FontSystem, Metrics, Shaping, Weight};
/// Rich text span — ölçüm için gerekli bilgiler
#[derive(Clone)]
pub struct RichSpanMeasure {
pub text: String,
pub font_family: Option<String>,
pub font_size_pt: f32,
pub font_weight: Option<String>,
}
/// Opak text ölçüm cache'i. `TextMeasurer` call'ları arasında taşınarak /// Opak text ölçüm cache'i. `TextMeasurer` call'ları arasında taşınarak
/// aynı parametrelerle yapılan ölçümlerin yeniden hesaplanmasını önler. /// aynı parametrelerle yapılan ölçümlerin yeniden hesaplanmasını önler.
#[derive(Default)] #[derive(Default)]
@@ -172,6 +181,83 @@ impl TextMeasurer {
(width_pt, height_pt) (width_pt, height_pt)
} }
/// Rich text ölç — birden fazla span, her biri farklı font/boyut/kalınlık.
/// cosmic-text set_rich_text() ile attributed text ölçümü yapar.
pub fn measure_rich_text(
&mut self,
spans: &[RichSpanMeasure],
available_width_pt: Option<f32>,
) -> (f32, f32) {
if spans.is_empty() {
return (0.0, 0.0);
}
// En büyük font boyutunu bul — line height buna göre belirlenir
let max_font_size_pt = spans
.iter()
.map(|s| s.font_size_pt)
.fold(0.0f32, f32::max);
if max_font_size_pt <= 0.0 {
return (0.0, 0.0);
}
let max_font_size_px = max_font_size_pt * PT_TO_PX;
let line_height_px = max_font_size_px * 1.2;
let metrics = Metrics::new(max_font_size_px, line_height_px);
let mut buffer = Buffer::new(&mut self.font_system, metrics);
let width_px = available_width_pt.map(|w| w * PT_TO_PX);
buffer.set_size(&mut self.font_system, width_px, None);
// Her span için (text, Attrs) pair oluştur
let rich_spans: Vec<(&str, Attrs)> = spans
.iter()
.map(|span| {
let weight = match span.font_weight.as_deref() {
Some("bold") => Weight::BOLD,
_ => Weight::NORMAL,
};
let family_name = span.font_family.as_deref().unwrap_or("Noto Sans");
let font_size_px = span.font_size_pt * PT_TO_PX;
let attrs = Attrs::new()
.family(Family::Name(family_name))
.weight(weight)
.metrics(Metrics::new(font_size_px, font_size_px * 1.2));
(span.text.as_str(), attrs)
})
.collect();
buffer.set_rich_text(
&mut self.font_system,
rich_spans,
&Attrs::new(),
Shaping::Advanced,
None,
);
buffer.shape_until_scroll(&mut self.font_system, false);
let mut max_width: f32 = 0.0;
let mut total_height: f32 = 0.0;
for run in buffer.layout_runs() {
if run.line_w > max_width {
max_width = run.line_w;
}
total_height = run.line_top + line_height_px;
}
if total_height == 0.0 {
total_height = line_height_px;
}
let width_pt = max_width / PT_TO_PX + 0.5;
let height_pt = total_height / PT_TO_PX;
(width_pt, height_pt)
}
} }
#[cfg(test)] #[cfg(test)]

View File

@@ -7,7 +7,7 @@ use crate::data_resolve::ResolvedData;
use crate::sizing::{self, mm_to_pt, pt_to_mm}; use crate::sizing::{self, mm_to_pt, pt_to_mm};
use crate::table_layout; use crate::table_layout;
use crate::text_measure::TextMeasurer; use crate::text_measure::TextMeasurer;
use crate::{ElementLayout, LayoutResult, PageLayout, ResolvedContent, ResolvedStyle}; use crate::{ElementLayout, LayoutResult, ResolvedContent, ResolvedStyle};
/// Taffy node ile dreport element arasındaki mapping /// Taffy node ile dreport element arasındaki mapping
struct NodeInfo { struct NodeInfo {
@@ -24,6 +24,8 @@ struct MeasureContext {
font_family: Option<String>, font_family: Option<String>,
font_size_pt: f32, font_size_pt: f32,
font_weight: Option<String>, font_weight: Option<String>,
/// Rich text span'ları (varsa text/font_family/font_size_pt/font_weight yok sayılır)
rich_spans: Option<Vec<crate::text_measure::RichSpanMeasure>>,
} }
/// Ana layout hesaplama fonksiyonu. /// Ana layout hesaplama fonksiyonu.
@@ -32,42 +34,53 @@ pub fn compute(
resolved: &ResolvedData, resolved: &ResolvedData,
measurer: &mut TextMeasurer, measurer: &mut TextMeasurer,
) -> LayoutResult { ) -> LayoutResult {
let page_w_pt = mm_to_pt(template.page.width);
// --- 1. Header layout (varsa) ---
let (header_elements, header_height_mm) = if let Some(ref header) = template.header {
compute_section(header, page_w_pt, resolved, measurer)
} else {
(vec![], 0.0)
};
// --- 2. Footer layout (varsa) ---
let (footer_elements, footer_height_mm) = if let Some(ref footer) = template.footer {
compute_section(footer, page_w_pt, resolved, measurer)
} else {
(vec![], 0.0)
};
// --- 3. Body layout — SINIRSIZ YÜKSEKLİK ---
let mut taffy = TaffyTree::<MeasureContext>::new(); let mut taffy = TaffyTree::<MeasureContext>::new();
taffy.disable_rounding(); taffy.disable_rounding();
let mut node_map: HashMap<NodeId, NodeInfo> = HashMap::new(); let mut node_map: HashMap<NodeId, NodeInfo> = HashMap::new();
// Kök sayfa node'u: sabit boyutlu, column flex container
let page_w_pt = mm_to_pt(template.page.width);
let page_h_pt = mm_to_pt(template.page.height);
// Root container'ı build et
let root_node = build_container( let root_node = build_container(
&template.root, &template.root,
&mut taffy, &mut taffy,
&mut node_map, &mut node_map,
resolved, resolved,
None, // root'un parent direction'ı yok None,
); );
// Sayfa wrapper: sabit boyutlu flex container, root'u stretch eder // Sayfa wrapper: sayfa genişliğinde ama yükseklik sınırsız (auto)
let page_style = Style { let page_style = Style {
display: Display::Flex, display: Display::Flex,
flex_direction: FlexDirection::Column, flex_direction: FlexDirection::Column,
size: Size { size: Size {
width: Dimension::length(page_w_pt), width: Dimension::length(page_w_pt),
height: Dimension::length(page_h_pt), height: Dimension::auto(),
}, },
..Default::default() ..Default::default()
}; };
let page_node = taffy.new_with_children(page_style, &[root_node]).unwrap(); let page_node = taffy.new_with_children(page_style, &[root_node]).unwrap();
// Layout hesapla
taffy taffy
.compute_layout_with_measure( .compute_layout_with_measure(
page_node, page_node,
Size { Size {
width: AvailableSpace::Definite(page_w_pt), width: AvailableSpace::Definite(page_w_pt),
height: AvailableSpace::Definite(page_h_pt), height: AvailableSpace::MaxContent,
}, },
|known_dimensions, available_space, _node_id, context, _style| { |known_dimensions, available_space, _node_id, context, _style| {
measure_leaf(known_dimensions, available_space, context, measurer) measure_leaf(known_dimensions, available_space, context, measurer)
@@ -75,16 +88,90 @@ pub fn compute(
) )
.unwrap(); .unwrap();
// Layout sonuçlarını topla let body_elements = collect_layout(&taffy, root_node, &node_map, 0.0, 0.0);
let elements = collect_layout(&taffy, root_node, &node_map, 0.0, 0.0);
LayoutResult { // --- 4. Container break modlarını topla ---
pages: vec![PageLayout { let break_modes = collect_break_modes(&template.root);
page_index: 0,
width_mm: template.page.width, // --- 5. Sayfalara böl ---
height_mm: template.page.height, let input = crate::page_break::PageSplitInput {
elements, body_elements,
}], page_height_mm: template.page.height,
header_height_mm,
footer_height_mm,
header_elements,
footer_elements,
page_width_mm: template.page.width,
break_modes,
page_number_formats: resolved.page_number_formats.clone(),
root_padding_top_mm: template.root.padding.top,
};
let pages = crate::page_break::split_into_pages(input);
LayoutResult { pages }
}
/// Header veya footer gibi bağımsız bir container section'ı hesapla.
/// Sayfa genişliğinde, auto yükseklikte layout yapar.
fn compute_section(
container: &ContainerElement,
page_w_pt: f32,
resolved: &ResolvedData,
measurer: &mut TextMeasurer,
) -> (Vec<ElementLayout>, f64) {
let mut taffy = TaffyTree::<MeasureContext>::new();
taffy.disable_rounding();
let mut node_map: HashMap<NodeId, NodeInfo> = HashMap::new();
let section_node = build_container(container, &mut taffy, &mut node_map, resolved, None);
let wrapper_style = Style {
display: Display::Flex,
flex_direction: FlexDirection::Column,
size: Size {
width: Dimension::length(page_w_pt),
height: Dimension::auto(),
},
..Default::default()
};
let wrapper_node = taffy.new_with_children(wrapper_style, &[section_node]).unwrap();
taffy
.compute_layout_with_measure(
wrapper_node,
Size {
width: AvailableSpace::Definite(page_w_pt),
height: AvailableSpace::MaxContent,
},
|known_dimensions, available_space, _node_id, context, _style| {
measure_leaf(known_dimensions, available_space, context, measurer)
},
)
.unwrap();
let elements = collect_layout(&taffy, section_node, &node_map, 0.0, 0.0);
// Section yüksekliği
let section_layout = taffy.layout(section_node).unwrap();
let height_mm = pt_to_mm(section_layout.size.height);
(elements, height_mm)
}
/// Template ağacındaki tüm container'ların break_inside modlarını topla.
fn collect_break_modes(root: &ContainerElement) -> HashMap<String, String> {
let mut modes = HashMap::new();
collect_break_modes_recursive(&TemplateElement::Container(root.clone()), &mut modes);
modes
}
fn collect_break_modes_recursive(el: &TemplateElement, modes: &mut HashMap<String, String>) {
if let TemplateElement::Container(c) = el {
modes.insert(c.id.clone(), c.break_inside.clone());
for child in &c.children {
collect_break_modes_recursive(child, modes);
}
} }
} }
@@ -190,6 +277,42 @@ fn build_element(
parent_direction, parent_direction,
) )
} }
TemplateElement::CurrentDate(e) => {
let text = resolved
.texts
.get(&e.id)
.map(|s| s.as_str())
.unwrap_or("");
build_text_leaf(
taffy,
node_map,
&e.id,
"current_date",
text,
&e.style,
&e.size,
&e.position,
parent_direction,
)
}
TemplateElement::CalculatedText(e) => {
let text = resolved
.texts
.get(&e.id)
.map(|s| s.as_str())
.unwrap_or("");
build_text_leaf(
taffy,
node_map,
&e.id,
"calculated_text",
text,
&e.style,
&e.size,
&e.position,
parent_direction,
)
}
TemplateElement::Line(e) => { TemplateElement::Line(e) => {
let stroke_w = e.style.stroke_width.unwrap_or(0.5); let stroke_w = e.style.stroke_width.unwrap_or(0.5);
let style = sizing::leaf_style(&e.size, &e.position, parent_direction); let style = sizing::leaf_style(&e.size, &e.position, parent_direction);
@@ -293,6 +416,144 @@ fn build_element(
parent_direction, parent_direction,
) )
} }
TemplateElement::Shape(e) => {
let style = sizing::leaf_style(&e.size, &e.position, parent_direction);
let node = taffy.new_leaf(style).unwrap();
node_map.insert(
node,
NodeInfo {
element_id: e.id.clone(),
element_type: "shape".to_string(),
content: Some(ResolvedContent::Shape {
shape_type: e.shape_type.clone(),
}),
style: ResolvedStyle {
background_color: e.style.background_color.clone(),
border_color: e.style.border_color.clone(),
border_width: e.style.border_width,
border_radius: e.style.border_radius,
..Default::default()
},
children_ids: vec![],
},
);
node
}
TemplateElement::Checkbox(e) => {
let checked_str = resolved.texts.get(&e.id).map(|s| s.as_str()).unwrap_or("false");
let checked = checked_str == "true";
let box_size_mm = e.style.size.unwrap_or(4.0);
let style = sizing::leaf_style(&e.size, &e.position, parent_direction);
// Auto size → square based on style.size
let mut leaf_style = style;
if matches!(e.size.width, SizeValue::Auto) {
leaf_style.size.width = Dimension::length(mm_to_pt(box_size_mm));
}
if matches!(e.size.height, SizeValue::Auto) {
leaf_style.size.height = Dimension::length(mm_to_pt(box_size_mm));
}
let node = taffy.new_leaf(leaf_style).unwrap();
node_map.insert(
node,
NodeInfo {
element_id: e.id.clone(),
element_type: "checkbox".to_string(),
content: Some(ResolvedContent::Checkbox { checked }),
style: ResolvedStyle {
color: e.style.check_color.clone(),
border_color: e.style.border_color.clone(),
border_width: e.style.border_width,
..Default::default()
},
children_ids: vec![],
},
);
node
}
TemplateElement::RichText(e) => {
let spans = resolved.rich_texts.get(&e.id).cloned().unwrap_or_default();
let rich_span_measures: Vec<crate::text_measure::RichSpanMeasure> = spans
.iter()
.map(|s| crate::text_measure::RichSpanMeasure {
text: s.text.clone(),
font_family: s.font_family.clone(),
font_size_pt: s.font_size.unwrap_or(11.0) as f32,
font_weight: s.font_weight.clone(),
})
.collect();
let max_font_size_pt = rich_span_measures
.iter()
.map(|s| s.font_size_pt)
.fold(11.0f32, f32::max);
let style = sizing::leaf_style(&e.size, &e.position, parent_direction);
let context = MeasureContext {
text: String::new(),
font_family: None,
font_size_pt: max_font_size_pt,
font_weight: None,
rich_spans: Some(rich_span_measures),
};
let node = taffy.new_leaf_with_context(style, context).unwrap();
// ResolvedContent::RichText span'ları oluştur
let resolved_spans: Vec<crate::ResolvedRichSpan> = spans
.iter()
.map(|s| crate::ResolvedRichSpan {
text: s.text.clone(),
font_size: s.font_size,
font_weight: s.font_weight.clone(),
font_family: s.font_family.clone(),
color: s.color.clone(),
})
.collect();
node_map.insert(
node,
NodeInfo {
element_id: e.id.clone(),
element_type: "rich_text".to_string(),
content: Some(ResolvedContent::RichText { spans: resolved_spans }),
style: ResolvedStyle {
font_size: e.style.font_size,
font_weight: e.style.font_weight.clone(),
font_family: e.style.font_family.clone(),
color: e.style.color.clone(),
text_align: e.style.align.clone(),
..Default::default()
},
children_ids: vec![],
},
);
node
}
TemplateElement::PageBreak(e) => {
// Küçük yükseklik — editörde görünür olması için (0.5mm ≈ 1.4pt)
let style = Style {
size: Size {
width: Dimension::auto(),
height: Dimension::length(mm_to_pt(0.5)),
},
..Default::default()
};
let node = taffy.new_leaf(style).unwrap();
node_map.insert(
node,
NodeInfo {
element_id: e.id.clone(),
element_type: "page_break".to_string(),
content: None,
style: ResolvedStyle::default(),
children_ids: vec![],
},
);
node
}
} }
} }
@@ -331,6 +592,7 @@ fn build_text_leaf(
font_family: text_style.font_family.clone(), font_family: text_style.font_family.clone(),
font_size_pt, font_size_pt,
font_weight: text_style.font_weight.clone(), font_weight: text_style.font_weight.clone(),
rich_spans: None,
}; };
let node = taffy.new_leaf_with_context(style, context).unwrap(); let node = taffy.new_leaf_with_context(style, context).unwrap();
@@ -387,13 +649,17 @@ fn measure_leaf(
AvailableSpace::MinContent => Some(0.0), AvailableSpace::MinContent => Some(0.0),
}; };
let (measured_w, measured_h) = measurer.measure( let (measured_w, measured_h) = if let Some(ref rich_spans) = ctx.rich_spans {
measurer.measure_rich_text(rich_spans, available_width)
} else {
measurer.measure(
&ctx.text, &ctx.text,
ctx.font_family.as_deref(), ctx.font_family.as_deref(),
ctx.font_size_pt, ctx.font_size_pt,
ctx.font_weight.as_deref(), ctx.font_weight.as_deref(),
available_width, available_width,
); )
};
Size { Size {
width: known_dimensions.width.unwrap_or(measured_w), width: known_dimensions.width.unwrap_or(measured_w),
@@ -458,6 +724,8 @@ mod tests {
height: 297.0, height: 297.0,
}, },
fonts: vec!["Noto Sans".to_string()], fonts: vec!["Noto Sans".to_string()],
header: None,
footer: None,
root: ContainerElement { root: ContainerElement {
id: "root".to_string(), id: "root".to_string(),
position: PositionMode::Flow, position: PositionMode::Flow,
@@ -480,6 +748,7 @@ mod tests {
align: "stretch".to_string(), align: "stretch".to_string(),
justify: "start".to_string(), justify: "start".to_string(),
style: ContainerStyle::default(), style: ContainerStyle::default(),
break_inside: "auto".to_string(),
children: vec![ children: vec![
TemplateElement::StaticText(StaticTextElement { TemplateElement::StaticText(StaticTextElement {
id: "title".to_string(), id: "title".to_string(),
@@ -598,6 +867,8 @@ mod tests {
height: 297.0, height: 297.0,
}, },
fonts: vec![], fonts: vec![],
header: None,
footer: None,
root: ContainerElement { root: ContainerElement {
id: "root".to_string(), id: "root".to_string(),
position: PositionMode::Flow, position: PositionMode::Flow,
@@ -620,6 +891,7 @@ mod tests {
align: "stretch".to_string(), align: "stretch".to_string(),
justify: "start".to_string(), justify: "start".to_string(),
style: ContainerStyle::default(), style: ContainerStyle::default(),
break_inside: "auto".to_string(),
children: vec![TemplateElement::Container(ContainerElement { children: vec![TemplateElement::Container(ContainerElement {
id: "row".to_string(), id: "row".to_string(),
position: PositionMode::Flow, position: PositionMode::Flow,
@@ -642,6 +914,7 @@ mod tests {
align: "start".to_string(), align: "start".to_string(),
justify: "start".to_string(), justify: "start".to_string(),
style: ContainerStyle::default(), style: ContainerStyle::default(),
break_inside: "auto".to_string(),
children: vec![ children: vec![
TemplateElement::StaticText(StaticTextElement { TemplateElement::StaticText(StaticTextElement {
id: "left".to_string(), id: "left".to_string(),
@@ -721,6 +994,8 @@ mod tests {
height: 297.0, height: 297.0,
}, },
fonts: vec![], fonts: vec![],
header: None,
footer: None,
root: ContainerElement { root: ContainerElement {
id: "root".to_string(), id: "root".to_string(),
position: PositionMode::Flow, position: PositionMode::Flow,
@@ -743,6 +1018,7 @@ mod tests {
align: "stretch".to_string(), align: "stretch".to_string(),
justify: "start".to_string(), justify: "start".to_string(),
style: ContainerStyle::default(), style: ContainerStyle::default(),
break_inside: "auto".to_string(),
children: vec![TemplateElement::StaticText(StaticTextElement { children: vec![TemplateElement::StaticText(StaticTextElement {
id: "abs_text".to_string(), id: "abs_text".to_string(),
position: PositionMode::Absolute { x: 50.0, y: 80.0 }, position: PositionMode::Absolute { x: 50.0, y: 80.0 },
@@ -806,6 +1082,8 @@ mod tests {
height: 297.0, height: 297.0,
}, },
fonts: vec!["Noto Sans".to_string()], fonts: vec!["Noto Sans".to_string()],
header: None,
footer: None,
root: ContainerElement { root: ContainerElement {
id: "root".to_string(), id: "root".to_string(),
position: PositionMode::Flow, position: PositionMode::Flow,
@@ -821,6 +1099,7 @@ mod tests {
align: "stretch".to_string(), align: "stretch".to_string(),
justify: "start".to_string(), justify: "start".to_string(),
style: ContainerStyle::default(), style: ContainerStyle::default(),
break_inside: "auto".to_string(),
children: vec![ children: vec![
// Header row // Header row
TemplateElement::Container(ContainerElement { TemplateElement::Container(ContainerElement {
@@ -833,6 +1112,7 @@ mod tests {
align: "start".to_string(), align: "start".to_string(),
justify: "space-between".to_string(), justify: "space-between".to_string(),
style: ContainerStyle::default(), style: ContainerStyle::default(),
break_inside: "auto".to_string(),
children: vec![ children: vec![
// Sol: firma bilgileri // Sol: firma bilgileri
TemplateElement::Container(ContainerElement { TemplateElement::Container(ContainerElement {
@@ -845,6 +1125,7 @@ mod tests {
align: "start".to_string(), align: "start".to_string(),
justify: "start".to_string(), justify: "start".to_string(),
style: ContainerStyle::default(), style: ContainerStyle::default(),
break_inside: "auto".to_string(),
children: vec![ children: vec![
TemplateElement::StaticText(StaticTextElement { TemplateElement::StaticText(StaticTextElement {
id: "el_firma_unvan".to_string(), id: "el_firma_unvan".to_string(),
@@ -921,6 +1202,7 @@ mod tests {
align: "end".to_string(), align: "end".to_string(),
justify: "start".to_string(), justify: "start".to_string(),
style: ContainerStyle::default(), style: ContainerStyle::default(),
break_inside: "auto".to_string(),
children: vec![ children: vec![
TemplateElement::StaticText(StaticTextElement { TemplateElement::StaticText(StaticTextElement {
id: "el_fatura_baslik".to_string(), id: "el_fatura_baslik".to_string(),

View File

@@ -49,6 +49,8 @@ fn simple_template() -> Template {
height: 297.0, height: 297.0,
}, },
fonts: vec!["Noto Sans".to_string()], fonts: vec!["Noto Sans".to_string()],
header: None,
footer: None,
root: ContainerElement { root: ContainerElement {
id: "root".to_string(), id: "root".to_string(),
position: PositionMode::Flow, position: PositionMode::Flow,
@@ -64,6 +66,7 @@ fn simple_template() -> Template {
align: "stretch".to_string(), align: "stretch".to_string(),
justify: "start".to_string(), justify: "start".to_string(),
style: ContainerStyle::default(), style: ContainerStyle::default(),
break_inside: "auto".to_string(),
children: vec![TemplateElement::StaticText(StaticTextElement { children: vec![TemplateElement::StaticText(StaticTextElement {
id: "title".to_string(), id: "title".to_string(),
position: PositionMode::Flow, position: PositionMode::Flow,
@@ -188,6 +191,8 @@ fn test_compute_layout_with_data_binding() {
height: 297.0, height: 297.0,
}, },
fonts: vec!["Noto Sans".to_string()], fonts: vec!["Noto Sans".to_string()],
header: None,
footer: None,
root: ContainerElement { root: ContainerElement {
id: "root".to_string(), id: "root".to_string(),
position: PositionMode::Flow, position: PositionMode::Flow,
@@ -203,6 +208,7 @@ fn test_compute_layout_with_data_binding() {
align: "stretch".to_string(), align: "stretch".to_string(),
justify: "start".to_string(), justify: "start".to_string(),
style: ContainerStyle::default(), style: ContainerStyle::default(),
break_inside: "auto".to_string(),
children: vec![TemplateElement::Text(TextElement { children: vec![TemplateElement::Text(TextElement {
id: "bound_text".to_string(), id: "bound_text".to_string(),
position: PositionMode::Flow, position: PositionMode::Flow,
@@ -254,6 +260,8 @@ fn test_compute_layout_multiple_children_ordering() {
height: 297.0, height: 297.0,
}, },
fonts: vec!["Noto Sans".to_string()], fonts: vec!["Noto Sans".to_string()],
header: None,
footer: None,
root: ContainerElement { root: ContainerElement {
id: "root".to_string(), id: "root".to_string(),
position: PositionMode::Flow, position: PositionMode::Flow,
@@ -269,6 +277,7 @@ fn test_compute_layout_multiple_children_ordering() {
align: "stretch".to_string(), align: "stretch".to_string(),
justify: "start".to_string(), justify: "start".to_string(),
style: ContainerStyle::default(), style: ContainerStyle::default(),
break_inside: "auto".to_string(),
children: vec![ children: vec![
TemplateElement::StaticText(StaticTextElement { TemplateElement::StaticText(StaticTextElement {
id: "first".to_string(), id: "first".to_string(),

View File

@@ -51,6 +51,8 @@ fn simple_template() -> Template {
height: 297.0, height: 297.0,
}, },
fonts: vec!["Noto Sans".to_string()], fonts: vec!["Noto Sans".to_string()],
header: None,
footer: None,
root: ContainerElement { root: ContainerElement {
id: "root".to_string(), id: "root".to_string(),
position: PositionMode::Flow, position: PositionMode::Flow,
@@ -66,6 +68,7 @@ fn simple_template() -> Template {
align: "stretch".to_string(), align: "stretch".to_string(),
justify: "start".to_string(), justify: "start".to_string(),
style: ContainerStyle::default(), style: ContainerStyle::default(),
break_inside: "auto".to_string(),
children: vec![TemplateElement::StaticText(StaticTextElement { children: vec![TemplateElement::StaticText(StaticTextElement {
id: "title".to_string(), id: "title".to_string(),
position: PositionMode::Flow, position: PositionMode::Flow,
@@ -118,6 +121,8 @@ fn test_render_pdf_with_multiple_elements() {
height: 297.0, height: 297.0,
}, },
fonts: vec!["Noto Sans".to_string()], fonts: vec!["Noto Sans".to_string()],
header: None,
footer: None,
root: ContainerElement { root: ContainerElement {
id: "root".to_string(), id: "root".to_string(),
position: PositionMode::Flow, position: PositionMode::Flow,
@@ -133,6 +138,7 @@ fn test_render_pdf_with_multiple_elements() {
align: "stretch".to_string(), align: "stretch".to_string(),
justify: "start".to_string(), justify: "start".to_string(),
style: ContainerStyle::default(), style: ContainerStyle::default(),
break_inside: "auto".to_string(),
children: vec![ children: vec![
TemplateElement::StaticText(StaticTextElement { TemplateElement::StaticText(StaticTextElement {
id: "header".to_string(), id: "header".to_string(),
@@ -207,6 +213,8 @@ fn test_render_pdf_with_container_styles() {
height: 297.0, height: 297.0,
}, },
fonts: vec!["Noto Sans".to_string()], fonts: vec!["Noto Sans".to_string()],
header: None,
footer: None,
root: ContainerElement { root: ContainerElement {
id: "root".to_string(), id: "root".to_string(),
position: PositionMode::Flow, position: PositionMode::Flow,
@@ -227,6 +235,7 @@ fn test_render_pdf_with_container_styles() {
border_width: Some(1.0), border_width: Some(1.0),
..Default::default() ..Default::default()
}, },
break_inside: "auto".to_string(),
children: vec![TemplateElement::StaticText(StaticTextElement { children: vec![TemplateElement::StaticText(StaticTextElement {
id: "text".to_string(), id: "text".to_string(),
position: PositionMode::Flow, position: PositionMode::Flow,
@@ -254,3 +263,79 @@ fn test_render_pdf_with_container_styles() {
assert!(!pdf_bytes.is_empty()); assert!(!pdf_bytes.is_empty());
assert!(pdf_bytes.starts_with(b"%PDF")); assert!(pdf_bytes.starts_with(b"%PDF"));
} }
#[test]
fn test_page_break_produces_multiple_pages() {
let template = Template {
id: "pb_test".to_string(),
name: "Page Break Test".to_string(),
page: PageSettings { width: 210.0, height: 297.0 },
fonts: vec!["Noto Sans".to_string()],
header: None,
footer: None,
root: ContainerElement {
id: "root".to_string(),
position: PositionMode::Flow,
size: SizeConstraint::default(),
direction: "column".to_string(),
gap: 5.0,
padding: Padding { top: 15.0, right: 15.0, bottom: 15.0, left: 15.0 },
align: "stretch".to_string(),
justify: "start".to_string(),
style: ContainerStyle::default(),
break_inside: "auto".to_string(),
children: vec![
TemplateElement::StaticText(StaticTextElement {
id: "t1".to_string(),
position: PositionMode::Flow,
size: SizeConstraint { width: SizeValue::Fr { value: 1.0 }, height: SizeValue::Auto, ..Default::default() },
style: TextStyle { font_size: Some(18.0), ..Default::default() },
content: "Page 1 content".to_string(),
}),
TemplateElement::PageBreak(PageBreakElement { id: "pb1".to_string() }),
TemplateElement::StaticText(StaticTextElement {
id: "t2".to_string(),
position: PositionMode::Flow,
size: SizeConstraint { width: SizeValue::Fr { value: 1.0 }, height: SizeValue::Auto, ..Default::default() },
style: TextStyle { font_size: Some(18.0), ..Default::default() },
content: "Page 2 content".to_string(),
}),
],
},
};
let data = serde_json::json!({});
let fonts = load_test_fonts();
let layout = compute_layout(&template, &data, &fonts);
println!("Layout pages: {}", layout.pages.len());
for (i, page) in layout.pages.iter().enumerate() {
println!("Page {}: {} elements", i, page.elements.len());
for el in &page.elements {
println!(" - {} (type={}, y={:.1}mm, h={:.1}mm)", el.id, el.element_type, el.y_mm, el.height_mm);
}
}
assert_eq!(layout.pages.len(), 2, "Page break should produce 2 pages");
// Verify page 1 has t1 and page 2 has t2
let p1_ids: Vec<&str> = layout.pages[0].elements.iter().map(|e| e.id.as_str()).collect();
let p2_ids: Vec<&str> = layout.pages[1].elements.iter().map(|e| e.id.as_str()).collect();
println!("Page 1 IDs: {:?}", p1_ids);
println!("Page 2 IDs: {:?}", p2_ids);
assert!(p1_ids.contains(&"t1"), "Page 1 should contain t1");
assert!(p2_ids.contains(&"t2"), "Page 2 should contain t2");
// Render PDF and verify it's valid
let pdf_bytes = dreport_layout::pdf_render::render_pdf(&layout, &fonts).unwrap();
assert!(pdf_bytes.starts_with(b"%PDF"));
// Write PDF for manual inspection
let out_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent().unwrap()
.join("test_page_break.pdf");
std::fs::write(&out_path, &pdf_bytes).unwrap();
println!("Wrote: {}", out_path.display());
}