mirror of
https://github.com/duhanbalci/dreport.git
synced 2026-07-01 18:39:16 +00:00
add elements
This commit is contained in:
42
ELEMENTS.md
42
ELEMENTS.md
@@ -81,7 +81,7 @@ Cok sayfali belgelerde otomatik sayfa numarasi. Format sablonu destekler (or: "S
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -236,22 +236,22 @@ Veri gorselIestirme icin basit grafik elemani. Rapor ciktilari icin degerli, fat
|
||||
Toolbar
|
||||
├── Duzen
|
||||
│ ├── Container (mevcut)
|
||||
│ └── Page Break (planlanmis)
|
||||
│ └── Page Break (mevcut)
|
||||
├── Metin
|
||||
│ ├── Statik Metin (mevcut)
|
||||
│ ├── Rich Text (planlanmis)
|
||||
│ └── Hesaplanmis Alan (planlanmis)
|
||||
│ ├── Rich Text (mevcut)
|
||||
│ └── Hesaplanmis Alan (mevcut)
|
||||
├── Veri
|
||||
│ ├── Tekrarlayan Tablo (mevcut)
|
||||
│ └── Checkbox (planlanmis)
|
||||
│ └── Checkbox (mevcut)
|
||||
├── Gorsel
|
||||
│ ├── Gorsel (mevcut)
|
||||
│ ├── Cizgi (mevcut)
|
||||
│ ├── Sekil (planlanmis)
|
||||
│ ├── Sekil (mevcut)
|
||||
│ └── Barkod / QR (mevcut)
|
||||
├── Otomatik
|
||||
│ ├── Sayfa No (mevcut)
|
||||
│ └── Tarih (planlanmis)
|
||||
│ └── Tarih (mevcut)
|
||||
└── Rapor
|
||||
└── Grafik (planlanmis)
|
||||
```
|
||||
@@ -260,12 +260,12 @@ Toolbar
|
||||
|
||||
## Oncelik Sirasi
|
||||
|
||||
| Oncelik | Element | Gerekce |
|
||||
|---------|---------|---------|
|
||||
| 1 | `rich_text` | Karisik formatlama en cok talep edilen ozellik, cosmic-text uyumlu |
|
||||
| 2 | `shape` | Basit implementasyon, gorsel zenginlik katiyor |
|
||||
| 3 | `checkbox` | Boolean gosterim, form/irsaliye icin onemli |
|
||||
| 4 | `calculated_text` | Hesaplama ihtiyaci fatura/rapor icin kritik |
|
||||
| 5 | `current_date` | Kucuk ama kullanisli, hizli implemente edilir |
|
||||
| 6 | `page_break` | Manuel sayfa kontrolu, rapor senaryolari icin |
|
||||
| 7 | `chart` | En karmasik, rapor fazinda ele alinabilir |
|
||||
| Oncelik | Element | Gerekce | Durum |
|
||||
|---------|---------|---------|-------|
|
||||
| 1 | `rich_text` | Karisik formatlama en cok talep edilen ozellik, cosmic-text uyumlu | Yapildi |
|
||||
| 2 | `shape` | Basit implementasyon, gorsel zenginlik katiyor | Yapildi |
|
||||
| 3 | `checkbox` | Boolean gosterim, form/irsaliye icin onemli | Yapildi |
|
||||
| 4 | `calculated_text` | Hesaplama ihtiyaci fatura/rapor icin kritik | Yapildi |
|
||||
| 5 | `current_date` | Kucuk ama kullanisli, hizli implemente edilir | Yapildi |
|
||||
| 6 | `page_break` | Manuel sayfa kontrolu, rapor senaryolari icin | Yapildi |
|
||||
| 7 | `chart` | En karmasik, rapor fazinda ele alinabilir | |
|
||||
|
||||
@@ -145,6 +145,19 @@ pub struct BarcodeStyle {
|
||||
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 ---
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
@@ -172,6 +185,18 @@ pub enum TemplateElement {
|
||||
PageNumber(PageNumberElement),
|
||||
#[serde(rename = "barcode")]
|
||||
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 {
|
||||
@@ -185,6 +210,12 @@ impl TemplateElement {
|
||||
Self::Image(e) => &e.id,
|
||||
Self::PageNumber(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::PageNumber(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 {
|
||||
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 {
|
||||
Self::Container(e) => &e.size,
|
||||
Self::StaticText(e) => &e.size,
|
||||
@@ -211,10 +256,27 @@ impl TemplateElement {
|
||||
Self::Image(e) => &e.size,
|
||||
Self::PageNumber(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)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ContainerElement {
|
||||
@@ -237,8 +299,12 @@ pub struct ContainerElement {
|
||||
pub style: ContainerStyle,
|
||||
#[serde(default)]
|
||||
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_stretch() -> String { "stretch".to_string() }
|
||||
fn default_start() -> String { "start".to_string() }
|
||||
@@ -315,6 +381,67 @@ pub struct RepeatingTableElement {
|
||||
pub data_source: ArrayBinding,
|
||||
pub columns: Vec<TableColumn>,
|
||||
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 ---
|
||||
@@ -325,5 +452,9 @@ pub struct Template {
|
||||
pub name: String,
|
||||
pub page: PageSettings,
|
||||
pub fonts: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub header: Option<ContainerElement>,
|
||||
#[serde(default)]
|
||||
pub footer: Option<ContainerElement>,
|
||||
pub root: ContainerElement,
|
||||
}
|
||||
|
||||
@@ -37,14 +37,24 @@ const scale = computed(() => {
|
||||
return (containerWidth.value / templateStore.template.page.width) * editorStore.zoom
|
||||
})
|
||||
|
||||
// Sayfa boyutu px cinsinden + margin CSS variables
|
||||
const pageStyle = computed(() => {
|
||||
// Layout sayfaları
|
||||
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 h = templateStore.template.page.height * scale.value
|
||||
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 {
|
||||
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-right': `${m.right * scale.value}px`,
|
||||
'--page-margin-bottom': `${m.bottom * scale.value}px`,
|
||||
@@ -204,10 +214,10 @@ function onPointerUp(e: PointerEvent) {
|
||||
@pointermove="onPointerMove"
|
||||
@pointerup="onPointerUp"
|
||||
>
|
||||
<!-- Sayfa -->
|
||||
<div ref="pageRef" class="editor-canvas__page" :style="[pageStyle, panTransform ? { transform: panTransform } : {}]">
|
||||
<!-- Sayfalar -->
|
||||
<div ref="pageRef" class="editor-canvas__pages" :style="[pagesContainerStyle, panTransform ? { transform: panTransform } : {}]">
|
||||
<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>
|
||||
|
||||
@@ -244,9 +254,7 @@ function onPointerUp(e: PointerEvent) {
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.editor-canvas__page {
|
||||
background: white;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
|
||||
.editor-canvas__pages {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -2,15 +2,19 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { useTemplateStore } from '../../stores/template'
|
||||
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 { isContainer, sz } from '../../core/types'
|
||||
import ElementToolbar from './ElementToolbar.vue'
|
||||
import { useSnapGuides } from '../../composables/useSnapGuides'
|
||||
|
||||
const PAGE_GAP_PX = 24
|
||||
|
||||
const props = defineProps<{
|
||||
scale: number
|
||||
layoutMap: Record<string, ElementLayout>
|
||||
layoutMap: Record<string, LayoutMapEntry>
|
||||
pageCount?: number
|
||||
pageHeightPx?: number
|
||||
}>()
|
||||
|
||||
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)
|
||||
if (templateStore.template.footer) {
|
||||
result.push(templateStore.template.footer as unknown as TemplateElement)
|
||||
walk(templateStore.template.footer as unknown as TemplateElement)
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
@@ -41,10 +54,25 @@ const allContainers = computed(() => {
|
||||
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)
|
||||
if (templateStore.template.footer) {
|
||||
result.push(templateStore.template.footer)
|
||||
for (const child of templateStore.template.footer.children) walk(child)
|
||||
}
|
||||
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) {
|
||||
const l = props.layoutMap[el.id]
|
||||
if (!l) return { display: 'none' }
|
||||
@@ -53,12 +81,13 @@ function getElementStyle(el: TemplateElement) {
|
||||
const h = l.height_mm * s
|
||||
const minH = 8
|
||||
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 {
|
||||
position: 'absolute' as const,
|
||||
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`,
|
||||
height: `${actualH}px`,
|
||||
}
|
||||
@@ -113,7 +142,7 @@ function findDeepestContainer(mouseX: number, mouseY: number, excludeId?: string
|
||||
/** Container içinde drop index hesapla */
|
||||
function computeDropIndex(container: ContainerElement, mouseX: number, mouseY: number, excludeId?: string) {
|
||||
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'
|
||||
|
||||
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
|
||||
let logicalIdx = visualIdx
|
||||
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)
|
||||
if (currentIdx >= 0) {
|
||||
// visualIdx, excludeId çıkarılmış listede. Gerçek listedeki pozisyona çevir.
|
||||
@@ -186,7 +215,7 @@ const dropIndicatorStyle = computed(() => {
|
||||
|
||||
// Sürüklenen elemanı çıkar
|
||||
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]
|
||||
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 })
|
||||
|
||||
function onDragStart(e: PointerEvent, el: TemplateElement) {
|
||||
if (el.type === 'page_break') return
|
||||
if (el.position.type === 'absolute') {
|
||||
onAbsoluteDragStart(e, el)
|
||||
return
|
||||
@@ -617,7 +647,7 @@ const isAnyDragActive = computed(() =>
|
||||
<div v-if="editorStore.selectedElementId === el.id" class="selection-border" />
|
||||
|
||||
<!-- 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'">
|
||||
<!-- Barkod/Görsel: sadece yatay resize (aspect ratio korunur) -->
|
||||
<div class="resize-handle resize-handle--e" @pointerdown="(e: PointerEvent) => onResizeStart(e, el.id, 'e')" />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, watch, nextTick } from 'vue'
|
||||
import type { ElementLayout, LayoutResult } from '../../core/layout-types'
|
||||
import { inject, watch, nextTick } from 'vue'
|
||||
import type { ElementLayout, PageLayout, LayoutResult } from '../../core/layout-types'
|
||||
|
||||
const props = defineProps<{
|
||||
layout: LayoutResult | null
|
||||
@@ -10,10 +10,14 @@ const props = defineProps<{
|
||||
// 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 pageElements = computed(() => {
|
||||
if (!props.layout || props.layout.pages.length === 0) return []
|
||||
return props.layout.pages[0].elements
|
||||
})
|
||||
function pageContainerStyle(page: PageLayout): Record<string, string> {
|
||||
const s = props.scale
|
||||
return {
|
||||
position: 'relative',
|
||||
width: `${page.width_mm * s}px`,
|
||||
height: `${page.height_mm * s}px`,
|
||||
}
|
||||
}
|
||||
|
||||
function elStyle(el: ElementLayout): Record<string, string> {
|
||||
const s = props.scale
|
||||
@@ -58,6 +62,25 @@ function containerStyle(el: ElementLayout): Record<string, string> {
|
||||
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> {
|
||||
const st = el.style
|
||||
return {
|
||||
@@ -146,70 +169,140 @@ watch(
|
||||
|
||||
<template>
|
||||
<div class="layout-renderer" v-if="layout">
|
||||
<template v-for="el in pageElements" :key="el.id">
|
||||
<!-- Container -->
|
||||
<div
|
||||
v-if="el.element_type === 'container'"
|
||||
class="layout-el layout-el--container"
|
||||
:style="{ ...elStyle(el), ...containerStyle(el) }"
|
||||
/>
|
||||
|
||||
<!-- Static text / Text / Page number -->
|
||||
<div
|
||||
v-else-if="el.element_type === 'static_text' || el.element_type === 'text' || el.element_type === 'page_number'"
|
||||
class="layout-el layout-el--text"
|
||||
:style="{ ...elStyle(el), ...textStyle(el) }"
|
||||
>
|
||||
{{ el.content?.type === 'text' ? el.content.value : '' }}
|
||||
</div>
|
||||
|
||||
<!-- Line -->
|
||||
<div
|
||||
v-else-if="el.element_type === 'line'"
|
||||
class="layout-el layout-el--line"
|
||||
:style="elStyle(el)"
|
||||
>
|
||||
<div :style="lineStyle(el)" />
|
||||
</div>
|
||||
|
||||
<!-- Image -->
|
||||
<div
|
||||
v-else-if="el.element_type === 'image'"
|
||||
class="layout-el layout-el--image"
|
||||
:style="elStyle(el)"
|
||||
>
|
||||
<img
|
||||
v-if="el.content?.type === 'image' && el.content.src"
|
||||
:src="el.content.src"
|
||||
:style="{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'fill',
|
||||
}"
|
||||
/>
|
||||
<div v-else class="layout-el__placeholder">Görsel</div>
|
||||
</div>
|
||||
|
||||
<!-- Barcode -->
|
||||
<div
|
||||
v-else-if="el.element_type === 'barcode'"
|
||||
class="layout-el layout-el--barcode"
|
||||
:style="elStyle(el)"
|
||||
>
|
||||
<canvas
|
||||
v-if="el.content?.type === 'barcode' && el.content.value"
|
||||
:ref="(ref) => onBarcodeCanvasMounted(ref as HTMLCanvasElement)"
|
||||
data-barcode
|
||||
:data-format="el.content.format"
|
||||
:data-value="el.content.value"
|
||||
:data-include-text="el.style.barcodeIncludeText ?? (el.content.format === 'ean13' || el.content.format === 'ean8')"
|
||||
:style="{ width: '100%', height: '100%', display: 'block' }"
|
||||
/>
|
||||
<div v-else class="layout-el__placeholder">
|
||||
{{ el.content?.type === 'barcode' ? `[${el.content.format}]` : '[barcode]' }}
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Container -->
|
||||
<div
|
||||
v-else-if="el.element_type === '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) }"
|
||||
>
|
||||
<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 -->
|
||||
<div
|
||||
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"
|
||||
:style="{ ...elStyle(el), ...textStyle(el) }"
|
||||
>
|
||||
{{ el.content?.type === 'text' ? el.content.value : '' }}
|
||||
</div>
|
||||
|
||||
<!-- Line -->
|
||||
<div
|
||||
v-else-if="el.element_type === 'line'"
|
||||
class="layout-el layout-el--line"
|
||||
:style="elStyle(el)"
|
||||
>
|
||||
<div :style="lineStyle(el)" />
|
||||
</div>
|
||||
|
||||
<!-- Image -->
|
||||
<div
|
||||
v-else-if="el.element_type === 'image'"
|
||||
class="layout-el layout-el--image"
|
||||
:style="elStyle(el)"
|
||||
>
|
||||
<img
|
||||
v-if="el.content?.type === 'image' && el.content.src"
|
||||
:src="el.content.src"
|
||||
:style="{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'fill',
|
||||
}"
|
||||
/>
|
||||
<div v-else class="layout-el__placeholder">Görsel</div>
|
||||
</div>
|
||||
|
||||
<!-- Barcode -->
|
||||
<div
|
||||
v-else-if="el.element_type === 'barcode'"
|
||||
class="layout-el layout-el--barcode"
|
||||
:style="elStyle(el)"
|
||||
>
|
||||
<canvas
|
||||
v-if="el.content?.type === 'barcode' && el.content.value"
|
||||
:ref="(ref) => onBarcodeCanvasMounted(ref as HTMLCanvasElement)"
|
||||
data-barcode
|
||||
:data-format="el.content.format"
|
||||
:data-value="el.content.value"
|
||||
:data-include-text="el.style.barcodeIncludeText ?? (el.content.format === 'ean13' || el.content.format === 'ean8')"
|
||||
:style="{ width: '100%', height: '100%', display: 'block' }"
|
||||
/>
|
||||
<div v-else class="layout-el__placeholder">
|
||||
{{ el.content?.type === 'barcode' ? `[${el.content.format}]` : '[barcode]' }}
|
||||
</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>
|
||||
</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>
|
||||
@@ -219,12 +312,20 @@ watch(
|
||||
|
||||
<style scoped>
|
||||
.layout-renderer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: 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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -247,6 +348,27 @@ watch(
|
||||
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 {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
@@ -10,6 +10,11 @@ import type {
|
||||
PageNumberElement,
|
||||
BarcodeElement,
|
||||
RepeatingTableElement,
|
||||
CurrentDateElement,
|
||||
ShapeElement,
|
||||
CheckboxElement,
|
||||
CalculatedTextElement,
|
||||
RichTextElement,
|
||||
} from '../../core/types'
|
||||
import PositioningProperties from '../properties/PositioningProperties.vue'
|
||||
import SizeProperties from '../properties/SizeProperties.vue'
|
||||
@@ -18,6 +23,11 @@ import LineProperties from '../properties/LineProperties.vue'
|
||||
import ImageProperties from '../properties/ImageProperties.vue'
|
||||
import PageNumberProperties from '../properties/PageNumberProperties.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 RepeatingTableProperties from '../properties/RepeatingTableProperties.vue'
|
||||
import '../../styles/properties.css'
|
||||
@@ -35,7 +45,10 @@ const elementTypeLabel = computed(() => {
|
||||
const el = selectedElement.value
|
||||
if (!el) return ''
|
||||
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 'text': return 'Metin'
|
||||
case 'line': return 'Cizgi'
|
||||
@@ -43,10 +56,28 @@ const elementTypeLabel = computed(() => {
|
||||
case 'image': return 'Gorsel'
|
||||
case 'page_number': return 'Sayfa No'
|
||||
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'
|
||||
}
|
||||
})
|
||||
|
||||
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() {
|
||||
const id = editorStore.selectedElementId
|
||||
if (!id || id === 'root') return
|
||||
@@ -70,41 +101,85 @@ function deleteElement() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PositioningProperties :element="selectedElement" />
|
||||
<SizeProperties :element="selectedElement" />
|
||||
<!-- 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>
|
||||
|
||||
<TextProperties
|
||||
v-if="selectedElement.type === 'static_text' || selectedElement.type === 'text'"
|
||||
:element="selectedElement" />
|
||||
<template v-else>
|
||||
<PositioningProperties :element="selectedElement" />
|
||||
<SizeProperties :element="selectedElement" />
|
||||
|
||||
<LineProperties
|
||||
v-if="selectedElement.type === 'line'"
|
||||
:element="(selectedElement as LineElement)" />
|
||||
<TextProperties
|
||||
v-if="selectedElement.type === 'static_text' || selectedElement.type === 'text'"
|
||||
:element="selectedElement" />
|
||||
|
||||
<ImageProperties
|
||||
v-if="selectedElement.type === 'image'"
|
||||
:element="(selectedElement as ImageElement)" />
|
||||
<LineProperties
|
||||
v-if="selectedElement.type === 'line'"
|
||||
:element="(selectedElement as LineElement)" />
|
||||
|
||||
<PageNumberProperties
|
||||
v-if="selectedElement.type === 'page_number'"
|
||||
:element="(selectedElement as PageNumberElement)" />
|
||||
<ImageProperties
|
||||
v-if="selectedElement.type === 'image'"
|
||||
:element="(selectedElement as ImageElement)" />
|
||||
|
||||
<BarcodeProperties
|
||||
v-if="selectedElement.type === 'barcode'"
|
||||
:element="(selectedElement as BarcodeElement)" />
|
||||
<PageNumberProperties
|
||||
v-if="selectedElement.type === 'page_number'"
|
||||
:element="(selectedElement as PageNumberElement)" />
|
||||
|
||||
<ContainerProperties
|
||||
v-if="isContainer(selectedElement)"
|
||||
:element="(selectedElement as ContainerElement)" />
|
||||
<BarcodeProperties
|
||||
v-if="selectedElement.type === 'barcode'"
|
||||
:element="(selectedElement as BarcodeElement)" />
|
||||
|
||||
<RepeatingTableProperties
|
||||
v-if="selectedElement.type === 'repeating_table'"
|
||||
:element="(selectedElement as RepeatingTableElement)" />
|
||||
<CurrentDateProperties
|
||||
v-if="selectedElement.type === 'current_date'"
|
||||
:element="(selectedElement as CurrentDateElement)" />
|
||||
|
||||
<!-- Delete -->
|
||||
<div v-if="selectedElement.id !== 'root'" class="prop-section">
|
||||
<button class="prop-delete-btn" @click="deleteElement">Sil</button>
|
||||
</div>
|
||||
<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
|
||||
v-if="isContainer(selectedElement)"
|
||||
:element="(selectedElement as ContainerElement)" />
|
||||
|
||||
<RepeatingTableProperties
|
||||
v-if="selectedElement.type === 'repeating_table'"
|
||||
: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 -->
|
||||
<div v-if="selectedElement.id !== 'root'" class="prop-section">
|
||||
<button class="prop-delete-btn" @click="deleteElement">Sil</button>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useEditorStore } from '../../stores/editor'
|
||||
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 { schemaFormatToFormatType, defaultAlignForSchema } from '../../core/schema-parser'
|
||||
|
||||
@@ -32,6 +32,21 @@ const tools: ToolItem[] = [
|
||||
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',
|
||||
icon: '▢',
|
||||
@@ -96,6 +111,7 @@ const tools: ToolItem[] = [
|
||||
fontSize: 10,
|
||||
headerFontSize: 10,
|
||||
},
|
||||
repeatHeader: true,
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -135,6 +151,62 @@ const tools: ToolItem[] = [
|
||||
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) {
|
||||
|
||||
@@ -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>
|
||||
50
frontend/src/components/properties/CheckboxProperties.vue
Normal file
50
frontend/src/components/properties/CheckboxProperties.vue
Normal 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>
|
||||
@@ -70,6 +70,16 @@ function updateStyle(key: string, value: unknown) {
|
||||
@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-row">
|
||||
<label class="prop-label">Arka plan</label>
|
||||
|
||||
59
frontend/src/components/properties/CurrentDateProperties.vue
Normal file
59
frontend/src/components/properties/CurrentDateProperties.vue
Normal 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>
|
||||
@@ -193,6 +193,17 @@ const tableItemFields = computed(() => {
|
||||
</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 -->
|
||||
<div class="prop-section">
|
||||
<div class="prop-section__title">Tablo Stili</div>
|
||||
|
||||
182
frontend/src/components/properties/RichTextProperties.vue
Normal file
182
frontend/src/components/properties/RichTextProperties.vue
Normal 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"
|
||||
>×</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>
|
||||
60
frontend/src/components/properties/ShapeProperties.vue
Normal file
60
frontend/src/components/properties/ShapeProperties.vue
Normal 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>
|
||||
@@ -1,8 +1,8 @@
|
||||
import { ref, watch, type Ref } from 'vue'
|
||||
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(
|
||||
template: Ref<Template>,
|
||||
@@ -14,7 +14,7 @@ export function useLayoutEngine(
|
||||
const computing = ref(false)
|
||||
|
||||
// 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 requestId = 0
|
||||
@@ -40,11 +40,13 @@ export function useLayoutEngine(
|
||||
layout.value = msg.layout
|
||||
error.value = null
|
||||
|
||||
// Flat map oluştur: id → ElementLayout
|
||||
const map: Record<string, ElementLayout> = {}
|
||||
// Flat map oluştur: id → LayoutMapEntry (pageIndex dahil)
|
||||
const map: Record<string, LayoutMapEntry> = {}
|
||||
for (const page of msg.layout.pages) {
|
||||
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
|
||||
|
||||
@@ -23,12 +23,27 @@ export interface ElementLayout {
|
||||
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 =
|
||||
| { type: 'text'; value: string }
|
||||
| { type: 'image'; src: string }
|
||||
| { type: 'line' }
|
||||
| { type: 'barcode'; format: string; value: string }
|
||||
| { 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[] }
|
||||
|
||||
export interface TableHeaderCell {
|
||||
|
||||
@@ -163,6 +163,56 @@ export interface BarcodeElement extends BaseElement {
|
||||
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 {
|
||||
type: 'container'
|
||||
direction: 'row' | 'column'
|
||||
@@ -170,6 +220,7 @@ export interface ContainerElement extends BaseElement {
|
||||
padding: Padding
|
||||
align: 'start' | 'center' | 'end' | 'stretch'
|
||||
justify: 'start' | 'center' | 'end' | 'space-between'
|
||||
breakInside?: 'auto' | 'avoid'
|
||||
style: ContainerStyle
|
||||
children: TemplateElement[]
|
||||
}
|
||||
@@ -179,9 +230,10 @@ export interface RepeatingTableElement extends BaseElement {
|
||||
dataSource: ArrayBinding
|
||||
columns: TableColumn[]
|
||||
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
|
||||
|
||||
// --- Template ---
|
||||
@@ -193,6 +245,8 @@ export interface Template {
|
||||
page: PageSettings
|
||||
fonts: string[]
|
||||
root: ContainerElement // kök container = sayfa
|
||||
header?: ContainerElement
|
||||
footer?: ContainerElement
|
||||
}
|
||||
|
||||
// --- Editor state ---
|
||||
|
||||
@@ -86,11 +86,34 @@ export const useTemplateStore = defineStore('template', () => {
|
||||
// --- Element CRUD ---
|
||||
|
||||
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 {
|
||||
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 */
|
||||
@@ -180,6 +203,58 @@ export const useTemplateStore = defineStore('template', () => {
|
||||
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 {
|
||||
template,
|
||||
mockData,
|
||||
@@ -202,5 +277,9 @@ export const useTemplateStore = defineStore('template', () => {
|
||||
redo,
|
||||
canUndo,
|
||||
canRedo,
|
||||
enableHeader,
|
||||
disableHeader,
|
||||
enableFooter,
|
||||
disableFooter,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -2,6 +2,72 @@ use dreport_core::models::*;
|
||||
use serde_json::Value;
|
||||
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.
|
||||
/// Table ve barcode gibi özel tipler de burada çözülür.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -14,6 +80,10 @@ pub struct ResolvedData {
|
||||
pub barcodes: HashMap<String, String>,
|
||||
/// element_id → çözümlenmiş image src
|
||||
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)]
|
||||
@@ -51,8 +121,16 @@ pub fn resolve_template(template: &Template, data: &Value) -> ResolvedData {
|
||||
tables: HashMap::new(),
|
||||
barcodes: 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);
|
||||
if let Some(ref footer) = template.footer {
|
||||
resolve_element(&TemplateElement::Container(footer.clone()), data, &mut resolved);
|
||||
}
|
||||
resolved
|
||||
}
|
||||
|
||||
@@ -70,8 +148,10 @@ fn resolve_element(el: &TemplateElement, data: &Value, resolved: &mut ResolvedDa
|
||||
resolved.texts.insert(e.id.clone(), text);
|
||||
}
|
||||
TemplateElement::PageNumber(e) => {
|
||||
// Sayfa numarası layout sonrasında çözülecek, placeholder koy
|
||||
let fmt = e.format.as_deref().unwrap_or("{current} / {total}");
|
||||
// Format string'i sakla — sayfa bölme sonrası gerçek değerlerle çözülecek
|
||||
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"));
|
||||
}
|
||||
TemplateElement::Barcode(e) => {
|
||||
@@ -116,7 +196,59 @@ fn resolve_element(el: &TemplateElement, data: &Value, resolved: &mut ResolvedDa
|
||||
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::Shape(_) => {}
|
||||
TemplateElement::PageBreak(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,6 +324,8 @@ mod tests {
|
||||
name: "Test".to_string(),
|
||||
page: PageSettings { width: 210.0, height: 297.0 },
|
||||
fonts: vec![],
|
||||
header: None,
|
||||
footer: None,
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
@@ -202,6 +336,7 @@ mod tests {
|
||||
align: "stretch".to_string(),
|
||||
justify: "start".to_string(),
|
||||
style: ContainerStyle::default(),
|
||||
break_inside: "auto".to_string(),
|
||||
children: vec![
|
||||
TemplateElement::Text(TextElement {
|
||||
id: "el_name".to_string(),
|
||||
@@ -233,6 +368,8 @@ mod tests {
|
||||
name: "Test".to_string(),
|
||||
page: PageSettings { width: 210.0, height: 297.0 },
|
||||
fonts: vec![],
|
||||
header: None,
|
||||
footer: None,
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
@@ -243,6 +380,7 @@ mod tests {
|
||||
align: "stretch".to_string(),
|
||||
justify: "start".to_string(),
|
||||
style: ContainerStyle::default(),
|
||||
break_inside: "auto".to_string(),
|
||||
children: vec![
|
||||
TemplateElement::Text(TextElement {
|
||||
id: "el_no".to_string(),
|
||||
@@ -274,6 +412,8 @@ mod tests {
|
||||
name: "Test".to_string(),
|
||||
page: PageSettings { width: 210.0, height: 297.0 },
|
||||
fonts: vec![],
|
||||
header: None,
|
||||
footer: None,
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
@@ -284,6 +424,7 @@ mod tests {
|
||||
align: "stretch".to_string(),
|
||||
justify: "start".to_string(),
|
||||
style: ContainerStyle::default(),
|
||||
break_inside: "auto".to_string(),
|
||||
children: vec![
|
||||
TemplateElement::StaticText(StaticTextElement {
|
||||
id: "title".to_string(),
|
||||
@@ -307,6 +448,8 @@ mod tests {
|
||||
name: "Test".to_string(),
|
||||
page: PageSettings { width: 210.0, height: 297.0 },
|
||||
fonts: vec![],
|
||||
header: None,
|
||||
footer: None,
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
@@ -317,6 +460,7 @@ mod tests {
|
||||
align: "stretch".to_string(),
|
||||
justify: "start".to_string(),
|
||||
style: ContainerStyle::default(),
|
||||
break_inside: "auto".to_string(),
|
||||
children: vec![
|
||||
TemplateElement::RepeatingTable(RepeatingTableElement {
|
||||
id: "tbl".to_string(),
|
||||
@@ -342,6 +486,7 @@ mod tests {
|
||||
},
|
||||
],
|
||||
style: TableStyle::default(),
|
||||
repeat_header: Some(true),
|
||||
}),
|
||||
],
|
||||
},
|
||||
@@ -368,6 +513,8 @@ mod tests {
|
||||
name: "Test".to_string(),
|
||||
page: PageSettings { width: 210.0, height: 297.0 },
|
||||
fonts: vec![],
|
||||
header: None,
|
||||
footer: None,
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
@@ -378,6 +525,7 @@ mod tests {
|
||||
align: "stretch".to_string(),
|
||||
justify: "start".to_string(),
|
||||
style: ContainerStyle::default(),
|
||||
break_inside: "auto".to_string(),
|
||||
children: vec![
|
||||
TemplateElement::RepeatingTable(RepeatingTableElement {
|
||||
id: "tbl".to_string(),
|
||||
@@ -395,6 +543,7 @@ mod tests {
|
||||
},
|
||||
],
|
||||
style: TableStyle::default(),
|
||||
repeat_header: Some(true),
|
||||
}),
|
||||
],
|
||||
},
|
||||
@@ -413,6 +562,8 @@ mod tests {
|
||||
name: "Test".to_string(),
|
||||
page: PageSettings { width: 210.0, height: 297.0 },
|
||||
fonts: vec![],
|
||||
header: None,
|
||||
footer: None,
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
@@ -423,6 +574,7 @@ mod tests {
|
||||
align: "stretch".to_string(),
|
||||
justify: "start".to_string(),
|
||||
style: ContainerStyle::default(),
|
||||
break_inside: "auto".to_string(),
|
||||
children: vec![
|
||||
TemplateElement::Text(TextElement {
|
||||
id: "el_missing".to_string(),
|
||||
|
||||
510
layout-engine/src/expr_eval.rs
Normal file
510
layout-engine/src/expr_eval.rs
Normal 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), "");
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ pub mod text_measure;
|
||||
pub mod data_resolve;
|
||||
pub mod table_layout;
|
||||
pub mod tree;
|
||||
pub mod page_break;
|
||||
pub mod expr_eval;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub mod wasm_api;
|
||||
@@ -56,6 +58,12 @@ pub enum ResolvedContent {
|
||||
Barcode { format: String, value: String },
|
||||
#[serde(rename = "page_number")]
|
||||
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")]
|
||||
Table {
|
||||
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)]
|
||||
pub struct TableHeaderCell {
|
||||
pub text: String,
|
||||
|
||||
995
layout-engine/src/page_break.rs
Normal file
995
layout-engine/src/page_break.rs
Normal 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}", ¤t_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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -208,6 +208,12 @@ fn render_element(
|
||||
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 {
|
||||
return;
|
||||
};
|
||||
@@ -230,12 +236,160 @@ fn render_element(
|
||||
// Tablolar expand edilerek container + text olarak render edilir.
|
||||
// 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 } => {
|
||||
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(
|
||||
surface: &mut krilla::surface::Surface<'_>,
|
||||
x: f32,
|
||||
@@ -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(
|
||||
surface: &mut krilla::surface::Surface<'_>,
|
||||
x: f32,
|
||||
@@ -595,6 +835,8 @@ mod tests {
|
||||
name: "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,
|
||||
@@ -609,6 +851,7 @@ mod tests {
|
||||
align: "stretch".to_string(),
|
||||
justify: "start".to_string(),
|
||||
style: ContainerStyle::default(),
|
||||
break_inside: "auto".to_string(),
|
||||
children: vec![
|
||||
TemplateElement::StaticText(StaticTextElement {
|
||||
id: "title".to_string(),
|
||||
|
||||
@@ -326,6 +326,7 @@ mod tests {
|
||||
justify: "space-between".to_string(),
|
||||
style: ContainerStyle::default(),
|
||||
children: vec![],
|
||||
break_inside: "auto".to_string(),
|
||||
};
|
||||
let style = container_to_style(&el, None);
|
||||
assert_eq!(style.flex_direction, FlexDirection::Row);
|
||||
@@ -346,6 +347,7 @@ mod tests {
|
||||
justify: "start".to_string(),
|
||||
style: ContainerStyle::default(),
|
||||
children: vec![],
|
||||
break_inside: "auto".to_string(),
|
||||
};
|
||||
let style = container_to_style(&el, None);
|
||||
assert_eq!(style.position, Position::Absolute);
|
||||
|
||||
@@ -73,6 +73,7 @@ pub fn expand_table(
|
||||
..Default::default()
|
||||
},
|
||||
children: header_cells,
|
||||
break_inside: "auto".to_string(),
|
||||
}));
|
||||
|
||||
// Header altına ayırıcı çizgi
|
||||
@@ -163,6 +164,7 @@ pub fn expand_table(
|
||||
..Default::default()
|
||||
},
|
||||
children: cells,
|
||||
break_inside: "auto".to_string(),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -187,6 +189,7 @@ pub fn expand_table(
|
||||
..Default::default()
|
||||
},
|
||||
children,
|
||||
break_inside: "auto".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,6 +222,7 @@ mod tests {
|
||||
data_source: ArrayBinding { path: "items".to_string() },
|
||||
columns,
|
||||
style: TableStyle::default(),
|
||||
repeat_header: Some(true),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,6 +234,8 @@ mod tests {
|
||||
tables,
|
||||
barcodes: HashMap::new(),
|
||||
images: HashMap::new(),
|
||||
page_number_formats: HashMap::new(),
|
||||
rich_texts: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,15 @@ use std::hash::Hash;
|
||||
use crate::FontData;
|
||||
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
|
||||
/// aynı parametrelerle yapılan ölçümlerin yeniden hesaplanmasını önler.
|
||||
#[derive(Default)]
|
||||
@@ -172,6 +181,83 @@ impl TextMeasurer {
|
||||
|
||||
(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)]
|
||||
|
||||
@@ -7,7 +7,7 @@ use crate::data_resolve::ResolvedData;
|
||||
use crate::sizing::{self, mm_to_pt, pt_to_mm};
|
||||
use crate::table_layout;
|
||||
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
|
||||
struct NodeInfo {
|
||||
@@ -24,6 +24,8 @@ struct MeasureContext {
|
||||
font_family: Option<String>,
|
||||
font_size_pt: f32,
|
||||
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.
|
||||
@@ -32,42 +34,53 @@ pub fn compute(
|
||||
resolved: &ResolvedData,
|
||||
measurer: &mut TextMeasurer,
|
||||
) -> 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();
|
||||
taffy.disable_rounding();
|
||||
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(
|
||||
&template.root,
|
||||
&mut taffy,
|
||||
&mut node_map,
|
||||
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 {
|
||||
display: Display::Flex,
|
||||
flex_direction: FlexDirection::Column,
|
||||
size: Size {
|
||||
width: Dimension::length(page_w_pt),
|
||||
height: Dimension::length(page_h_pt),
|
||||
height: Dimension::auto(),
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let page_node = taffy.new_with_children(page_style, &[root_node]).unwrap();
|
||||
|
||||
// Layout hesapla
|
||||
taffy
|
||||
.compute_layout_with_measure(
|
||||
page_node,
|
||||
Size {
|
||||
width: AvailableSpace::Definite(page_w_pt),
|
||||
height: AvailableSpace::Definite(page_h_pt),
|
||||
height: AvailableSpace::MaxContent,
|
||||
},
|
||||
|known_dimensions, available_space, _node_id, context, _style| {
|
||||
measure_leaf(known_dimensions, available_space, context, measurer)
|
||||
@@ -75,16 +88,90 @@ pub fn compute(
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Layout sonuçlarını topla
|
||||
let elements = collect_layout(&taffy, root_node, &node_map, 0.0, 0.0);
|
||||
let body_elements = collect_layout(&taffy, root_node, &node_map, 0.0, 0.0);
|
||||
|
||||
LayoutResult {
|
||||
pages: vec![PageLayout {
|
||||
page_index: 0,
|
||||
width_mm: template.page.width,
|
||||
height_mm: template.page.height,
|
||||
elements,
|
||||
}],
|
||||
// --- 4. Container break modlarını topla ---
|
||||
let break_modes = collect_break_modes(&template.root);
|
||||
|
||||
// --- 5. Sayfalara böl ---
|
||||
let input = crate::page_break::PageSplitInput {
|
||||
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,
|
||||
)
|
||||
}
|
||||
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) => {
|
||||
let stroke_w = e.style.stroke_width.unwrap_or(0.5);
|
||||
let style = sizing::leaf_style(&e.size, &e.position, parent_direction);
|
||||
@@ -293,6 +416,144 @@ fn build_element(
|
||||
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_size_pt,
|
||||
font_weight: text_style.font_weight.clone(),
|
||||
rich_spans: None,
|
||||
};
|
||||
|
||||
let node = taffy.new_leaf_with_context(style, context).unwrap();
|
||||
@@ -387,13 +649,17 @@ fn measure_leaf(
|
||||
AvailableSpace::MinContent => Some(0.0),
|
||||
};
|
||||
|
||||
let (measured_w, measured_h) = measurer.measure(
|
||||
&ctx.text,
|
||||
ctx.font_family.as_deref(),
|
||||
ctx.font_size_pt,
|
||||
ctx.font_weight.as_deref(),
|
||||
available_width,
|
||||
);
|
||||
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.font_family.as_deref(),
|
||||
ctx.font_size_pt,
|
||||
ctx.font_weight.as_deref(),
|
||||
available_width,
|
||||
)
|
||||
};
|
||||
|
||||
Size {
|
||||
width: known_dimensions.width.unwrap_or(measured_w),
|
||||
@@ -458,6 +724,8 @@ mod tests {
|
||||
height: 297.0,
|
||||
},
|
||||
fonts: vec!["Noto Sans".to_string()],
|
||||
header: None,
|
||||
footer: None,
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
@@ -480,6 +748,7 @@ mod tests {
|
||||
align: "stretch".to_string(),
|
||||
justify: "start".to_string(),
|
||||
style: ContainerStyle::default(),
|
||||
break_inside: "auto".to_string(),
|
||||
children: vec![
|
||||
TemplateElement::StaticText(StaticTextElement {
|
||||
id: "title".to_string(),
|
||||
@@ -598,6 +867,8 @@ mod tests {
|
||||
height: 297.0,
|
||||
},
|
||||
fonts: vec![],
|
||||
header: None,
|
||||
footer: None,
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
@@ -620,6 +891,7 @@ mod tests {
|
||||
align: "stretch".to_string(),
|
||||
justify: "start".to_string(),
|
||||
style: ContainerStyle::default(),
|
||||
break_inside: "auto".to_string(),
|
||||
children: vec![TemplateElement::Container(ContainerElement {
|
||||
id: "row".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
@@ -642,6 +914,7 @@ mod tests {
|
||||
align: "start".to_string(),
|
||||
justify: "start".to_string(),
|
||||
style: ContainerStyle::default(),
|
||||
break_inside: "auto".to_string(),
|
||||
children: vec![
|
||||
TemplateElement::StaticText(StaticTextElement {
|
||||
id: "left".to_string(),
|
||||
@@ -721,6 +994,8 @@ mod tests {
|
||||
height: 297.0,
|
||||
},
|
||||
fonts: vec![],
|
||||
header: None,
|
||||
footer: None,
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
@@ -743,6 +1018,7 @@ mod tests {
|
||||
align: "stretch".to_string(),
|
||||
justify: "start".to_string(),
|
||||
style: ContainerStyle::default(),
|
||||
break_inside: "auto".to_string(),
|
||||
children: vec![TemplateElement::StaticText(StaticTextElement {
|
||||
id: "abs_text".to_string(),
|
||||
position: PositionMode::Absolute { x: 50.0, y: 80.0 },
|
||||
@@ -806,6 +1082,8 @@ mod tests {
|
||||
height: 297.0,
|
||||
},
|
||||
fonts: vec!["Noto Sans".to_string()],
|
||||
header: None,
|
||||
footer: None,
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
@@ -821,6 +1099,7 @@ mod tests {
|
||||
align: "stretch".to_string(),
|
||||
justify: "start".to_string(),
|
||||
style: ContainerStyle::default(),
|
||||
break_inside: "auto".to_string(),
|
||||
children: vec![
|
||||
// Header row
|
||||
TemplateElement::Container(ContainerElement {
|
||||
@@ -833,6 +1112,7 @@ mod tests {
|
||||
align: "start".to_string(),
|
||||
justify: "space-between".to_string(),
|
||||
style: ContainerStyle::default(),
|
||||
break_inside: "auto".to_string(),
|
||||
children: vec![
|
||||
// Sol: firma bilgileri
|
||||
TemplateElement::Container(ContainerElement {
|
||||
@@ -845,6 +1125,7 @@ mod tests {
|
||||
align: "start".to_string(),
|
||||
justify: "start".to_string(),
|
||||
style: ContainerStyle::default(),
|
||||
break_inside: "auto".to_string(),
|
||||
children: vec![
|
||||
TemplateElement::StaticText(StaticTextElement {
|
||||
id: "el_firma_unvan".to_string(),
|
||||
@@ -921,6 +1202,7 @@ mod tests {
|
||||
align: "end".to_string(),
|
||||
justify: "start".to_string(),
|
||||
style: ContainerStyle::default(),
|
||||
break_inside: "auto".to_string(),
|
||||
children: vec![
|
||||
TemplateElement::StaticText(StaticTextElement {
|
||||
id: "el_fatura_baslik".to_string(),
|
||||
|
||||
@@ -49,6 +49,8 @@ fn simple_template() -> Template {
|
||||
height: 297.0,
|
||||
},
|
||||
fonts: vec!["Noto Sans".to_string()],
|
||||
header: None,
|
||||
footer: None,
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
@@ -64,6 +66,7 @@ fn simple_template() -> Template {
|
||||
align: "stretch".to_string(),
|
||||
justify: "start".to_string(),
|
||||
style: ContainerStyle::default(),
|
||||
break_inside: "auto".to_string(),
|
||||
children: vec![TemplateElement::StaticText(StaticTextElement {
|
||||
id: "title".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
@@ -188,6 +191,8 @@ fn test_compute_layout_with_data_binding() {
|
||||
height: 297.0,
|
||||
},
|
||||
fonts: vec!["Noto Sans".to_string()],
|
||||
header: None,
|
||||
footer: None,
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
@@ -203,6 +208,7 @@ fn test_compute_layout_with_data_binding() {
|
||||
align: "stretch".to_string(),
|
||||
justify: "start".to_string(),
|
||||
style: ContainerStyle::default(),
|
||||
break_inside: "auto".to_string(),
|
||||
children: vec![TemplateElement::Text(TextElement {
|
||||
id: "bound_text".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
@@ -254,6 +260,8 @@ fn test_compute_layout_multiple_children_ordering() {
|
||||
height: 297.0,
|
||||
},
|
||||
fonts: vec!["Noto Sans".to_string()],
|
||||
header: None,
|
||||
footer: None,
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
@@ -269,6 +277,7 @@ fn test_compute_layout_multiple_children_ordering() {
|
||||
align: "stretch".to_string(),
|
||||
justify: "start".to_string(),
|
||||
style: ContainerStyle::default(),
|
||||
break_inside: "auto".to_string(),
|
||||
children: vec![
|
||||
TemplateElement::StaticText(StaticTextElement {
|
||||
id: "first".to_string(),
|
||||
|
||||
@@ -51,6 +51,8 @@ fn simple_template() -> Template {
|
||||
height: 297.0,
|
||||
},
|
||||
fonts: vec!["Noto Sans".to_string()],
|
||||
header: None,
|
||||
footer: None,
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
@@ -66,6 +68,7 @@ fn simple_template() -> Template {
|
||||
align: "stretch".to_string(),
|
||||
justify: "start".to_string(),
|
||||
style: ContainerStyle::default(),
|
||||
break_inside: "auto".to_string(),
|
||||
children: vec![TemplateElement::StaticText(StaticTextElement {
|
||||
id: "title".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
@@ -118,6 +121,8 @@ fn test_render_pdf_with_multiple_elements() {
|
||||
height: 297.0,
|
||||
},
|
||||
fonts: vec!["Noto Sans".to_string()],
|
||||
header: None,
|
||||
footer: None,
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
@@ -133,6 +138,7 @@ fn test_render_pdf_with_multiple_elements() {
|
||||
align: "stretch".to_string(),
|
||||
justify: "start".to_string(),
|
||||
style: ContainerStyle::default(),
|
||||
break_inside: "auto".to_string(),
|
||||
children: vec![
|
||||
TemplateElement::StaticText(StaticTextElement {
|
||||
id: "header".to_string(),
|
||||
@@ -207,6 +213,8 @@ fn test_render_pdf_with_container_styles() {
|
||||
height: 297.0,
|
||||
},
|
||||
fonts: vec!["Noto Sans".to_string()],
|
||||
header: None,
|
||||
footer: None,
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
@@ -227,6 +235,7 @@ fn test_render_pdf_with_container_styles() {
|
||||
border_width: Some(1.0),
|
||||
..Default::default()
|
||||
},
|
||||
break_inside: "auto".to_string(),
|
||||
children: vec![TemplateElement::StaticText(StaticTextElement {
|
||||
id: "text".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
@@ -254,3 +263,79 @@ fn test_render_pdf_with_container_styles() {
|
||||
assert!(!pdf_bytes.is_empty());
|
||||
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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user