mirror of
https://github.com/duhanbalci/dreport.git
synced 2026-07-01 18:39:16 +00:00
Compare commits
3 Commits
5ffc6d866c
...
e574889e5d
| Author | SHA1 | Date | |
|---|---|---|---|
| e574889e5d | |||
| 238e911875 | |||
| 09dc2b4ecd |
4
Cargo.lock
generated
4
Cargo.lock
generated
@@ -397,9 +397,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dexpr"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
source = "sparse+https://gitea.duhanbalci.com/api/packages/duhanbalci/cargo/"
|
||||
checksum = "37e0a98f2810bb770c76ef1e99d07066a15997086f9ead93917a82711274af25"
|
||||
checksum = "e65e74adffaab8b52681e3e3e5006365f0f8c5e3e07870cbd58ca74769eb150a"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"indexmap",
|
||||
|
||||
@@ -657,7 +657,7 @@ pub fn load_test_fonts() -> Vec<FontData> { ... }
|
||||
|
||||
## 7. Yeni Ozellik Onerileri
|
||||
|
||||
### 7.1 Conditional Rendering `[IMPLEMENTE EDILMEDI]`
|
||||
### 7.1 Conditional Rendering `[IMPLEMENTE EDILDI]`
|
||||
|
||||
**Aciklama:**
|
||||
Template'te `v-if` benzeri kosullu gosterim. Data'daki bir alana gore eleman goster/gizle.
|
||||
@@ -714,7 +714,7 @@ Tablo disinda array verisiyle tekrarlayan serbest-form container. Ornegin bir ka
|
||||
|
||||
---
|
||||
|
||||
### 7.5 Coklu Dil / Lokalizasyon Destegi `[IMPLEMENTE EDILMEDI]`
|
||||
### 7.5 Coklu Dil / Lokalizasyon Destegi `[IMPLEMENTE EDILDI]`
|
||||
|
||||
**Aciklama:**
|
||||
Currency, date ve sayi formatlama icin lokalizasyon. Su an Turk lokali hardcoded.
|
||||
@@ -745,7 +745,7 @@ Template'te header/footer tanimi icin `condition` alani:
|
||||
|
||||
---
|
||||
|
||||
### 7.7 QR Code Eleman Tipi `[IMPLEMENTE EDILMEDI]`
|
||||
### 7.7 QR Code Eleman Tipi `[bu var zaten, barcode özelliklerinden barkod tipi seçilebiliyor qr olarak]`
|
||||
|
||||
**Mevcut Durum:**
|
||||
`rxing` crate'i barcode uretimi icin zaten kullaniliyor ve QR Code destegi var. Ancak UI tarafinda ayri bir QR Code eleman tipi tanimlanmamis.
|
||||
@@ -773,7 +773,7 @@ Hazir sablon galerisi — kullanici sifirdan tasarlamak yerine bir sablon secip
|
||||
|
||||
## 8. Kucuk Ama Degerli Iyilestirmeler
|
||||
|
||||
### 8.1 Chart Legend Tek Seri Durumu `[IMPLEMENTE EDILMEDI]`
|
||||
### 8.1 Chart Legend Tek Seri Durumu `[IMPLEMENTE EDILDI]`
|
||||
|
||||
**Dosya:** `layout-engine/src/chart_render.rs`
|
||||
|
||||
@@ -781,7 +781,7 @@ Hazir sablon galerisi — kullanici sifirdan tasarlamak yerine bir sablon secip
|
||||
|
||||
---
|
||||
|
||||
### 8.2 Pie Chart Label Kontrolu `[IMPLEMENTE EDILMEDI]`
|
||||
### 8.2 Pie Chart Label Kontrolu `[IMPLEMENTE EDILDI]`
|
||||
|
||||
**Dosya:** `layout-engine/src/chart_render.rs` (satirlar 521-551)
|
||||
|
||||
|
||||
@@ -97,6 +97,20 @@ pub struct ContainerStyle {
|
||||
pub border_style: Option<String>,
|
||||
}
|
||||
|
||||
// --- Condition (v-if benzeri koşullu gösterim) ---
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Condition {
|
||||
/// Data JSON'daki alan yolu (ör: "toplamlar.iskonto")
|
||||
pub path: String,
|
||||
/// Karşılaştırma operatörü: eq, neq, gt, gte, lt, lte, empty, not_empty
|
||||
pub operator: String,
|
||||
/// Karşılaştırılacak değer (empty/not_empty için gerekmez)
|
||||
#[serde(default)]
|
||||
pub value: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
// --- Binding ---
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -234,6 +248,8 @@ pub struct ChartStyle {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ChartElement {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub condition: Option<Condition>,
|
||||
pub position: PositionMode,
|
||||
pub size: SizeConstraint,
|
||||
pub chart_type: ChartType,
|
||||
@@ -340,6 +356,26 @@ impl TemplateElement {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn condition(&self) -> Option<&Condition> {
|
||||
match self {
|
||||
Self::Container(e) => e.condition.as_ref(),
|
||||
Self::StaticText(e) => e.condition.as_ref(),
|
||||
Self::Text(e) => e.condition.as_ref(),
|
||||
Self::Line(e) => e.condition.as_ref(),
|
||||
Self::RepeatingTable(e) => e.condition.as_ref(),
|
||||
Self::Image(e) => e.condition.as_ref(),
|
||||
Self::PageNumber(e) => e.condition.as_ref(),
|
||||
Self::Barcode(e) => e.condition.as_ref(),
|
||||
Self::PageBreak(e) => e.condition.as_ref(),
|
||||
Self::CurrentDate(e) => e.condition.as_ref(),
|
||||
Self::Shape(e) => e.condition.as_ref(),
|
||||
Self::Checkbox(e) => e.condition.as_ref(),
|
||||
Self::CalculatedText(e) => e.condition.as_ref(),
|
||||
Self::RichText(e) => e.condition.as_ref(),
|
||||
Self::Chart(e) => e.condition.as_ref(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn size(&self) -> &SizeConstraint {
|
||||
static DEFAULT_SIZE: SizeConstraint = SizeConstraint {
|
||||
width: SizeValue::Auto,
|
||||
@@ -373,6 +409,8 @@ impl TemplateElement {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RichTextElement {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub condition: Option<Condition>,
|
||||
pub position: PositionMode,
|
||||
pub size: SizeConstraint,
|
||||
#[serde(default)]
|
||||
@@ -385,6 +423,8 @@ pub struct RichTextElement {
|
||||
pub struct ContainerElement {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub condition: Option<Condition>,
|
||||
#[serde(default)]
|
||||
pub position: PositionMode,
|
||||
#[serde(default)]
|
||||
pub size: SizeConstraint,
|
||||
@@ -424,6 +464,8 @@ fn default_start() -> String {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StaticTextElement {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub condition: Option<Condition>,
|
||||
pub position: PositionMode,
|
||||
pub size: SizeConstraint,
|
||||
pub style: TextStyle,
|
||||
@@ -434,6 +476,8 @@ pub struct StaticTextElement {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TextElement {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub condition: Option<Condition>,
|
||||
pub position: PositionMode,
|
||||
pub size: SizeConstraint,
|
||||
pub style: TextStyle,
|
||||
@@ -445,6 +489,8 @@ pub struct TextElement {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LineElement {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub condition: Option<Condition>,
|
||||
pub position: PositionMode,
|
||||
pub size: SizeConstraint,
|
||||
pub style: LineStyle,
|
||||
@@ -454,6 +500,8 @@ pub struct LineElement {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ImageElement {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub condition: Option<Condition>,
|
||||
pub position: PositionMode,
|
||||
pub size: SizeConstraint,
|
||||
pub src: Option<String>,
|
||||
@@ -465,6 +513,8 @@ pub struct ImageElement {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PageNumberElement {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub condition: Option<Condition>,
|
||||
pub position: PositionMode,
|
||||
pub size: SizeConstraint,
|
||||
pub style: TextStyle,
|
||||
@@ -475,6 +525,8 @@ pub struct PageNumberElement {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BarcodeElement {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub condition: Option<Condition>,
|
||||
pub position: PositionMode,
|
||||
pub size: SizeConstraint,
|
||||
pub format: String, // qr, ean13, ean8, code128, code39
|
||||
@@ -487,6 +539,8 @@ pub struct BarcodeElement {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RepeatingTableElement {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub condition: Option<Condition>,
|
||||
pub position: PositionMode,
|
||||
pub size: SizeConstraint,
|
||||
pub data_source: ArrayBinding,
|
||||
@@ -504,12 +558,16 @@ fn default_true() -> Option<bool> {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PageBreakElement {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub condition: Option<Condition>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CurrentDateElement {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub condition: Option<Condition>,
|
||||
pub position: PositionMode,
|
||||
pub size: SizeConstraint,
|
||||
pub style: TextStyle,
|
||||
@@ -520,6 +578,8 @@ pub struct CurrentDateElement {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ShapeElement {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub condition: Option<Condition>,
|
||||
pub position: PositionMode,
|
||||
pub size: SizeConstraint,
|
||||
pub shape_type: String, // rectangle, ellipse, rounded_rectangle
|
||||
@@ -539,6 +599,8 @@ pub struct CheckboxStyle {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CheckboxElement {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub condition: Option<Condition>,
|
||||
pub position: PositionMode,
|
||||
pub size: SizeConstraint,
|
||||
pub checked: Option<bool>, // statik değer
|
||||
@@ -550,6 +612,8 @@ pub struct CheckboxElement {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CalculatedTextElement {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub condition: Option<Condition>,
|
||||
pub position: PositionMode,
|
||||
pub size: SizeConstraint,
|
||||
pub style: TextStyle,
|
||||
@@ -572,6 +636,10 @@ pub struct Template {
|
||||
pub root: ContainerElement,
|
||||
#[serde(default)]
|
||||
pub format_config: Option<FormatConfig>,
|
||||
/// Lokalizasyon: "tr-TR", "en-US", "de-DE", "fr-FR" vb.
|
||||
/// Belirtilirse ve format_config yoksa, locale'den FormatConfig türetilir.
|
||||
#[serde(default)]
|
||||
pub locale: Option<String>,
|
||||
}
|
||||
|
||||
/// Sayı/para birimi formatlama ayarları.
|
||||
@@ -617,3 +685,53 @@ impl Default for FormatConfig {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FormatConfig {
|
||||
/// Locale string'inden FormatConfig türet.
|
||||
/// Desteklenen locale'ler: tr-TR, en-US, de-DE, fr-FR.
|
||||
/// Bilinmeyen locale → Türk formatı (varsayılan).
|
||||
pub fn from_locale(locale: &str) -> Self {
|
||||
match locale {
|
||||
"en-US" | "en" => Self {
|
||||
thousands_separator: ",".to_string(),
|
||||
decimal_separator: ".".to_string(),
|
||||
currency_symbol: "$".to_string(),
|
||||
currency_position: "prefix".to_string(),
|
||||
},
|
||||
"de-DE" | "de" => Self {
|
||||
thousands_separator: ".".to_string(),
|
||||
decimal_separator: ",".to_string(),
|
||||
currency_symbol: "€".to_string(),
|
||||
currency_position: "suffix".to_string(),
|
||||
},
|
||||
"fr-FR" | "fr" => Self {
|
||||
thousands_separator: " ".to_string(),
|
||||
decimal_separator: ",".to_string(),
|
||||
currency_symbol: "€".to_string(),
|
||||
currency_position: "suffix".to_string(),
|
||||
},
|
||||
"en-GB" | "gb" => Self {
|
||||
thousands_separator: ",".to_string(),
|
||||
decimal_separator: ".".to_string(),
|
||||
currency_symbol: "£".to_string(),
|
||||
currency_position: "prefix".to_string(),
|
||||
},
|
||||
// tr-TR veya bilinmeyen → Türk formatı
|
||||
_ => Self::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Template {
|
||||
/// Template'in etkin FormatConfig'ini döndür.
|
||||
/// Öncelik: format_config > locale > varsayılan (tr-TR).
|
||||
pub fn effective_format_config(&self) -> FormatConfig {
|
||||
if let Some(ref fc) = self.format_config {
|
||||
fc.clone()
|
||||
} else if let Some(ref locale) = self.locale {
|
||||
FormatConfig::from_locale(locale)
|
||||
} else {
|
||||
FormatConfig::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,45 +129,24 @@ const sampleData: Record<string, unknown> = {
|
||||
telefon: '+90 216 444 0018',
|
||||
},
|
||||
kalemler: [
|
||||
{
|
||||
siraNo: 1,
|
||||
adi: 'Web Uygulama Gelistirme',
|
||||
miktar: 1,
|
||||
birim: 'Adet',
|
||||
birimFiyat: 45000,
|
||||
tutar: 45000,
|
||||
},
|
||||
{
|
||||
siraNo: 2,
|
||||
adi: 'Mobil Uygulama Gelistirme',
|
||||
miktar: 1,
|
||||
birim: 'Adet',
|
||||
birimFiyat: 35000,
|
||||
tutar: 35000,
|
||||
},
|
||||
{
|
||||
siraNo: 3,
|
||||
adi: 'UI/UX Tasarim Hizmeti',
|
||||
miktar: 40,
|
||||
birim: 'Saat',
|
||||
birimFiyat: 750,
|
||||
tutar: 30000,
|
||||
},
|
||||
{
|
||||
siraNo: 4,
|
||||
adi: 'Sunucu Bakim Sozlesmesi (Yillik)',
|
||||
miktar: 1,
|
||||
birim: 'Adet',
|
||||
birimFiyat: 12000,
|
||||
tutar: 12000,
|
||||
},
|
||||
{ siraNo: 1, adi: 'Web Uygulama Gelistirme', miktar: 1, birim: 'Adet', birimFiyat: 45000, tutar: 45000 },
|
||||
{ siraNo: 2, adi: 'Mobil Uygulama Gelistirme', miktar: 1, birim: 'Adet', birimFiyat: 35000, tutar: 35000 },
|
||||
{ siraNo: 3, adi: 'UI/UX Tasarim Hizmeti', miktar: 40, birim: 'Saat', birimFiyat: 750, tutar: 30000 },
|
||||
{ siraNo: 4, adi: 'Sunucu Bakim Sozlesmesi (Yillik)', miktar: 1, birim: 'Adet', birimFiyat: 12000, tutar: 12000 },
|
||||
{ siraNo: 5, adi: 'SSL Sertifikasi', miktar: 3, birim: 'Adet', birimFiyat: 500, tutar: 1500 },
|
||||
{ siraNo: 6, adi: 'Veritabani Yonetimi', miktar: 12, birim: 'Ay', birimFiyat: 2000, tutar: 24000 },
|
||||
{ siraNo: 7, adi: 'API Entegrasyon Hizmeti', miktar: 1, birim: 'Adet', birimFiyat: 18000, tutar: 18000 },
|
||||
{ siraNo: 8, adi: 'Bulut Altyapi Kurulumu', miktar: 1, birim: 'Adet', birimFiyat: 8000, tutar: 8000 },
|
||||
{ siraNo: 9, adi: 'Siber Guvenlik Danismanligi', miktar: 20, birim: 'Saat', birimFiyat: 900, tutar: 18000 },
|
||||
{ siraNo: 10, adi: 'E-posta Sunucu Yapilandirmasi', miktar: 1, birim: 'Adet', birimFiyat: 3500, tutar: 3500 },
|
||||
{ siraNo: 11, adi: 'Yedekleme Sistemi Kurulumu', miktar: 1, birim: 'Adet', birimFiyat: 5000, tutar: 5000 },
|
||||
{ siraNo: 12, adi: 'SEO Optimizasyonu', miktar: 1, birim: 'Adet', birimFiyat: 7500, tutar: 7500 },
|
||||
{ siraNo: 13, adi: 'Egitim ve Dokumantasyon', miktar: 8, birim: 'Saat', birimFiyat: 600, tutar: 4800 },
|
||||
{ siraNo: 14, adi: 'Performans Testi ve Raporlama', miktar: 1, birim: 'Adet', birimFiyat: 6000, tutar: 6000 },
|
||||
{ siraNo: 15, adi: 'Teknik Destek Paketi (6 Ay)', miktar: 1, birim: 'Adet', birimFiyat: 9000, tutar: 9000 },
|
||||
],
|
||||
toplamlar: {
|
||||
araToplam: 123500,
|
||||
kdvOrani: 20,
|
||||
kdv: 24700,
|
||||
genelToplam: 148200,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -480,22 +459,66 @@ const defaultInvoiceTemplate: Template = {
|
||||
style: { borderColor: '#e2e8f0', borderWidth: 0.5 },
|
||||
children: [
|
||||
{
|
||||
id: 'el_ara_toplam',
|
||||
type: 'text',
|
||||
id: 'c_ara_toplam_row',
|
||||
type: 'container',
|
||||
position: { type: 'flow' },
|
||||
size: { width: sz.auto(), height: sz.auto() },
|
||||
style: { fontSize: 10, color: '#333333', align: 'right' },
|
||||
content: 'Ara Toplam: ',
|
||||
binding: { type: 'scalar', path: 'toplamlar.araToplam' },
|
||||
size: { width: sz.fr(), height: sz.auto() },
|
||||
direction: 'row',
|
||||
gap: 2,
|
||||
padding: { top: 0, right: 0, bottom: 0, left: 0 },
|
||||
align: 'center',
|
||||
justify: 'space-between',
|
||||
style: {},
|
||||
children: [
|
||||
{
|
||||
id: 'el_ara_toplam_label',
|
||||
type: 'static_text',
|
||||
position: { type: 'flow' },
|
||||
size: { width: sz.auto(), height: sz.auto() },
|
||||
style: { fontSize: 10, color: '#333333' },
|
||||
content: 'Ara Toplam:',
|
||||
},
|
||||
{
|
||||
id: 'el_ara_toplam',
|
||||
type: 'calculated_text',
|
||||
position: { type: 'flow' },
|
||||
size: { width: sz.auto(), height: sz.auto() },
|
||||
style: { fontSize: 10, color: '#333333', align: 'right' },
|
||||
expression: 'kalemler.tutar.sum()',
|
||||
format: 'currency',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'el_kdv',
|
||||
type: 'text',
|
||||
id: 'c_kdv_row',
|
||||
type: 'container',
|
||||
position: { type: 'flow' },
|
||||
size: { width: sz.auto(), height: sz.auto() },
|
||||
style: { fontSize: 10, color: '#333333', align: 'right' },
|
||||
content: 'KDV (%20): ',
|
||||
binding: { type: 'scalar', path: 'toplamlar.kdv' },
|
||||
size: { width: sz.fr(), height: sz.auto() },
|
||||
direction: 'row',
|
||||
gap: 2,
|
||||
padding: { top: 0, right: 0, bottom: 0, left: 0 },
|
||||
align: 'center',
|
||||
justify: 'space-between',
|
||||
style: {},
|
||||
children: [
|
||||
{
|
||||
id: 'el_kdv_label',
|
||||
type: 'static_text',
|
||||
position: { type: 'flow' },
|
||||
size: { width: sz.auto(), height: sz.auto() },
|
||||
style: { fontSize: 10, color: '#333333' },
|
||||
content: 'KDV (%20):',
|
||||
},
|
||||
{
|
||||
id: 'el_kdv',
|
||||
type: 'calculated_text',
|
||||
position: { type: 'flow' },
|
||||
size: { width: sz.auto(), height: sz.auto() },
|
||||
style: { fontSize: 10, color: '#333333', align: 'right' },
|
||||
expression: 'kalemler.tutar.sum() * toplamlar.kdvOrani / 100',
|
||||
format: 'currency',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'el_cizgi_2',
|
||||
@@ -505,13 +528,35 @@ const defaultInvoiceTemplate: Template = {
|
||||
style: { strokeColor: '#1e293b', strokeWidth: 1 },
|
||||
},
|
||||
{
|
||||
id: 'el_genel_toplam',
|
||||
type: 'text',
|
||||
id: 'c_genel_toplam_row',
|
||||
type: 'container',
|
||||
position: { type: 'flow' },
|
||||
size: { width: sz.auto(), height: sz.auto() },
|
||||
style: { fontSize: 12, fontWeight: 'bold', color: '#1a1a1a', align: 'right' },
|
||||
content: 'GENEL TOPLAM: ',
|
||||
binding: { type: 'scalar', path: 'toplamlar.genelToplam' },
|
||||
size: { width: sz.fr(), height: sz.auto() },
|
||||
direction: 'row',
|
||||
gap: 2,
|
||||
padding: { top: 0, right: 0, bottom: 0, left: 0 },
|
||||
align: 'center',
|
||||
justify: 'space-between',
|
||||
style: {},
|
||||
children: [
|
||||
{
|
||||
id: 'el_genel_toplam_label',
|
||||
type: 'static_text',
|
||||
position: { type: 'flow' },
|
||||
size: { width: sz.auto(), height: sz.auto() },
|
||||
style: { fontSize: 12, fontWeight: 'bold', color: '#1a1a1a' },
|
||||
content: 'GENEL TOPLAM:',
|
||||
},
|
||||
{
|
||||
id: 'el_genel_toplam',
|
||||
type: 'calculated_text',
|
||||
position: { type: 'flow' },
|
||||
size: { width: sz.auto(), height: sz.auto() },
|
||||
style: { fontSize: 12, fontWeight: 'bold', color: '#1a1a1a', align: 'right' },
|
||||
expression: 'kalemler.tutar.sum() * (1 + toplamlar.kdvOrani / 100)',
|
||||
format: 'currency',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useLayoutEngine } from '../../composables/useLayoutEngine'
|
||||
import LayoutRenderer from './LayoutRenderer.vue'
|
||||
import InteractionOverlay from './InteractionOverlay.vue'
|
||||
import RulerBar from './RulerBar.vue'
|
||||
import MinimapOverlay from './MinimapOverlay.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -23,6 +24,7 @@ const { template, mockData, layoutVersion } = storeToRefs(templateStore)
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const containerWidth = ref(800)
|
||||
const containerHeight = ref(600)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'compile-error': [error: string | null]
|
||||
@@ -43,9 +45,23 @@ provide('generateBarcode', generateBarcode)
|
||||
|
||||
watch(error, (val) => emit('compile-error', val))
|
||||
|
||||
// mm → px dönüşüm katsayısı
|
||||
// ============================================================
|
||||
// Zoom gesture: CSS transform ile anlık geri bildirim,
|
||||
// debounce ile gerçek scale commit
|
||||
// ============================================================
|
||||
|
||||
// committedZoom: son commit edilen zoom seviyesi (bu değer scale'i belirler)
|
||||
const committedZoom = ref(editorStore.zoom)
|
||||
// Gesture sırasında hedef zoom/pan (henüz commit edilmedi)
|
||||
const gestureZoom = ref(editorStore.zoom)
|
||||
const gesturePanX = ref(editorStore.panX)
|
||||
const gesturePanY = ref(editorStore.panY)
|
||||
const isZoomGesture = ref(false)
|
||||
let zoomCommitTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// mm → px dönüşüm katsayısı (committed zoom'a bağlı)
|
||||
const scale = computed(() => {
|
||||
return (containerWidth.value / templateStore.template.page.width) * editorStore.zoom
|
||||
return (containerWidth.value / templateStore.template.page.width) * committedZoom.value
|
||||
})
|
||||
|
||||
// Layout sayfaları
|
||||
@@ -54,7 +70,50 @@ 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
|
||||
// Görünür sayfa indeksleri — viewport dışındaki sayfaların DOM elemanları render edilmez
|
||||
// Stabil: sadece gerçek indeksler değiştiğinde yeni Set oluştur
|
||||
const _lastVisibleKey = ref('')
|
||||
const _lastVisibleSet = ref(new Set<number>([0]))
|
||||
|
||||
const visiblePageIndices = computed(() => {
|
||||
// Gesture sırasında gesture değerlerini, yoksa store değerlerini kullan
|
||||
const currentPanY = isZoomGesture.value ? gesturePanY.value : editorStore.panY
|
||||
const currentZoom = isZoomGesture.value ? gestureZoom.value : editorStore.zoom
|
||||
const baseScale = containerWidth.value / templateStore.template.page.width
|
||||
const currentScale = baseScale * currentZoom
|
||||
const pageH = templateStore.template.page.height * currentScale
|
||||
const gap = 24
|
||||
const count = layoutPages.value.length
|
||||
if (count === 0) return _lastVisibleSet.value
|
||||
|
||||
const pagesTop = 60 + currentPanY
|
||||
const viewH = containerHeight.value
|
||||
|
||||
const indices: number[] = []
|
||||
for (let i = 0; i < count; i++) {
|
||||
const pageTop = pagesTop + i * (pageH + gap)
|
||||
const pageBottom = pageTop + pageH
|
||||
const buffer = pageH
|
||||
if (pageBottom > -buffer && pageTop < viewH + buffer) {
|
||||
indices.push(i)
|
||||
}
|
||||
}
|
||||
|
||||
const key = indices.join(',')
|
||||
if (key !== _lastVisibleKey.value) {
|
||||
_lastVisibleKey.value = key
|
||||
_lastVisibleSet.value = new Set(indices)
|
||||
}
|
||||
return _lastVisibleSet.value
|
||||
})
|
||||
|
||||
// CSS transform zoom oranı — gesture sırasında visual feedback
|
||||
const zoomCssRatio = computed(() => {
|
||||
if (!isZoomGesture.value) return 1
|
||||
return gestureZoom.value / committedZoom.value
|
||||
})
|
||||
|
||||
// Sayfalar container stili — committed scale'e göre
|
||||
const pagesContainerStyle = computed(() => {
|
||||
const w = templateStore.template.page.width * scale.value
|
||||
const m = templateStore.template.root.padding
|
||||
@@ -66,6 +125,7 @@ const pagesContainerStyle = computed(() => {
|
||||
height: `${totalH}px`,
|
||||
position: 'relative' as const,
|
||||
flexShrink: 0,
|
||||
willChange: 'transform' as const,
|
||||
'--page-margin-top': `${m.top * scale.value}px`,
|
||||
'--page-margin-right': `${m.right * scale.value}px`,
|
||||
'--page-margin-bottom': `${m.bottom * scale.value}px`,
|
||||
@@ -73,12 +133,94 @@ const pagesContainerStyle = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
// Pan transform — sayfa container'ına uygulanacak
|
||||
const panTransform = computed(() => {
|
||||
if (editorStore.panX === 0 && editorStore.panY === 0) return undefined
|
||||
return `translate(${editorStore.panX}px, ${editorStore.panY}px)`
|
||||
// Pan sınırları
|
||||
function clampPan(x: number, y: number, zoomOverride?: number): [number, number] {
|
||||
const z = zoomOverride ?? committedZoom.value
|
||||
const baseScale = containerWidth.value / templateStore.template.page.width
|
||||
const s = baseScale * z
|
||||
const pageW = templateStore.template.page.width * s
|
||||
const pageCount = Math.max(1, layoutPages.value.length)
|
||||
const pageGap = 24
|
||||
const phPx = templateStore.template.page.height * s
|
||||
const totalH = phPx * pageCount + pageGap * (pageCount - 1)
|
||||
|
||||
const viewH = (containerRef.value?.clientHeight ?? 600) - 60 - 40
|
||||
|
||||
const clampX = pageW / 2
|
||||
const maxY = viewH * 0.5
|
||||
const minY = viewH * 0.5 - totalH
|
||||
|
||||
return [
|
||||
Math.max(-clampX, Math.min(clampX, x)),
|
||||
Math.max(minY, Math.min(maxY, y)),
|
||||
]
|
||||
}
|
||||
|
||||
// Pages container transform — pan + gesture zoom CSS scale
|
||||
const pagesTransform = computed(() => {
|
||||
const ratio = zoomCssRatio.value
|
||||
const panX = isZoomGesture.value ? gesturePanX.value : editorStore.panX
|
||||
const panY = isZoomGesture.value ? gesturePanY.value : editorStore.panY
|
||||
|
||||
if (ratio === 1) {
|
||||
if (panX === 0 && panY === 0) return undefined
|
||||
return `translate(${panX}px, ${panY}px)`
|
||||
}
|
||||
|
||||
// Scale from top-left (0 0). Centering düzeltmesi:
|
||||
// Flex container ortalar → naturalLeft = (containerW - w) / 2
|
||||
// Scale sonrası visual width = w * ratio, visual center kayar
|
||||
// Düzeltme: tx += w * (1 - ratio) / 2
|
||||
const w = templateStore.template.page.width * scale.value
|
||||
const centerCorrection = w * (1 - ratio) / 2
|
||||
const tx = panX + centerCorrection
|
||||
const ty = panY
|
||||
|
||||
return `translate(${tx}px, ${ty}px) scale(${ratio})`
|
||||
})
|
||||
|
||||
const pagesTransformOrigin = computed(() => {
|
||||
if (zoomCssRatio.value === 1) return undefined
|
||||
return '0 0'
|
||||
})
|
||||
|
||||
// Zoom commit: gesture sonunda gerçek scale'i güncelle
|
||||
function commitZoom() {
|
||||
const z = gestureZoom.value
|
||||
const px = gesturePanX.value
|
||||
let py = gesturePanY.value
|
||||
|
||||
const ratio = z / committedZoom.value
|
||||
const pageCount = layoutPages.value.length
|
||||
|
||||
// Gap düzeltmesi: CSS scale sırasında 24px gap'ler de ratio ile ölçekleniyor.
|
||||
// Commit sonrası gap'ler tekrar 24px'e dönüyor → dikey kayma.
|
||||
// Viewport merkezindeki sayfanın üstündeki gap sayısı × 24 × (ratio - 1) kadar düzelt.
|
||||
if (ratio !== 1 && pageCount > 1) {
|
||||
const pageH_dom = templateStore.template.page.height * scale.value // committed scale'de
|
||||
const strideVisual = (pageH_dom + 24) * ratio
|
||||
|
||||
// Viewport merkezinin container visual koordinatındaki Y pozisyonu
|
||||
const viewCenterY = containerHeight.value / 2 - 60 - py
|
||||
if (viewCenterY > 0 && strideVisual > 0) {
|
||||
const gapsAbove = Math.min(pageCount - 1, Math.max(0, Math.floor(viewCenterY / strideVisual)))
|
||||
py += gapsAbove * 24 * (ratio - 1)
|
||||
}
|
||||
}
|
||||
|
||||
committedZoom.value = z
|
||||
editorStore.setZoom(z)
|
||||
const [cx, cy] = clampPan(px, py, z)
|
||||
editorStore.setPan(cx, cy)
|
||||
isZoomGesture.value = false
|
||||
zoomCommitTimer = null
|
||||
}
|
||||
|
||||
function scheduleZoomCommit() {
|
||||
if (zoomCommitTimer) clearTimeout(zoomCommitTimer)
|
||||
zoomCommitTimer = setTimeout(commitZoom, 120)
|
||||
}
|
||||
|
||||
// Pan: Space+drag veya orta fare tuşu
|
||||
const isPanning = ref(false)
|
||||
const panStart = ref({ x: 0, y: 0 })
|
||||
@@ -98,7 +240,10 @@ onMounted(() => {
|
||||
if (containerRef.value) {
|
||||
resizeObserver = new ResizeObserver((entries) => {
|
||||
const entry = entries[0]
|
||||
if (entry) containerWidth.value = entry.contentRect.width
|
||||
if (entry) {
|
||||
containerWidth.value = entry.contentRect.width
|
||||
containerHeight.value = entry.contentRect.height
|
||||
}
|
||||
})
|
||||
resizeObserver.observe(containerRef.value)
|
||||
}
|
||||
@@ -109,10 +254,19 @@ onMounted(() => {
|
||||
onBeforeUnmount(() => {
|
||||
resizeObserver?.disconnect()
|
||||
dispose()
|
||||
if (zoomCommitTimer) clearTimeout(zoomCommitTimer)
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
window.removeEventListener('keyup', onKeyUp)
|
||||
})
|
||||
|
||||
// Store'daki zoom değiştiğinde (dışarıdan, ör. zoom butonları) committed'ı da güncelle
|
||||
watch(() => editorStore.zoom, (z) => {
|
||||
if (!isZoomGesture.value) {
|
||||
committedZoom.value = z
|
||||
gestureZoom.value = z
|
||||
}
|
||||
})
|
||||
|
||||
// Zoom & Pan via wheel/trackpad
|
||||
const pageRef = ref<HTMLElement | null>(null)
|
||||
|
||||
@@ -142,7 +296,17 @@ function onWheel(e: WheelEvent) {
|
||||
} else {
|
||||
// İki parmak pan (touchpad) veya normal scroll
|
||||
e.preventDefault()
|
||||
editorStore.setPan(editorStore.panX - e.deltaX, editorStore.panY - e.deltaY)
|
||||
const curPanX = isZoomGesture.value ? gesturePanX.value : editorStore.panX
|
||||
const curPanY = isZoomGesture.value ? gesturePanY.value : editorStore.panY
|
||||
const curZoom = isZoomGesture.value ? gestureZoom.value : editorStore.zoom
|
||||
const [cx, cy] = clampPan(curPanX - e.deltaX, curPanY - e.deltaY, curZoom)
|
||||
|
||||
if (isZoomGesture.value) {
|
||||
gesturePanX.value = cx
|
||||
gesturePanY.value = cy
|
||||
} else {
|
||||
editorStore.setPan(cx, cy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,29 +314,40 @@ function applyZoom(delta: number, clientX: number, clientY: number) {
|
||||
const pageEl = pageRef.value
|
||||
if (!pageEl) return
|
||||
|
||||
const oldZoom = editorStore.zoom
|
||||
// Gesture başlat veya devam et
|
||||
if (!isZoomGesture.value) {
|
||||
isZoomGesture.value = true
|
||||
gestureZoom.value = editorStore.zoom
|
||||
gesturePanX.value = editorStore.panX
|
||||
gesturePanY.value = editorStore.panY
|
||||
}
|
||||
|
||||
const oldZoom = gestureZoom.value
|
||||
const zoomFactor = Math.pow(0.99, delta)
|
||||
const newZoom = Math.max(0.25, Math.min(4, oldZoom * zoomFactor))
|
||||
if (newZoom === oldZoom) return
|
||||
|
||||
// Sayfa elemanının şu anki ekran pozisyonunu al (centering + pan dahil)
|
||||
const pageRect = pageEl.getBoundingClientRect()
|
||||
|
||||
// Mouse'un sayfa üzerindeki pozisyonu (mm cinsinden)
|
||||
// pageRef'in ekran pozisyonunu al (CSS transform dahil)
|
||||
const pageRect = pageEl.getBoundingClientRect()
|
||||
const baseScale = containerWidth.value / templateStore.template.page.width
|
||||
const oldScale = baseScale * oldZoom
|
||||
const newScale = baseScale * newZoom
|
||||
const mousePageMmX = (clientX - pageRect.left) / oldScale
|
||||
const mousePageMmY = (clientY - pageRect.top) / oldScale
|
||||
const oldGestureScale = baseScale * oldZoom
|
||||
const newGestureScale = baseScale * newZoom
|
||||
const mousePageMmX = (clientX - pageRect.left) / oldGestureScale
|
||||
const mousePageMmY = (clientY - pageRect.top) / oldGestureScale
|
||||
|
||||
const pageW = templateStore.template.page.width
|
||||
|
||||
// Yeni pan: mouse'un gösterdiği mm noktası aynı ekran pozisyonunda kalmalı
|
||||
const newPanX = editorStore.panX + (mousePageMmX - pageW / 2) * (oldScale - newScale)
|
||||
const newPanY = editorStore.panY + mousePageMmY * (oldScale - newScale)
|
||||
const newPanX = gesturePanX.value + (mousePageMmX - pageW / 2) * (oldGestureScale - newGestureScale)
|
||||
const newPanY = gesturePanY.value + mousePageMmY * (oldGestureScale - newGestureScale)
|
||||
|
||||
editorStore.setZoom(newZoom)
|
||||
editorStore.setPan(newPanX, newPanY)
|
||||
gestureZoom.value = newZoom
|
||||
const [cx, cy] = clampPan(newPanX, newPanY, newZoom)
|
||||
gesturePanX.value = cx
|
||||
gesturePanY.value = cy
|
||||
|
||||
scheduleZoomCommit()
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
@@ -208,7 +383,8 @@ function onPointerDown(e: PointerEvent) {
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (!isPanning.value) return
|
||||
editorStore.setPan(e.clientX - panStart.value.x, e.clientY - panStart.value.y)
|
||||
const [cx2, cy2] = clampPan(e.clientX - panStart.value.x, e.clientY - panStart.value.y)
|
||||
editorStore.setPan(cx2, cy2)
|
||||
}
|
||||
|
||||
function onPointerUp(e: PointerEvent) {
|
||||
@@ -217,6 +393,17 @@ function onPointerUp(e: PointerEvent) {
|
||||
;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId)
|
||||
}
|
||||
}
|
||||
|
||||
function onMinimapNavigate(x: number, y: number) {
|
||||
const [cx, cy] = clampPan(x, y)
|
||||
editorStore.setPan(cx, cy)
|
||||
}
|
||||
|
||||
// Minimap'e gerçek scale'i geçir (gesture dahil)
|
||||
const minimapScale = computed(() => {
|
||||
const z = isZoomGesture.value ? gestureZoom.value : editorStore.zoom
|
||||
return (containerWidth.value / templateStore.template.page.width) * z
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -225,9 +412,12 @@ function onPointerUp(e: PointerEvent) {
|
||||
<RulerBar
|
||||
:page-width="templateStore.template.page.width"
|
||||
:page-height="templateStore.template.page.height"
|
||||
:scale="scale"
|
||||
:pan-x="editorStore.panX"
|
||||
:pan-y="editorStore.panY"
|
||||
:scale="minimapScale"
|
||||
:pan-x="isZoomGesture ? gesturePanX : editorStore.panX"
|
||||
:pan-y="isZoomGesture ? gesturePanY : editorStore.panY"
|
||||
:container-width="containerWidth"
|
||||
:page-count="layoutPages.length"
|
||||
:page-gap="24"
|
||||
/>
|
||||
|
||||
<!-- Scroll alanı -->
|
||||
@@ -244,9 +434,13 @@ function onPointerUp(e: PointerEvent) {
|
||||
<div
|
||||
ref="pageRef"
|
||||
class="editor-canvas__pages"
|
||||
:style="[pagesContainerStyle, panTransform ? { transform: panTransform } : {}]"
|
||||
:style="[
|
||||
pagesContainerStyle,
|
||||
pagesTransform ? { transform: pagesTransform } : {},
|
||||
pagesTransformOrigin ? { transformOrigin: pagesTransformOrigin } : {},
|
||||
]"
|
||||
>
|
||||
<LayoutRenderer :layout="layout" :scale="scale" />
|
||||
<LayoutRenderer :layout="layout" :scale="scale" :visible-page-indices="visiblePageIndices" />
|
||||
<InteractionOverlay
|
||||
:scale="scale"
|
||||
:layout-map="layoutMap"
|
||||
@@ -261,7 +455,24 @@ function onPointerUp(e: PointerEvent) {
|
||||
{{ error }}
|
||||
</div>
|
||||
<div v-if="compiling" class="editor-canvas__compiling">Derleniyor...</div>
|
||||
<div class="editor-canvas__zoom">%{{ editorStore.zoomPercent }}</div>
|
||||
|
||||
<!-- Minimap + zoom göstergesi -->
|
||||
<div class="editor-canvas__minimap-area">
|
||||
<MinimapOverlay
|
||||
:layout="layout"
|
||||
:page-width="templateStore.template.page.width"
|
||||
:page-height="templateStore.template.page.height"
|
||||
:zoom="isZoomGesture ? gestureZoom : editorStore.zoom"
|
||||
:pan-x="isZoomGesture ? gesturePanX : editorStore.panX"
|
||||
:pan-y="isZoomGesture ? gesturePanY : editorStore.panY"
|
||||
:container-width="containerWidth"
|
||||
:container-height="containerHeight"
|
||||
:scale="minimapScale"
|
||||
:page-gap="24"
|
||||
@navigate="onMinimapNavigate"
|
||||
/>
|
||||
<div class="editor-canvas__zoom">%{{ Math.round((isZoomGesture ? gestureZoom : editorStore.zoom) * 100) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -318,15 +529,22 @@ function onPointerUp(e: PointerEvent) {
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.editor-canvas__zoom {
|
||||
.editor-canvas__minimap-area {
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
right: 16px;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.editor-canvas__zoom {
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
z-index: 100;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { ElementLayout, PageLayout, LayoutResult } from '../../core/layout-
|
||||
const props = defineProps<{
|
||||
layout: LayoutResult | null
|
||||
scale: number
|
||||
visiblePageIndices?: Set<number>
|
||||
}>()
|
||||
|
||||
// WASM barcode üretme fonksiyonu (EditorCanvas'tan provide edilir)
|
||||
@@ -196,7 +197,7 @@ watch(
|
||||
class="layout-page"
|
||||
:style="pageContainerStyle(page)"
|
||||
>
|
||||
<template v-for="el in page.elements" :key="el.id">
|
||||
<template v-if="!visiblePageIndices || visiblePageIndices.has(pageIdx)" v-for="el in page.elements" :key="el.id">
|
||||
<!-- Page break: dashed horizontal line -->
|
||||
<div
|
||||
v-if="el.element_type === 'page_break'"
|
||||
|
||||
442
frontend/src/components/editor/MinimapOverlay.vue
Normal file
442
frontend/src/components/editor/MinimapOverlay.vue
Normal file
@@ -0,0 +1,442 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, nextTick } from 'vue'
|
||||
import type { LayoutResult } from '../../core/layout-types'
|
||||
|
||||
const props = defineProps<{
|
||||
layout: LayoutResult | null
|
||||
pageWidth: number // mm
|
||||
pageHeight: number // mm
|
||||
zoom: number
|
||||
panX: number
|
||||
panY: number
|
||||
containerWidth: number // px — editor canvas container genişliği
|
||||
containerHeight: number // px — editor canvas container yüksekliği
|
||||
scale: number // mm → px (zoom dahil)
|
||||
pageGap: number // px — sayfalar arası boşluk
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
navigate: [x: number, y: number]
|
||||
}>()
|
||||
|
||||
const MAX_MINIMAP_WIDTH = 140
|
||||
const MAX_EXPANDED_HEIGHT = 300
|
||||
const PADDING = 6
|
||||
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
const scrollRef = ref<HTMLElement | null>(null)
|
||||
const isHovered = ref(false)
|
||||
const isPointerDragging = ref(false)
|
||||
|
||||
// Offscreen canvas — sayfa içeriği cache'i (layout değiştiğinde yeniden çizilir)
|
||||
let contentCanvas: OffscreenCanvas | null = null
|
||||
let contentDirty = true
|
||||
|
||||
const pageCount = computed(() => Math.max(1, props.layout?.pages.length ?? 1))
|
||||
|
||||
// Minimap'te sayfalar arası sabit piksel boşluk
|
||||
const MINIMAP_PAGE_GAP_PX = 4
|
||||
|
||||
// Editördeki toplam yükseklik (mm, viewport hesabı için)
|
||||
const totalHeightMm = computed(() => {
|
||||
const gapMm = props.pageGap / props.scale
|
||||
return props.pageHeight * pageCount.value + gapMm * (pageCount.value - 1)
|
||||
})
|
||||
|
||||
const minimapScale = computed(() => (MAX_MINIMAP_WIDTH - PADDING * 2) / props.pageWidth)
|
||||
|
||||
const pageHeightPx = computed(() => props.pageHeight * minimapScale.value)
|
||||
|
||||
const canvasWidth = computed(() => props.pageWidth * minimapScale.value + PADDING * 2)
|
||||
const canvasHeight = computed(() => {
|
||||
const n = pageCount.value
|
||||
return pageHeightPx.value * n + MINIMAP_PAGE_GAP_PX * (n - 1) + PADDING * 2
|
||||
})
|
||||
|
||||
const singlePageMinimapH = computed(() => pageHeightPx.value + PADDING * 2)
|
||||
|
||||
// Editördeki gap'in mm karşılığı (activePageIndex hesabı için)
|
||||
const editorGapMm = computed(() => props.pageGap / props.scale)
|
||||
|
||||
const activePageIndex = computed(() => {
|
||||
const viewH = props.containerHeight - 60 - 40
|
||||
const viewCenterMm = (-props.panY + viewH / 2) / props.scale
|
||||
const stride = props.pageHeight + editorGapMm.value
|
||||
const idx = Math.floor(viewCenterMm / stride)
|
||||
return Math.max(0, Math.min(pageCount.value - 1, idx))
|
||||
})
|
||||
|
||||
const visibleHeight = computed(() => {
|
||||
if (isHovered.value || isPointerDragging.value) {
|
||||
return Math.min(canvasHeight.value, MAX_EXPANDED_HEIGHT)
|
||||
}
|
||||
return Math.min(singlePageMinimapH.value, canvasHeight.value)
|
||||
})
|
||||
|
||||
/** Sayfanın canvas üzerindeki Y pozisyonu (px) */
|
||||
function pageTopOnCanvas(pageIdx: number): number {
|
||||
return PADDING + pageIdx * (pageHeightPx.value + MINIMAP_PAGE_GAP_PX)
|
||||
}
|
||||
|
||||
const targetScrollTop = computed(() => {
|
||||
if (isHovered.value || isPointerDragging.value) {
|
||||
const vp = viewportRect.value
|
||||
const vpCenter = vp.y + vp.h / 2
|
||||
const half = visibleHeight.value / 2
|
||||
const maxScroll = canvasHeight.value - visibleHeight.value
|
||||
return Math.max(0, Math.min(maxScroll, vpCenter - half))
|
||||
}
|
||||
const top = pageTopOnCanvas(activePageIndex.value) - PADDING
|
||||
const maxScroll = canvasHeight.value - visibleHeight.value
|
||||
return Math.max(0, Math.min(maxScroll, top))
|
||||
})
|
||||
|
||||
/** Editör mm koordinatını minimap canvas px'e çevir (Y ekseni, sayfa gap'leri hesaba katarak) */
|
||||
function mmYToCanvasPx(mmY: number): number {
|
||||
const gapMm = editorGapMm.value
|
||||
const stride = props.pageHeight + gapMm
|
||||
const pageIdx = Math.min(pageCount.value - 1, Math.max(0, Math.floor(mmY / stride)))
|
||||
const withinPageMm = mmY - pageIdx * stride
|
||||
return pageTopOnCanvas(pageIdx) + withinPageMm * minimapScale.value
|
||||
}
|
||||
|
||||
const viewportRect = computed(() => {
|
||||
const s = minimapScale.value
|
||||
const pageWidthPx = props.pageWidth * props.scale
|
||||
const pageLeftPx = (props.containerWidth - pageWidthPx) / 2 + props.panX
|
||||
const pageTopPx = props.panY
|
||||
|
||||
const viewW = props.containerWidth
|
||||
const viewH = props.containerHeight - 60 - 40
|
||||
|
||||
const visLeftMm = -pageLeftPx / props.scale
|
||||
const visTopMm = -pageTopPx / props.scale
|
||||
const visWidthMm = viewW / props.scale
|
||||
const visHeightMm = viewH / props.scale
|
||||
|
||||
// Clamp to page boundaries
|
||||
const clampedLeft = Math.max(0, visLeftMm)
|
||||
const clampedTop = Math.max(0, visTopMm)
|
||||
const clampedRight = Math.min(props.pageWidth, visLeftMm + visWidthMm)
|
||||
const clampedBottom = Math.min(totalHeightMm.value, visTopMm + visHeightMm)
|
||||
|
||||
const y1 = mmYToCanvasPx(clampedTop)
|
||||
const y2 = mmYToCanvasPx(clampedBottom)
|
||||
|
||||
return {
|
||||
x: PADDING + clampedLeft * s,
|
||||
y: y1,
|
||||
w: Math.max(0, (clampedRight - clampedLeft) * s),
|
||||
h: Math.max(0, y2 - y1),
|
||||
}
|
||||
})
|
||||
|
||||
function elementColor(type: string): string {
|
||||
switch (type) {
|
||||
case 'text':
|
||||
case 'static_text':
|
||||
case 'rich_text':
|
||||
return '#93c5fd'
|
||||
case 'container':
|
||||
return '#c4b5fd'
|
||||
case 'repeating_table':
|
||||
return '#86efac'
|
||||
case 'image':
|
||||
return '#fdba74'
|
||||
case 'line':
|
||||
return '#9ca3af'
|
||||
case 'barcode':
|
||||
return '#fca5a5'
|
||||
case 'chart':
|
||||
return '#67e8f9'
|
||||
default:
|
||||
return '#d1d5db'
|
||||
}
|
||||
}
|
||||
|
||||
// --- İki aşamalı çizim: content (pahalı, cache'li) + viewport overlay (ucuz) ---
|
||||
|
||||
/** Sayfa içeriğini offscreen canvas'a çizer — sadece layout değiştiğinde çağrılır */
|
||||
function drawContent() {
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const w = canvasWidth.value
|
||||
const h = canvasHeight.value
|
||||
|
||||
if (!contentCanvas || contentCanvas.width !== Math.ceil(w * dpr) || contentCanvas.height !== Math.ceil(h * dpr)) {
|
||||
contentCanvas = new OffscreenCanvas(Math.ceil(w * dpr), Math.ceil(h * dpr))
|
||||
}
|
||||
|
||||
const ctx = contentCanvas.getContext('2d')!
|
||||
ctx.resetTransform()
|
||||
ctx.scale(dpr, dpr)
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
|
||||
const s = minimapScale.value
|
||||
const pages = props.layout?.pages ?? []
|
||||
|
||||
for (let i = 0; i < Math.max(1, pages.length); i++) {
|
||||
const px = PADDING
|
||||
const py = pageTopOnCanvas(i)
|
||||
const pw = props.pageWidth * s
|
||||
const ph = props.pageHeight * s
|
||||
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.fillRect(px, py, pw, ph)
|
||||
ctx.strokeStyle = '#d1d5db'
|
||||
ctx.lineWidth = 0.5
|
||||
ctx.strokeRect(px, py, pw, ph)
|
||||
|
||||
const page = pages[i]
|
||||
if (page) {
|
||||
for (const el of page.elements) {
|
||||
if (el.element_type === 'container') continue
|
||||
const ex = px + el.x_mm * s
|
||||
const ey = py + el.y_mm * s
|
||||
const ew = Math.max(1, el.width_mm * s)
|
||||
const eh = Math.max(1, el.height_mm * s)
|
||||
|
||||
ctx.fillStyle = elementColor(el.element_type)
|
||||
ctx.globalAlpha = 0.7
|
||||
ctx.fillRect(ex, ey, ew, eh)
|
||||
ctx.globalAlpha = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
contentDirty = false
|
||||
}
|
||||
|
||||
/** Ana canvas'a composite: cached content + viewport dikdörtgeni */
|
||||
function compose() {
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas) return
|
||||
|
||||
if (contentDirty || !contentCanvas) {
|
||||
drawContent()
|
||||
}
|
||||
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const w = canvasWidth.value
|
||||
const h = canvasHeight.value
|
||||
|
||||
canvas.width = Math.ceil(w * dpr)
|
||||
canvas.height = Math.ceil(h * dpr)
|
||||
canvas.style.width = `${w}px`
|
||||
canvas.style.height = `${h}px`
|
||||
|
||||
const ctx = canvas.getContext('2d')!
|
||||
ctx.resetTransform()
|
||||
|
||||
// Offscreen content'i kopyala (1:1 pixel, zaten dpr ölçekli)
|
||||
ctx.drawImage(contentCanvas!, 0, 0)
|
||||
|
||||
// Viewport dikdörtgenini çiz (dpr ölçekli)
|
||||
ctx.scale(dpr, dpr)
|
||||
const v = viewportRect.value
|
||||
ctx.strokeStyle = '#2563eb'
|
||||
ctx.lineWidth = 1.5
|
||||
ctx.strokeRect(v.x, v.y, v.w, v.h)
|
||||
ctx.fillStyle = 'rgba(37, 99, 235, 0.08)'
|
||||
ctx.fillRect(v.x, v.y, v.w, v.h)
|
||||
}
|
||||
|
||||
// rAF throttle — aynı frame'de birden fazla compose çağrısını engelle
|
||||
let composeRAF: number | null = null
|
||||
function scheduleCompose() {
|
||||
if (composeRAF !== null) return
|
||||
composeRAF = requestAnimationFrame(() => {
|
||||
composeRAF = null
|
||||
compose()
|
||||
})
|
||||
}
|
||||
|
||||
// --- Scroll yönetimi ---
|
||||
|
||||
function smoothScrollTo(target: number) {
|
||||
scrollRef.value?.scrollTo({ top: target, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
function jumpScrollTo(target: number) {
|
||||
if (scrollRef.value) scrollRef.value.scrollTop = target
|
||||
}
|
||||
|
||||
// --- Pointer etkileşimi ---
|
||||
|
||||
/** Canvas px → editör mm (sayfa gap dönüşümü dahil) */
|
||||
function canvasToMm(clientX: number, clientY: number): { mmX: number; mmY: number } {
|
||||
const canvas = canvasRef.value!
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const mx = clientX - rect.left - PADDING
|
||||
const my = clientY - rect.top - PADDING
|
||||
const s = minimapScale.value
|
||||
|
||||
// Y: canvas px'ten hangi sayfadayız bul, editör mm'e çevir
|
||||
const pageStridePx = pageHeightPx.value + MINIMAP_PAGE_GAP_PX
|
||||
const pageIdx = Math.min(pageCount.value - 1, Math.max(0, Math.floor(my / pageStridePx)))
|
||||
const withinPagePx = my - pageIdx * pageStridePx
|
||||
const withinPageMm = withinPagePx / s
|
||||
const editorStride = props.pageHeight + editorGapMm.value
|
||||
const mmY = pageIdx * editorStride + withinPageMm
|
||||
|
||||
return { mmX: mx / s, mmY }
|
||||
}
|
||||
|
||||
function navigateTo(clientX: number, clientY: number) {
|
||||
const { mmX, mmY } = canvasToMm(clientX, clientY)
|
||||
const viewW = props.containerWidth
|
||||
const viewH = props.containerHeight - 60 - 40
|
||||
const pageWidthPx = props.pageWidth * props.scale
|
||||
|
||||
const newPanX = -(mmX * props.scale) + viewW / 2 - (viewW - pageWidthPx) / 2
|
||||
const newPanY = -(mmY * props.scale) + viewH / 2
|
||||
|
||||
emit('navigate', newPanX, newPanY)
|
||||
}
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
isPointerDragging.value = true
|
||||
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
||||
navigateTo(e.clientX, e.clientY)
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (!isPointerDragging.value) return
|
||||
navigateTo(e.clientX, e.clientY)
|
||||
}
|
||||
|
||||
function onPointerUp(e: PointerEvent) {
|
||||
if (isPointerDragging.value) {
|
||||
isPointerDragging.value = false
|
||||
;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId)
|
||||
}
|
||||
}
|
||||
|
||||
function onMouseEnter() {
|
||||
isHovered.value = true
|
||||
}
|
||||
|
||||
function onMouseLeave() {
|
||||
if (!isPointerDragging.value) {
|
||||
isHovered.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(isPointerDragging, (dragging) => {
|
||||
if (!dragging) {
|
||||
nextTick(() => {
|
||||
const el = scrollRef.value
|
||||
if (el && !el.matches(':hover')) {
|
||||
isHovered.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Layout değiştiğinde content'i dirty işaretle + tam redraw
|
||||
watch(() => props.layout, () => {
|
||||
contentDirty = true
|
||||
scheduleCompose()
|
||||
}, { deep: true })
|
||||
|
||||
// Scale değiştiğinde (zoom) content'i de yeniden çizmek gerekir (gapMm değişir)
|
||||
watch(() => props.scale, () => {
|
||||
contentDirty = true
|
||||
scheduleCompose()
|
||||
})
|
||||
|
||||
// Pan değiştiğinde sadece viewport overlay'i yeniden çiz (ucuz)
|
||||
// Minimap drag sırasında scroll yapma — kullanıcı zaten sürükleyerek kontrol ediyor
|
||||
watch([() => props.panX, () => props.panY], () => {
|
||||
scheduleCompose()
|
||||
if (!isPointerDragging.value) {
|
||||
smoothScrollTo(targetScrollTop.value)
|
||||
}
|
||||
})
|
||||
|
||||
// Zoom değiştiğinde scroll da güncelle
|
||||
watch(() => props.zoom, () => {
|
||||
smoothScrollTo(targetScrollTop.value)
|
||||
})
|
||||
|
||||
// Container boyutu değiştiğinde
|
||||
watch([() => props.containerWidth, () => props.containerHeight], () => {
|
||||
scheduleCompose()
|
||||
})
|
||||
|
||||
// Hover/collapse durumu değiştiğinde
|
||||
watch([isHovered, isPointerDragging], () => {
|
||||
nextTick(() => {
|
||||
if (isHovered.value || isPointerDragging.value) {
|
||||
smoothScrollTo(targetScrollTop.value)
|
||||
} else {
|
||||
jumpScrollTo(targetScrollTop.value)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
drawContent()
|
||||
compose()
|
||||
jumpScrollTo(targetScrollTop.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="minimap"
|
||||
:class="{
|
||||
'minimap--expanded': isHovered || isPointerDragging,
|
||||
'minimap--dragging': isPointerDragging,
|
||||
}"
|
||||
:style="{ width: `${canvasWidth}px`, height: `${visibleHeight}px` }"
|
||||
@mouseenter="onMouseEnter"
|
||||
@mouseleave="onMouseLeave"
|
||||
>
|
||||
<div
|
||||
ref="scrollRef"
|
||||
class="minimap__scroll"
|
||||
:style="{ height: `${visibleHeight}px` }"
|
||||
>
|
||||
<canvas
|
||||
ref="canvasRef"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointermove="onPointerMove"
|
||||
@pointerup="onPointerUp"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.minimap {
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
|
||||
cursor: crosshair;
|
||||
user-select: none;
|
||||
backdrop-filter: blur(4px);
|
||||
overflow: hidden;
|
||||
transition: height 0.25s ease;
|
||||
}
|
||||
|
||||
.minimap--expanded {
|
||||
border-color: #93c5fd;
|
||||
box-shadow: 0 2px 12px rgba(37, 99, 235, 0.15);
|
||||
}
|
||||
|
||||
.minimap--dragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.minimap__scroll {
|
||||
overflow: hidden;
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
|
||||
.minimap__scroll canvas {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -12,6 +12,12 @@ const props = defineProps<{
|
||||
panX: number
|
||||
/** Pan offset Y (px) */
|
||||
panY: number
|
||||
/** editor-canvas content width (px) — ResizeObserver'dan */
|
||||
containerWidth: number
|
||||
/** Sayfa sayısı */
|
||||
pageCount: number
|
||||
/** Sayfalar arası boşluk (px) */
|
||||
pageGap?: number
|
||||
/** Cetvel kalınlığı px */
|
||||
rulerSize?: number
|
||||
}>()
|
||||
@@ -69,19 +75,8 @@ function drawTicks(
|
||||
size: number,
|
||||
) {
|
||||
const s = props.scale
|
||||
const pageMm = direction === 'horizontal' ? props.pageWidth : props.pageHeight
|
||||
const pan = direction === 'horizontal' ? props.panX : props.panY
|
||||
|
||||
// Sayfa başlangıcı: ortaya hizalı + pan
|
||||
// EditorCanvas sayfayı ortalar, ruler da buna uymalı
|
||||
// Yatay: canvas ortası - sayfa genişliği/2
|
||||
// Sayfanın canvas üzerindeki orijin px'i
|
||||
const canvasCenter =
|
||||
direction === 'horizontal'
|
||||
? length / 2 // flex centering approximation
|
||||
: 40 // EditorCanvas padding-top: 40px
|
||||
|
||||
const pageStartPx = canvasCenter - (pageMm * s) / 2 + pan
|
||||
const rulerSz = RULER_SIZE.value
|
||||
const gap = props.pageGap ?? 24
|
||||
|
||||
// Tick aralığı belirleme (zoom'a göre)
|
||||
const mmPerPx = 1 / s
|
||||
@@ -98,11 +93,41 @@ function drawTicks(
|
||||
ctx.font = '9px system-ui, sans-serif'
|
||||
ctx.textBaseline = 'top'
|
||||
|
||||
// Sayfanın mm aralığını çiz
|
||||
const startMm = 0
|
||||
const endMm = pageMm
|
||||
if (direction === 'horizontal') {
|
||||
// Yatay cetvel: tek sayfa genişliği, flex-center ile hizalı
|
||||
// editor-canvas padding: left=60, right=40; ruler canvas left=rulerSize
|
||||
// pageLeft_in_wrapper = 60 + (containerWidth - pageWidthPx) / 2
|
||||
// pageLeft_in_ruler = pageLeft_in_wrapper - rulerSz + panX
|
||||
const pageWidthPx = props.pageWidth * s
|
||||
const pageStartPx = (60 - rulerSz) + (props.containerWidth - pageWidthPx) / 2 + props.panX
|
||||
|
||||
for (let mm = startMm; mm <= endMm; mm += tickMm) {
|
||||
drawPageTicks(ctx, direction, length, size, pageStartPx, props.pageWidth, tickMm)
|
||||
} else {
|
||||
// Dikey cetvel: her sayfa için ayrı tick çiz
|
||||
// editor-canvas padding-top=60; ruler canvas top=rulerSize
|
||||
// pageTop for page i = (60 - rulerSz) + panY + i * (pageHeightPx + gap)
|
||||
const pageHeightPx = props.pageHeight * s
|
||||
const pageCount = Math.max(1, props.pageCount)
|
||||
|
||||
for (let i = 0; i < pageCount; i++) {
|
||||
const pageStartPx = (60 - rulerSz) + props.panY + i * (pageHeightPx + gap)
|
||||
drawPageTicks(ctx, direction, length, size, pageStartPx, props.pageHeight, tickMm)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawPageTicks(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
direction: 'horizontal' | 'vertical',
|
||||
length: number,
|
||||
size: number,
|
||||
pageStartPx: number,
|
||||
pageMm: number,
|
||||
tickMm: number,
|
||||
) {
|
||||
const s = props.scale
|
||||
|
||||
for (let mm = 0; mm <= pageMm; mm += tickMm) {
|
||||
const px = pageStartPx + mm * s
|
||||
|
||||
if (px < -10 || px > length + 10) continue
|
||||
@@ -141,7 +166,7 @@ function drawTicks(
|
||||
}
|
||||
}
|
||||
|
||||
// Sayfa kenar çizgileri (margin göstergesi)
|
||||
// Sayfa kenar çizgileri
|
||||
ctx.strokeStyle = 'rgba(59, 130, 246, 0.3)'
|
||||
ctx.lineWidth = 1
|
||||
const startPx = pageStartPx
|
||||
@@ -159,6 +184,11 @@ function drawTicks(
|
||||
ctx.lineTo(size, endPx)
|
||||
}
|
||||
ctx.stroke()
|
||||
|
||||
// Renkleri geri al (sonraki sayfa için)
|
||||
ctx.fillStyle = '#94a3b8'
|
||||
ctx.strokeStyle = '#94a3b8'
|
||||
ctx.lineWidth = 0.5
|
||||
}
|
||||
|
||||
function redraw() {
|
||||
@@ -166,7 +196,7 @@ function redraw() {
|
||||
drawRuler(vCanvas.value, 'vertical')
|
||||
}
|
||||
|
||||
watch(() => [props.scale, props.panX, props.panY, props.pageWidth, props.pageHeight], redraw)
|
||||
watch(() => [props.scale, props.panX, props.panY, props.pageWidth, props.pageHeight, props.containerWidth, props.pageCount], redraw)
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
|
||||
@@ -205,7 +235,7 @@ onBeforeUnmount(() => {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 20px;
|
||||
right: 0;
|
||||
width: calc(100% - 20px);
|
||||
z-index: 50;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -214,7 +244,7 @@ onBeforeUnmount(() => {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
height: calc(100% - 20px);
|
||||
z-index: 50;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
189
frontend/src/composables/__tests__/useSnapGuides.test.ts
Normal file
189
frontend/src/composables/__tests__/useSnapGuides.test.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { useSnapGuides } from '../useSnapGuides'
|
||||
import type { ElementLayout } from '../../core/layout-types'
|
||||
|
||||
function makeLayout(
|
||||
id: string,
|
||||
x: number,
|
||||
y: number,
|
||||
w: number,
|
||||
h: number,
|
||||
): ElementLayout {
|
||||
return {
|
||||
id,
|
||||
x_mm: x,
|
||||
y_mm: y,
|
||||
width_mm: w,
|
||||
height_mm: h,
|
||||
element_type: 'static_text',
|
||||
style: {},
|
||||
} as ElementLayout
|
||||
}
|
||||
|
||||
describe('useSnapGuides', () => {
|
||||
let guides: ReturnType<typeof useSnapGuides>
|
||||
|
||||
beforeEach(() => {
|
||||
guides = useSnapGuides()
|
||||
})
|
||||
|
||||
describe('collectEdges', () => {
|
||||
it('collects page edges and element edges', () => {
|
||||
const layoutMap: Record<string, ElementLayout> = {
|
||||
el1: makeLayout('el1', 10, 20, 50, 30),
|
||||
}
|
||||
|
||||
guides.collectEdges(layoutMap, 'excluded', 210, 297)
|
||||
|
||||
// After collecting, calculateSnap should work
|
||||
const result = guides.calculateSnap(0, 0, 10, 10)
|
||||
expect(result).toBeDefined()
|
||||
})
|
||||
|
||||
it('excludes the dragged element', () => {
|
||||
const layoutMap: Record<string, ElementLayout> = {
|
||||
dragged: makeLayout('dragged', 50, 50, 20, 20),
|
||||
other: makeLayout('other', 100, 100, 30, 30),
|
||||
}
|
||||
|
||||
guides.collectEdges(layoutMap, 'dragged', 210, 297)
|
||||
|
||||
// Snap to "other" element's left edge (100mm)
|
||||
const result = guides.calculateSnap(99.5, 50, 20, 20)
|
||||
expect(result.snappedX_mm).toBe(100) // snaps to other's left edge
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateSnap', () => {
|
||||
it('returns proposed position when no edges cached', () => {
|
||||
const result = guides.calculateSnap(42, 73, 10, 10)
|
||||
|
||||
expect(result.snappedX_mm).toBe(42)
|
||||
expect(result.snappedY_mm).toBe(73)
|
||||
expect(result.guides).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('snaps left edge to page left (0)', () => {
|
||||
guides.collectEdges({}, 'none', 210, 297)
|
||||
|
||||
// Proposed x=0.5 → should snap to 0 (within 1.5mm threshold)
|
||||
const result = guides.calculateSnap(0.5, 50, 20, 20)
|
||||
expect(result.snappedX_mm).toBe(0)
|
||||
expect(result.guides).toContainEqual({ type: 'vertical', position_mm: 0 })
|
||||
})
|
||||
|
||||
it('snaps right edge to page right', () => {
|
||||
guides.collectEdges({}, 'none', 210, 297)
|
||||
|
||||
// Element 20mm wide, proposed x=189 → right edge = 209, should snap to 210
|
||||
const result = guides.calculateSnap(189, 50, 20, 20)
|
||||
expect(result.snappedX_mm).toBe(190) // 210 - 20 = 190
|
||||
expect(result.guides).toContainEqual({ type: 'vertical', position_mm: 210 })
|
||||
})
|
||||
|
||||
it('snaps center to page center', () => {
|
||||
guides.collectEdges({}, 'none', 210, 297)
|
||||
|
||||
// Element 20mm wide, center at 105mm → x = 95
|
||||
// Proposed x=94.5 → center = 104.5, should snap to 105 → x = 95
|
||||
const result = guides.calculateSnap(94.5, 50, 20, 20)
|
||||
expect(result.snappedX_mm).toBe(95) // 105 - 10 = 95
|
||||
})
|
||||
|
||||
it('snaps top edge to page top', () => {
|
||||
guides.collectEdges({}, 'none', 210, 297)
|
||||
|
||||
const result = guides.calculateSnap(50, 1.0, 20, 20)
|
||||
expect(result.snappedY_mm).toBe(0)
|
||||
expect(result.guides).toContainEqual({ type: 'horizontal', position_mm: 0 })
|
||||
})
|
||||
|
||||
it('does not snap when outside threshold', () => {
|
||||
guides.collectEdges({}, 'none', 210, 297)
|
||||
|
||||
// Proposed x=50, far from any edge → no snap
|
||||
const result = guides.calculateSnap(50, 50, 20, 20)
|
||||
expect(result.snappedX_mm).toBe(50)
|
||||
expect(result.snappedY_mm).toBe(50)
|
||||
})
|
||||
|
||||
it('snaps to other element edges', () => {
|
||||
const layoutMap: Record<string, ElementLayout> = {
|
||||
ref: makeLayout('ref', 30, 40, 50, 20),
|
||||
}
|
||||
guides.collectEdges(layoutMap, 'dragged', 210, 297)
|
||||
|
||||
// Snap dragged element's left to ref's right (30+50=80)
|
||||
const result = guides.calculateSnap(79.5, 50, 20, 20)
|
||||
expect(result.snappedX_mm).toBe(80)
|
||||
})
|
||||
|
||||
it('snaps both axes simultaneously', () => {
|
||||
guides.collectEdges({}, 'none', 210, 297)
|
||||
|
||||
// Near page origin
|
||||
const result = guides.calculateSnap(0.5, 0.5, 20, 20)
|
||||
expect(result.snappedX_mm).toBe(0)
|
||||
expect(result.snappedY_mm).toBe(0)
|
||||
expect(result.guides).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('updates activeGuides ref', () => {
|
||||
guides.collectEdges({}, 'none', 210, 297)
|
||||
|
||||
guides.calculateSnap(0.5, 0.5, 20, 20)
|
||||
expect(guides.activeGuides.value.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateResizeSnap', () => {
|
||||
it('returns proposed value when no edges', () => {
|
||||
const result = guides.calculateResizeSnap('right', 42)
|
||||
expect(result).toBe(42)
|
||||
})
|
||||
|
||||
it('snaps right edge to nearest vertical', () => {
|
||||
const layoutMap: Record<string, ElementLayout> = {
|
||||
ref: makeLayout('ref', 100, 50, 40, 20),
|
||||
}
|
||||
guides.collectEdges(layoutMap, 'resizing', 210, 297)
|
||||
|
||||
// Snap to ref's left edge (100mm)
|
||||
const result = guides.calculateResizeSnap('right', 99.5)
|
||||
expect(result).toBe(100)
|
||||
})
|
||||
|
||||
it('snaps bottom edge to nearest horizontal', () => {
|
||||
const layoutMap: Record<string, ElementLayout> = {
|
||||
ref: makeLayout('ref', 50, 80, 40, 20),
|
||||
}
|
||||
guides.collectEdges(layoutMap, 'resizing', 210, 297)
|
||||
|
||||
// Snap to ref's top edge (80mm)
|
||||
const result = guides.calculateResizeSnap('bottom', 79.5)
|
||||
expect(result).toBe(80)
|
||||
})
|
||||
|
||||
it('does not snap when outside threshold', () => {
|
||||
guides.collectEdges({}, 'none', 210, 297)
|
||||
|
||||
const result = guides.calculateResizeSnap('right', 50)
|
||||
expect(result).toBe(50) // no edge near 50mm
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearGuides', () => {
|
||||
it('clears active guides and cached edges', () => {
|
||||
guides.collectEdges({}, 'none', 210, 297)
|
||||
guides.calculateSnap(0.5, 0.5, 10, 10)
|
||||
expect(guides.activeGuides.value.length).toBeGreaterThan(0)
|
||||
|
||||
guides.clearGuides()
|
||||
expect(guides.activeGuides.value).toHaveLength(0)
|
||||
|
||||
// After clear, calculateSnap should return unsnapped
|
||||
const result = guides.calculateSnap(0.5, 0.5, 10, 10)
|
||||
expect(result.snappedX_mm).toBe(0.5)
|
||||
})
|
||||
})
|
||||
})
|
||||
152
frontend/src/composables/__tests__/useUndoRedo.test.ts
Normal file
152
frontend/src/composables/__tests__/useUndoRedo.test.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { useUndoRedo } from '../useUndoRedo'
|
||||
|
||||
describe('useUndoRedo', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('starts with initial snapshot', () => {
|
||||
const source = ref({ value: 1 })
|
||||
const { canUndo, canRedo } = useUndoRedo(source)
|
||||
|
||||
expect(canUndo()).toBe(false) // only 1 snapshot (initial)
|
||||
expect(canRedo()).toBe(false)
|
||||
})
|
||||
|
||||
it('records snapshot after debounce', async () => {
|
||||
const source = ref({ value: 1 })
|
||||
const { canUndo } = useUndoRedo(source)
|
||||
|
||||
source.value = { value: 2 }
|
||||
await vi.advanceTimersByTimeAsync(350) // debounce = 300ms
|
||||
|
||||
expect(canUndo()).toBe(true)
|
||||
})
|
||||
|
||||
it('undo restores previous state', async () => {
|
||||
const source = ref({ count: 0 })
|
||||
const { undo, canUndo } = useUndoRedo(source)
|
||||
|
||||
source.value = { count: 1 }
|
||||
await vi.advanceTimersByTimeAsync(350)
|
||||
|
||||
source.value = { count: 2 }
|
||||
await vi.advanceTimersByTimeAsync(350)
|
||||
|
||||
expect(source.value.count).toBe(2)
|
||||
|
||||
undo()
|
||||
expect(source.value.count).toBe(1)
|
||||
|
||||
undo()
|
||||
expect(source.value.count).toBe(0)
|
||||
})
|
||||
|
||||
it('redo restores undone state', async () => {
|
||||
const source = ref({ count: 0 })
|
||||
const { undo, redo, canRedo } = useUndoRedo(source)
|
||||
|
||||
source.value = { count: 1 }
|
||||
await vi.advanceTimersByTimeAsync(350)
|
||||
|
||||
undo()
|
||||
expect(source.value.count).toBe(0)
|
||||
expect(canRedo()).toBe(true)
|
||||
|
||||
redo()
|
||||
expect(source.value.count).toBe(1)
|
||||
expect(canRedo()).toBe(false)
|
||||
})
|
||||
|
||||
it('new mutation clears redo stack', async () => {
|
||||
const source = ref({ v: 'a' })
|
||||
const { undo, redo, canRedo } = useUndoRedo(source)
|
||||
|
||||
source.value = { v: 'b' }
|
||||
await vi.advanceTimersByTimeAsync(350)
|
||||
|
||||
undo()
|
||||
expect(canRedo()).toBe(true)
|
||||
|
||||
// New mutation after undo → clears redo
|
||||
source.value = { v: 'c' }
|
||||
await vi.advanceTimersByTimeAsync(350)
|
||||
|
||||
expect(canRedo()).toBe(false)
|
||||
})
|
||||
|
||||
it('respects maxHistory limit', async () => {
|
||||
const source = ref({ n: 0 })
|
||||
const { canUndo, undo } = useUndoRedo(source, 3) // max 3 snapshots
|
||||
|
||||
source.value = { n: 1 }
|
||||
await vi.advanceTimersByTimeAsync(350)
|
||||
|
||||
source.value = { n: 2 }
|
||||
await vi.advanceTimersByTimeAsync(350)
|
||||
|
||||
source.value = { n: 3 }
|
||||
await vi.advanceTimersByTimeAsync(350)
|
||||
|
||||
// Stack: [1, 2, 3] (initial 0 was shifted out)
|
||||
// 3 snapshots, can undo twice (back to 1)
|
||||
undo()
|
||||
expect(source.value.n).toBe(2)
|
||||
|
||||
undo()
|
||||
expect(source.value.n).toBe(1)
|
||||
|
||||
// Can't undo further (stack has only 1 left)
|
||||
expect(canUndo()).toBe(false)
|
||||
})
|
||||
|
||||
it('skips duplicate snapshots', async () => {
|
||||
const source = ref({ x: 1 })
|
||||
const { canUndo } = useUndoRedo(source)
|
||||
|
||||
// Set same value
|
||||
source.value = { x: 1 }
|
||||
await vi.advanceTimersByTimeAsync(350)
|
||||
|
||||
expect(canUndo()).toBe(false) // no new snapshot since value same
|
||||
})
|
||||
|
||||
it('debounces rapid changes into one snapshot', async () => {
|
||||
const source = ref({ n: 0 })
|
||||
const { undo } = useUndoRedo(source)
|
||||
|
||||
// Rapid changes within debounce window
|
||||
source.value = { n: 1 }
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
source.value = { n: 2 }
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
source.value = { n: 3 }
|
||||
await vi.advanceTimersByTimeAsync(350) // trigger debounce
|
||||
|
||||
// Only one snapshot recorded (n=3), so one undo goes to initial
|
||||
undo()
|
||||
expect(source.value.n).toBe(0)
|
||||
})
|
||||
|
||||
it('undo with only initial snapshot does nothing', () => {
|
||||
const source = ref({ v: 'init' })
|
||||
const { undo } = useUndoRedo(source)
|
||||
|
||||
undo() // should not crash
|
||||
expect(source.value.v).toBe('init')
|
||||
})
|
||||
|
||||
it('redo with empty redo stack does nothing', () => {
|
||||
const source = ref({ v: 'init' })
|
||||
const { redo } = useUndoRedo(source)
|
||||
|
||||
redo() // should not crash
|
||||
expect(source.value.v).toBe('init')
|
||||
})
|
||||
})
|
||||
@@ -12,7 +12,7 @@ crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
dreport-core = { version = "0.2.0", path = "../core", registry = "gitea" }
|
||||
dexpr = { version = "0.1.0", registry = "gitea" }
|
||||
dexpr = { version = "0.3.0", registry = "gitea" }
|
||||
rust_decimal = "1.41"
|
||||
taffy = "0.9"
|
||||
cosmic-text = { version = "0.18", default-features = false, features = ["std", "swash"] }
|
||||
|
||||
@@ -64,7 +64,9 @@ pub struct YTick {
|
||||
|
||||
pub struct XLabelLayout {
|
||||
pub labels: Vec<XLabel>,
|
||||
pub needs_rotate: bool,
|
||||
/// Rotation angle in degrees (0 = horizontal, 90 = fully vertical).
|
||||
/// Dynamically computed based on available space vs label length.
|
||||
pub rotate_angle: f64,
|
||||
}
|
||||
|
||||
pub struct XLabel {
|
||||
@@ -162,6 +164,8 @@ pub struct PieChartLayout {
|
||||
pub inner_radius: f64,
|
||||
pub slices: Vec<PieSlice>,
|
||||
pub show_labels: bool,
|
||||
/// Category name labels + leader lines outside slices
|
||||
pub show_cat_labels: bool,
|
||||
pub label_font: f64,
|
||||
pub label_color: String,
|
||||
}
|
||||
@@ -543,14 +547,14 @@ pub fn compute_chart_layout(
|
||||
} else {
|
||||
available_w
|
||||
};
|
||||
let max_chars_fit = (cat_width / 1.25).max(1.0) as usize;
|
||||
let will_rotate = max_label_len > max_chars_fit;
|
||||
if will_rotate {
|
||||
let char_w_mm = 1.1;
|
||||
let rotate_angle = compute_label_rotation(max_label_len, cat_width);
|
||||
if rotate_angle > 0.0 {
|
||||
let char_w_mm = 2.5 * 0.6;
|
||||
let max_text_w = max_label_len as f64 * char_w_mm;
|
||||
let label_v = max_text_w * 0.707;
|
||||
let angle_rad = rotate_angle.to_radians();
|
||||
let label_v = max_text_w * angle_rad.sin();
|
||||
margin_bottom += label_v.clamp(6.0, 25.0);
|
||||
let label_h = max_text_w * 0.707;
|
||||
let label_h = max_text_w * angle_rad.cos();
|
||||
let extra_left = (label_h - cat_width / 2.0).max(0.0);
|
||||
margin_left += extra_left.min(10.0);
|
||||
} else {
|
||||
@@ -620,6 +624,29 @@ pub fn compute_y_axis(
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute dynamic label rotation angle (degrees) based on available space.
|
||||
/// Uses Chart.js-style algorithm: rotate only when labels overflow their slot,
|
||||
/// and use the minimum angle that prevents overlap.
|
||||
fn compute_label_rotation(max_label_len: usize, slot_width: f64) -> f64 {
|
||||
let label_font_size = 2.5_f64;
|
||||
let char_w_mm = label_font_size * 0.6;
|
||||
let max_label_w = max_label_len as f64 * char_w_mm;
|
||||
let padding = label_font_size * 0.5;
|
||||
|
||||
// Labels fit horizontally — no rotation needed
|
||||
if (max_label_w + padding) <= slot_width {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Chart.js Constraint A: sin(angle) = (label_height + padding) / slot_width
|
||||
// This finds the minimum angle where the rotated label's projected height
|
||||
// fits within the tick slot width, preventing horizontal overlap.
|
||||
let label_h = label_font_size;
|
||||
let sin_val = ((label_h + padding) / slot_width).clamp(0.0, 1.0);
|
||||
let angle_deg = sin_val.asin().to_degrees();
|
||||
angle_deg.clamp(0.0, 50.0)
|
||||
}
|
||||
|
||||
/// Compute X label positions for bar chart (slot-based spacing).
|
||||
pub fn compute_x_labels_bar(
|
||||
categories: &[String],
|
||||
@@ -631,12 +658,12 @@ pub fn compute_x_labels_bar(
|
||||
if n_cats == 0 {
|
||||
return XLabelLayout {
|
||||
labels: vec![],
|
||||
needs_rotate: false,
|
||||
rotate_angle: 0.0,
|
||||
};
|
||||
}
|
||||
let cat_width = pw / n_cats as f64;
|
||||
let max_chars = (cat_width / 1.25).max(1.0) as usize;
|
||||
let needs_rotate = categories.iter().any(|c| c.len() > max_chars);
|
||||
let max_label_len = categories.iter().map(|c| c.len()).max().unwrap_or(0);
|
||||
let rotate_angle = compute_label_rotation(max_label_len, cat_width);
|
||||
let labels = categories
|
||||
.iter()
|
||||
.enumerate()
|
||||
@@ -648,7 +675,7 @@ pub fn compute_x_labels_bar(
|
||||
.collect();
|
||||
XLabelLayout {
|
||||
labels,
|
||||
needs_rotate,
|
||||
rotate_angle,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -663,7 +690,7 @@ pub fn compute_x_labels_line(
|
||||
if n_cats == 0 {
|
||||
return XLabelLayout {
|
||||
labels: vec![],
|
||||
needs_rotate: false,
|
||||
rotate_angle: 0.0,
|
||||
};
|
||||
}
|
||||
let spacing = if n_cats == 1 {
|
||||
@@ -671,8 +698,8 @@ pub fn compute_x_labels_line(
|
||||
} else {
|
||||
pw / (n_cats - 1) as f64
|
||||
};
|
||||
let max_chars = (spacing / 1.25).max(1.0) as usize;
|
||||
let needs_rotate = categories.iter().any(|c| c.len() > max_chars);
|
||||
let max_label_len = categories.iter().map(|c| c.len()).max().unwrap_or(0);
|
||||
let rotate_angle = compute_label_rotation(max_label_len, spacing);
|
||||
let labels = categories
|
||||
.iter()
|
||||
.enumerate()
|
||||
@@ -691,7 +718,7 @@ pub fn compute_x_labels_line(
|
||||
.collect();
|
||||
XLabelLayout {
|
||||
labels,
|
||||
needs_rotate,
|
||||
rotate_angle,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -955,6 +982,7 @@ pub fn compute_pie_layout(data: &dyn ChartDataSource, cl: &ChartLayout) -> PieCh
|
||||
inner_radius: inner_r,
|
||||
slices,
|
||||
show_labels,
|
||||
show_cat_labels: show_labels,
|
||||
label_font,
|
||||
label_color,
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ pub fn render_svg(data: &ResolvedChartData, width_mm: f64, height_mm: f64) -> St
|
||||
}
|
||||
|
||||
// Legend render
|
||||
if cl.legend_show && data.series.len() > 1 {
|
||||
if cl.legend_show {
|
||||
render_legend(&mut svg, data, &cl, width_mm, height_mm);
|
||||
}
|
||||
|
||||
@@ -251,7 +251,7 @@ fn render_pie(svg: &mut String, data: &ResolvedChartData, cl: &ChartLayout) {
|
||||
}
|
||||
|
||||
// Category name label outside slice with leader line
|
||||
if !slice.cat_label_text.is_empty() {
|
||||
if pl.show_cat_labels && !slice.cat_label_text.is_empty() {
|
||||
write!(
|
||||
svg,
|
||||
r##"<line x1="{:.2}" y1="{:.2}" x2="{:.2}" y2="{:.2}" stroke="#999" stroke-width="0.2"/>"##,
|
||||
@@ -337,12 +337,13 @@ fn render_y_axis_svg(svg: &mut String, y_axis: &chart_layout::YAxisLayout) {
|
||||
}
|
||||
|
||||
fn render_x_labels_svg(svg: &mut String, x_labels: &chart_layout::XLabelLayout) {
|
||||
let angle = x_labels.rotate_angle;
|
||||
for label in &x_labels.labels {
|
||||
if x_labels.needs_rotate {
|
||||
if angle > 0.0 {
|
||||
write!(
|
||||
svg,
|
||||
r##"<text x="{:.2}" y="{:.2}" font-size="2.2" fill="#666" text-anchor="end" transform="rotate(-45,{:.2},{:.2})">{}</text>"##,
|
||||
label.x, label.y, label.x, label.y, escape_xml(&label.text)
|
||||
r##"<text x="{:.2}" y="{:.2}" font-size="2.2" fill="#666" text-anchor="end" transform="rotate(-{:.1},{:.2},{:.2})">{}</text>"##,
|
||||
label.x, label.y, angle, label.x, label.y, escape_xml(&label.text)
|
||||
)
|
||||
.unwrap();
|
||||
} else {
|
||||
@@ -362,3 +363,250 @@ fn escape_xml(s: &str) -> String {
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::data_resolve::{ChartSeries, ResolvedChartData};
|
||||
use dreport_core::models::{ChartAxis, ChartLabels, ChartLegend, ChartStyle, ChartTitle, ChartType};
|
||||
|
||||
fn make_bar_data(categories: Vec<&str>, series: Vec<(&str, Vec<f64>)>) -> ResolvedChartData {
|
||||
ResolvedChartData {
|
||||
chart_type: ChartType::Bar,
|
||||
categories: categories.into_iter().map(|s| s.to_string()).collect(),
|
||||
series: series
|
||||
.into_iter()
|
||||
.map(|(name, values)| ChartSeries {
|
||||
name: name.to_string(),
|
||||
values,
|
||||
})
|
||||
.collect(),
|
||||
title: None,
|
||||
legend: None,
|
||||
labels: None,
|
||||
axis: None,
|
||||
style: ChartStyle::default(),
|
||||
group_mode: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_line_data(categories: Vec<&str>, series: Vec<(&str, Vec<f64>)>) -> ResolvedChartData {
|
||||
let mut data = make_bar_data(categories, series);
|
||||
data.chart_type = ChartType::Line;
|
||||
data
|
||||
}
|
||||
|
||||
fn make_pie_data(categories: Vec<&str>, values: Vec<f64>) -> ResolvedChartData {
|
||||
ResolvedChartData {
|
||||
chart_type: ChartType::Pie,
|
||||
categories: categories.into_iter().map(|s| s.to_string()).collect(),
|
||||
series: vec![ChartSeries {
|
||||
name: "data".to_string(),
|
||||
values,
|
||||
}],
|
||||
title: None,
|
||||
legend: None,
|
||||
labels: None,
|
||||
axis: None,
|
||||
style: ChartStyle::default(),
|
||||
group_mode: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bar_chart_svg_structure() {
|
||||
let data = make_bar_data(vec!["A", "B", "C"], vec![("Sales", vec![10.0, 20.0, 30.0])]);
|
||||
let svg = render_svg(&data, 100.0, 60.0);
|
||||
|
||||
assert!(svg.starts_with("<svg"));
|
||||
assert!(svg.ends_with("</svg>"));
|
||||
// 3 categories × 1 series = 3 bars (each with rx="0.5")
|
||||
let bar_count = svg.matches(r#"rx="0.5""#).count();
|
||||
assert_eq!(bar_count, 3, "expected 3 bars for 3 categories, got {}", bar_count);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bar_chart_with_labels() {
|
||||
let mut data = make_bar_data(vec!["A", "B"], vec![("S1", vec![10.0, 20.0])]);
|
||||
data.labels = Some(ChartLabels {
|
||||
show: true,
|
||||
font_size: None,
|
||||
color: None,
|
||||
});
|
||||
let svg = render_svg(&data, 100.0, 60.0);
|
||||
|
||||
// Labels shown → should contain text elements with formatted values
|
||||
assert!(svg.contains("<text"), "labels enabled but no text elements found");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_line_chart_svg_structure() {
|
||||
let data = make_line_data(vec!["Jan", "Feb", "Mar"], vec![("Revenue", vec![5.0, 15.0, 10.0])]);
|
||||
let svg = render_svg(&data, 100.0, 60.0);
|
||||
|
||||
assert!(svg.starts_with("<svg"));
|
||||
// Should contain polyline for the series
|
||||
assert!(svg.contains("<polyline"), "line chart should contain polyline");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_line_chart_with_points() {
|
||||
let mut data = make_line_data(vec!["A", "B", "C"], vec![("S1", vec![1.0, 2.0, 3.0])]);
|
||||
data.style.show_points = Some(true);
|
||||
let svg = render_svg(&data, 100.0, 60.0);
|
||||
|
||||
// 3 data points → 3 circles
|
||||
let circle_count = svg.matches("<circle").count();
|
||||
assert_eq!(circle_count, 3, "expected 3 circles for 3 data points, got {}", circle_count);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pie_chart_svg_structure() {
|
||||
let data = make_pie_data(vec!["A", "B", "C"], vec![50.0, 30.0, 20.0]);
|
||||
let svg = render_svg(&data, 80.0, 80.0);
|
||||
|
||||
assert!(svg.starts_with("<svg"));
|
||||
// 3 slices → 3 path elements
|
||||
let path_count = svg.matches("<path d=").count();
|
||||
assert_eq!(path_count, 3, "expected 3 pie slices, got {}", path_count);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pie_chart_percentage_labels() {
|
||||
let mut data = make_pie_data(vec!["A", "B"], vec![75.0, 25.0]);
|
||||
data.labels = Some(ChartLabels {
|
||||
show: true,
|
||||
font_size: None,
|
||||
color: None,
|
||||
});
|
||||
let svg = render_svg(&data, 80.0, 80.0);
|
||||
|
||||
assert!(svg.contains("75%"), "should show 75% label");
|
||||
assert!(svg.contains("25%"), "should show 25% label");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_legend_renders_for_multi_series() {
|
||||
let mut data = make_bar_data(
|
||||
vec!["A", "B"],
|
||||
vec![("Series 1", vec![10.0, 20.0]), ("Series 2", vec![15.0, 25.0])],
|
||||
);
|
||||
data.legend = Some(ChartLegend {
|
||||
show: true,
|
||||
position: None,
|
||||
font_size: None,
|
||||
});
|
||||
let svg = render_svg(&data, 100.0, 60.0);
|
||||
|
||||
// Multi-series + legend.show → legend should render
|
||||
assert!(svg.contains("Series 1"), "legend should show series name");
|
||||
assert!(svg.contains("Series 2"), "legend should show second series name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_legend_hidden_for_single_series() {
|
||||
let data = make_bar_data(vec!["A", "B"], vec![("Only", vec![10.0, 20.0])]);
|
||||
let svg = render_svg(&data, 100.0, 60.0);
|
||||
|
||||
// legend: None → legend_show=false → legend not rendered
|
||||
// The text "Only" might appear in x-axis labels, so check for legend swatch rect pattern
|
||||
// Legend renders swatch rects with width="2.5" height="2.5"
|
||||
let legend_swatch = svg.contains(r#"width="2.5" height="2.5""#);
|
||||
assert!(!legend_swatch, "single series should not render legend swatches");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_categories_bar_chart() {
|
||||
let data = make_bar_data(vec![], vec![("S", vec![])]);
|
||||
let svg = render_svg(&data, 100.0, 60.0);
|
||||
|
||||
// Should still produce valid SVG (bg rect + no bars)
|
||||
assert!(svg.starts_with("<svg"));
|
||||
assert!(svg.ends_with("</svg>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_series_bar_chart() {
|
||||
let data = make_bar_data(vec!["A", "B"], vec![]);
|
||||
let svg = render_svg(&data, 100.0, 60.0);
|
||||
|
||||
assert!(svg.starts_with("<svg"));
|
||||
assert!(svg.ends_with("</svg>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_pie_chart() {
|
||||
let data = make_pie_data(vec![], vec![]);
|
||||
let svg = render_svg(&data, 80.0, 80.0);
|
||||
|
||||
assert!(svg.starts_with("<svg"));
|
||||
assert!(svg.ends_with("</svg>"));
|
||||
// No slices
|
||||
assert!(!svg.contains("<path d="), "empty pie should have no slices");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_title_rendered() {
|
||||
let mut data = make_bar_data(vec!["A"], vec![("S", vec![10.0])]);
|
||||
data.title = Some(ChartTitle {
|
||||
text: "My Chart Title".to_string(),
|
||||
font_size: Some(4.0),
|
||||
color: Some("#333".to_string()),
|
||||
align: None,
|
||||
});
|
||||
let svg = render_svg(&data, 100.0, 60.0);
|
||||
|
||||
assert!(svg.contains("My Chart Title"), "title should be rendered");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_axis_labels_rendered() {
|
||||
let mut data = make_bar_data(vec!["Q1", "Q2"], vec![("Sales", vec![100.0, 200.0])]);
|
||||
data.axis = Some(ChartAxis {
|
||||
x_label: Some("Quarter".to_string()),
|
||||
y_label: Some("Revenue".to_string()),
|
||||
show_grid: None,
|
||||
grid_color: None,
|
||||
});
|
||||
let svg = render_svg(&data, 100.0, 60.0);
|
||||
|
||||
assert!(svg.contains("Quarter"), "x axis label should be rendered");
|
||||
assert!(svg.contains("Revenue"), "y axis label should be rendered");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_axis_labels_not_on_pie() {
|
||||
let mut data = make_pie_data(vec!["A", "B"], vec![50.0, 50.0]);
|
||||
data.axis = Some(ChartAxis {
|
||||
x_label: Some("X Label".to_string()),
|
||||
y_label: Some("Y Label".to_string()),
|
||||
show_grid: None,
|
||||
grid_color: None,
|
||||
});
|
||||
let svg = render_svg(&data, 80.0, 80.0);
|
||||
|
||||
// Pie charts should not render axis labels
|
||||
assert!(!svg.contains("X Label"), "pie chart should not have x axis label");
|
||||
assert!(!svg.contains("Y Label"), "pie chart should not have y axis label");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_escape_xml_special_chars() {
|
||||
assert_eq!(escape_xml("a & b"), "a & b");
|
||||
assert_eq!(escape_xml("<script>"), "<script>");
|
||||
assert_eq!(escape_xml(r#"say "hi""#), "say "hi"");
|
||||
assert_eq!(escape_xml("normal"), "normal");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_donut_chart_inner_radius() {
|
||||
let mut data = make_pie_data(vec!["A", "B"], vec![60.0, 40.0]);
|
||||
data.style.inner_radius = Some(0.5);
|
||||
let svg = render_svg(&data, 80.0, 80.0);
|
||||
|
||||
// Donut chart uses arc paths with inner radius → the path should contain "A" commands
|
||||
// for both outer and inner arcs
|
||||
let path_count = svg.matches("<path d=").count();
|
||||
assert_eq!(path_count, 2, "donut chart should have 2 slices");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,6 +106,8 @@ pub struct ResolvedData {
|
||||
pub rich_texts: HashMap<String, Vec<ResolvedRichSpan>>,
|
||||
/// element_id → çözümlenmiş chart verisi
|
||||
pub charts: HashMap<String, ResolvedChartData>,
|
||||
/// Koşulu sağlamayan (gizlenmesi gereken) element ID'leri
|
||||
pub hidden_elements: std::collections::HashSet<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -146,30 +148,91 @@ pub fn resolve_template(template: &Template, data: &Value) -> ResolvedData {
|
||||
page_number_formats: HashMap::new(),
|
||||
rich_texts: HashMap::new(),
|
||||
charts: HashMap::new(),
|
||||
hidden_elements: std::collections::HashSet::new(),
|
||||
};
|
||||
let fc = template.effective_format_config();
|
||||
if let Some(ref header) = template.header {
|
||||
resolve_element(
|
||||
&TemplateElement::Container(header.clone()),
|
||||
data,
|
||||
&mut resolved,
|
||||
&fc,
|
||||
);
|
||||
}
|
||||
resolve_element(
|
||||
&TemplateElement::Container(template.root.clone()),
|
||||
data,
|
||||
&mut resolved,
|
||||
&fc,
|
||||
);
|
||||
if let Some(ref footer) = template.footer {
|
||||
resolve_element(
|
||||
&TemplateElement::Container(footer.clone()),
|
||||
data,
|
||||
&mut resolved,
|
||||
&fc,
|
||||
);
|
||||
}
|
||||
resolved
|
||||
}
|
||||
|
||||
fn resolve_element(el: &TemplateElement, data: &Value, resolved: &mut ResolvedData) {
|
||||
/// Koşul değerlendirme: Condition struct'ındaki path, operator, value ile data'yı karşılaştır.
|
||||
fn evaluate_condition(condition: &dreport_core::models::Condition, data: &Value) -> bool {
|
||||
let actual = resolve_path(data, &condition.path);
|
||||
match condition.operator.as_str() {
|
||||
"empty" => matches!(actual, Value::Null) || actual.as_str().is_some_and(|s| s.is_empty()),
|
||||
"not_empty" => !matches!(actual, Value::Null) && !actual.as_str().is_some_and(|s| s.is_empty()),
|
||||
"eq" => {
|
||||
if let Some(ref expected) = condition.value {
|
||||
json_values_eq(actual, expected)
|
||||
} else {
|
||||
actual.is_null()
|
||||
}
|
||||
}
|
||||
"neq" => {
|
||||
if let Some(ref expected) = condition.value {
|
||||
!json_values_eq(actual, expected)
|
||||
} else {
|
||||
!actual.is_null()
|
||||
}
|
||||
}
|
||||
op @ ("gt" | "gte" | "lt" | "lte") => {
|
||||
let a = actual.as_f64().unwrap_or(0.0);
|
||||
let b = condition.value.as_ref().and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
match op {
|
||||
"gt" => a > b,
|
||||
"gte" => a >= b,
|
||||
"lt" => a < b,
|
||||
"lte" => a <= b,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
_ => true, // bilinmeyen operator → göster
|
||||
}
|
||||
}
|
||||
|
||||
/// İki JSON değerini karşılaştır (tip dönüşümlü).
|
||||
fn json_values_eq(a: &Value, b: &Value) -> bool {
|
||||
match (a, b) {
|
||||
(Value::Number(a), Value::Number(b)) => a.as_f64() == b.as_f64(),
|
||||
(Value::String(a), Value::String(b)) => a == b,
|
||||
(Value::Bool(a), Value::Bool(b)) => a == b,
|
||||
(Value::Null, Value::Null) => true,
|
||||
// Çapraz tip karşılaştırma: sayı string vs sayı
|
||||
(Value::String(s), Value::Number(n)) | (Value::Number(n), Value::String(s)) => {
|
||||
s.parse::<f64>().ok() == n.as_f64()
|
||||
}
|
||||
_ => a == b,
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_element(el: &TemplateElement, data: &Value, resolved: &mut ResolvedData, format_config: &dreport_core::models::FormatConfig) {
|
||||
// Koşul kontrolü: condition varsa ve sağlanmıyorsa, hidden olarak işaretle ve çık
|
||||
if let Some(condition) = el.condition() && !evaluate_condition(condition, data) {
|
||||
resolved.hidden_elements.insert(el.id().to_string());
|
||||
return;
|
||||
}
|
||||
|
||||
match el {
|
||||
TemplateElement::StaticText(e) => {
|
||||
resolved.texts.insert(e.id.clone(), e.content.clone());
|
||||
@@ -228,7 +291,7 @@ fn resolve_element(el: &TemplateElement, data: &Value, resolved: &mut ResolvedDa
|
||||
let raw = value_to_string(v);
|
||||
// Sütun formatı varsa uygula (currency, percentage, number, date)
|
||||
if let Some(ref fmt) = col.format {
|
||||
crate::expr_eval::apply_format(&raw, Some(fmt.as_str()))
|
||||
crate::expr_eval::apply_format_with_config(&raw, Some(fmt.as_str()), format_config)
|
||||
} else {
|
||||
raw
|
||||
}
|
||||
@@ -243,7 +306,7 @@ fn resolve_element(el: &TemplateElement, data: &Value, resolved: &mut ResolvedDa
|
||||
}
|
||||
TemplateElement::Container(e) => {
|
||||
for child in &e.children {
|
||||
resolve_element(child, data, resolved);
|
||||
resolve_element(child, data, resolved, format_config);
|
||||
}
|
||||
}
|
||||
TemplateElement::CurrentDate(e) => {
|
||||
@@ -268,7 +331,7 @@ fn resolve_element(el: &TemplateElement, data: &Value, resolved: &mut ResolvedDa
|
||||
}
|
||||
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());
|
||||
let formatted = crate::expr_eval::apply_format_with_config(&result, e.format.as_deref(), format_config);
|
||||
// Bos ifade veya hata durumunda placeholder goster — element 0 yukseklige dusmesin
|
||||
let text = if formatted.is_empty() {
|
||||
" ".to_string()
|
||||
@@ -477,8 +540,10 @@ mod tests {
|
||||
header: None,
|
||||
footer: None,
|
||||
format_config: None,
|
||||
locale: None,
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
direction: "column".to_string(),
|
||||
@@ -490,6 +555,7 @@ mod tests {
|
||||
break_inside: "auto".to_string(),
|
||||
children: vec![TemplateElement::Text(TextElement {
|
||||
id: "el_name".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
style: TextStyle::default(),
|
||||
@@ -525,8 +591,10 @@ mod tests {
|
||||
header: None,
|
||||
footer: None,
|
||||
format_config: None,
|
||||
locale: None,
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
direction: "column".to_string(),
|
||||
@@ -538,6 +606,7 @@ mod tests {
|
||||
break_inside: "auto".to_string(),
|
||||
children: vec![TemplateElement::Text(TextElement {
|
||||
id: "el_no".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
style: TextStyle::default(),
|
||||
@@ -570,8 +639,10 @@ mod tests {
|
||||
header: None,
|
||||
footer: None,
|
||||
format_config: None,
|
||||
locale: None,
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
direction: "column".to_string(),
|
||||
@@ -583,6 +654,7 @@ mod tests {
|
||||
break_inside: "auto".to_string(),
|
||||
children: vec![TemplateElement::StaticText(StaticTextElement {
|
||||
id: "title".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
style: TextStyle::default(),
|
||||
@@ -608,8 +680,10 @@ mod tests {
|
||||
header: None,
|
||||
footer: None,
|
||||
format_config: None,
|
||||
locale: None,
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
direction: "column".to_string(),
|
||||
@@ -621,6 +695,7 @@ mod tests {
|
||||
break_inside: "auto".to_string(),
|
||||
children: vec![TemplateElement::RepeatingTable(RepeatingTableElement {
|
||||
id: "tbl".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
data_source: ArrayBinding {
|
||||
@@ -677,8 +752,10 @@ mod tests {
|
||||
header: None,
|
||||
footer: None,
|
||||
format_config: None,
|
||||
locale: None,
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
direction: "column".to_string(),
|
||||
@@ -690,6 +767,7 @@ mod tests {
|
||||
break_inside: "auto".to_string(),
|
||||
children: vec![TemplateElement::RepeatingTable(RepeatingTableElement {
|
||||
id: "tbl".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
data_source: ArrayBinding {
|
||||
@@ -728,8 +806,10 @@ mod tests {
|
||||
header: None,
|
||||
footer: None,
|
||||
format_config: None,
|
||||
locale: None,
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
direction: "column".to_string(),
|
||||
@@ -741,6 +821,7 @@ mod tests {
|
||||
break_inside: "auto".to_string(),
|
||||
children: vec![TemplateElement::Text(TextElement {
|
||||
id: "el_missing".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
style: TextStyle::default(),
|
||||
|
||||
@@ -65,6 +65,10 @@ fn dexpr_value_to_string(val: &DexprValue) -> String {
|
||||
.collect();
|
||||
format!("{{{}}}", items.join(", "))
|
||||
}
|
||||
DexprValue::List(list) => {
|
||||
let items: Vec<String> = list.iter().map(|v| dexpr_value_to_string(v)).collect();
|
||||
format!("[{}]", items.join(", "))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -358,4 +362,31 @@ mod tests {
|
||||
};
|
||||
assert_eq!(format_currency("1500.25", &config), "$1,500.25");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_array_field_sum() {
|
||||
let data = json!({
|
||||
"kalemler": [
|
||||
{"adi": "A", "tutar": 100},
|
||||
{"adi": "B", "tutar": 200},
|
||||
{"adi": "C", "tutar": 50}
|
||||
]
|
||||
});
|
||||
assert_eq!(evaluate_expression("kalemler.tutar.sum()", &data), "350");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_array_field_sum_in_arithmetic() {
|
||||
let data = json!({
|
||||
"kalemler": [
|
||||
{"tutar": 1000},
|
||||
{"tutar": 2000}
|
||||
],
|
||||
"toplamlar": {"kdvOrani": 20}
|
||||
});
|
||||
assert_eq!(
|
||||
evaluate_expression("kalemler.tutar.sum() * toplamlar.kdvOrani / 100", &data),
|
||||
"600"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -896,6 +896,261 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_break_inside_avoid_group_moves_to_new_page() {
|
||||
// break_inside: avoid olan container grubunun mevcut sayfaya sığmadığında
|
||||
// komple yeni sayfaya taşınması gerekir.
|
||||
let mut break_modes = HashMap::new();
|
||||
break_modes.insert("group".to_string(), "avoid".to_string());
|
||||
|
||||
// group container: y=250, h=80 → bottom=330 > 297 (sayfa yüksekliği)
|
||||
// ama 80mm tek sayfaya sığar → yeni sayfaya geçmeli
|
||||
let mut group = make_element("group", 250.0, 80.0, "container");
|
||||
group.children = vec!["g_child1".to_string(), "g_child2".to_string()];
|
||||
|
||||
let input = PageSplitInput {
|
||||
body_elements: vec![
|
||||
make_element("el1", 0.0, 250.0, "text"),
|
||||
group,
|
||||
make_element("g_child1", 250.0, 40.0, "text"),
|
||||
make_element("g_child2", 290.0, 40.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,
|
||||
page_number_formats: HashMap::new(),
|
||||
root_padding_top_mm: 0.0,
|
||||
no_repeat_header_tables: HashSet::new(),
|
||||
};
|
||||
|
||||
let pages = split_into_pages(input);
|
||||
assert_eq!(pages.len(), 2, "avoid group should cause a new page");
|
||||
// el1 sayfada 1, group + children sayfada 2
|
||||
assert!(pages[0].elements.iter().any(|e| e.id == "el1"));
|
||||
assert!(pages[1].elements.iter().any(|e| e.id == "group"));
|
||||
assert!(pages[1].elements.iter().any(|e| e.id == "g_child1"));
|
||||
assert!(pages[1].elements.iter().any(|e| e.id == "g_child2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_avoid_group_larger_than_page_stays_in_flow() {
|
||||
// break_inside: avoid olan container sayfadan büyükse, normal akışa devam etmeli.
|
||||
// Yeni sayfaya atlamamalı çünkü zaten sığmıyor.
|
||||
let mut break_modes = HashMap::new();
|
||||
break_modes.insert("big_group".to_string(), "avoid".to_string());
|
||||
|
||||
let mut big = make_element("big_group", 0.0, 400.0, "container");
|
||||
big.children = vec!["bg_child".to_string()];
|
||||
|
||||
let input = PageSplitInput {
|
||||
body_elements: vec![
|
||||
big,
|
||||
make_element("bg_child", 0.0, 400.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,
|
||||
page_number_formats: HashMap::new(),
|
||||
root_padding_top_mm: 0.0,
|
||||
no_repeat_header_tables: HashSet::new(),
|
||||
};
|
||||
|
||||
let pages = split_into_pages(input);
|
||||
// Sayfa 1'de grup başlamalı (sığmasa da mecbur)
|
||||
assert!(pages[0].elements.iter().any(|e| e.id == "big_group"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_element_exactly_at_page_boundary() {
|
||||
// Eleman tam sayfa sınırına denk geldiğinde doğru sayfalanmalı.
|
||||
// İki eleman: 148.5mm + 148.5mm = 297mm → tam sığar, tek sayfa.
|
||||
let input = PageSplitInput {
|
||||
body_elements: vec![
|
||||
make_element("el1", 0.0, 148.5, "text"),
|
||||
make_element("el2", 148.5, 148.5, "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,
|
||||
no_repeat_header_tables: HashSet::new(),
|
||||
};
|
||||
|
||||
let pages = split_into_pages(input);
|
||||
assert_eq!(pages.len(), 1, "elements exactly filling page should fit in 1 page");
|
||||
assert_eq!(pages[0].elements.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_element_one_mm_over_page_boundary() {
|
||||
// Eleman sayfa sınırını 1mm aşıyorsa yeni sayfaya geçmeli.
|
||||
let input = PageSplitInput {
|
||||
body_elements: vec![
|
||||
make_element("el1", 0.0, 148.5, "text"),
|
||||
make_element("el2", 148.5, 149.5, "text"), // bottom = 298 > 297
|
||||
],
|
||||
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,
|
||||
no_repeat_header_tables: HashSet::new(),
|
||||
};
|
||||
|
||||
let pages = split_into_pages(input);
|
||||
assert_eq!(pages.len(), 2, "element exceeding page by 1mm should go to page 2");
|
||||
assert!(pages[0].elements.iter().any(|e| e.id == "el1"));
|
||||
assert!(pages[1].elements.iter().any(|e| e.id == "el2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_single_element_larger_than_page() {
|
||||
// Sayfadan büyük tek eleman — mecburen sayfa 1'de kalmalı.
|
||||
let input = PageSplitInput {
|
||||
body_elements: vec![
|
||||
make_element("huge", 0.0, 500.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,
|
||||
no_repeat_header_tables: HashSet::new(),
|
||||
};
|
||||
|
||||
let pages = split_into_pages(input);
|
||||
assert_eq!(pages.len(), 1, "single oversized element should stay on page 1");
|
||||
assert_eq!(pages[0].elements[0].id, "huge");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_repeat_header_tables_suppresses_header() {
|
||||
// no_repeat_header_tables'a eklenen tablonun header'ı tekrarlanmamalı.
|
||||
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(),
|
||||
];
|
||||
|
||||
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..5)
|
||||
.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 mut no_repeat = HashSet::new();
|
||||
no_repeat.insert("tbl".to_string());
|
||||
|
||||
let input = PageSplitInput {
|
||||
body_elements,
|
||||
page_height_mm: 120.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,
|
||||
no_repeat_header_tables: no_repeat,
|
||||
};
|
||||
|
||||
let pages = split_into_pages(input);
|
||||
assert!(pages.len() >= 2, "table should split across pages");
|
||||
|
||||
// Sayfa 2'de tekrarlanan header OLMAMALI
|
||||
let page2_has_repeated_header = pages[1]
|
||||
.elements
|
||||
.iter()
|
||||
.any(|e| e.id.starts_with("tbl_header") && e.id != "tbl_header");
|
||||
assert!(
|
||||
!page2_has_repeated_header,
|
||||
"no_repeat_header_tables should suppress header repetition on page 2"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_body_produces_single_page() {
|
||||
let input = PageSplitInput {
|
||||
body_elements: vec![],
|
||||
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,
|
||||
no_repeat_header_tables: HashSet::new(),
|
||||
};
|
||||
|
||||
let pages = split_into_pages(input);
|
||||
assert_eq!(pages.len(), 1, "empty body should produce 1 page");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_content_height_zero_returns_single_page() {
|
||||
// Header + footer sayfayı dolduruyor → content_height <= 0
|
||||
let input = PageSplitInput {
|
||||
body_elements: vec![
|
||||
make_element("el1", 0.0, 50.0, "text"),
|
||||
],
|
||||
page_height_mm: 100.0,
|
||||
header_height_mm: 60.0,
|
||||
footer_height_mm: 50.0,
|
||||
header_elements: vec![make_element("hdr", 0.0, 60.0, "text")],
|
||||
footer_elements: vec![make_element("ftr", 0.0, 50.0, "text")],
|
||||
page_width_mm: 210.0,
|
||||
break_modes: HashMap::new(),
|
||||
page_number_formats: HashMap::new(),
|
||||
root_padding_top_mm: 0.0,
|
||||
no_repeat_header_tables: HashSet::new(),
|
||||
};
|
||||
|
||||
let pages = split_into_pages(input);
|
||||
assert_eq!(pages.len(), 1, "zero content height should produce 1 page");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_repeated_header_no_gap_with_rows() {
|
||||
// Tekrarlanan header ile ilk satır arasında boşluk olmamalı.
|
||||
|
||||
@@ -1126,7 +1126,7 @@ fn render_chart(
|
||||
);
|
||||
}
|
||||
|
||||
if !slice.cat_label_text.is_empty() {
|
||||
if pl.show_cat_labels && !slice.cat_label_text.is_empty() {
|
||||
chart_line_seg(
|
||||
surface,
|
||||
slice.leader_start_x,
|
||||
@@ -1165,7 +1165,7 @@ fn render_chart(
|
||||
}
|
||||
|
||||
// Legend render
|
||||
if cl.legend_show && data.series.len() > 1 {
|
||||
if cl.legend_show {
|
||||
let legend = compute_legend(data, &cl, base_x_mm, base_y_mm, w_mm, h_mm);
|
||||
for item in &legend.items {
|
||||
let color = parse_color(color_at(&cl.palette, item.color_idx));
|
||||
@@ -1406,11 +1406,13 @@ fn render_chart_x_labels(
|
||||
fonts: &FontCollection,
|
||||
measurer: &mut TextMeasurer,
|
||||
) {
|
||||
let angle = x_labels.rotate_angle;
|
||||
for label in &x_labels.labels {
|
||||
if x_labels.needs_rotate {
|
||||
if angle > 0.0 {
|
||||
surface.push_transform(&Transform::from_translate(pt(label.x), pt(label.y)));
|
||||
let c = std::f32::consts::FRAC_PI_4.cos();
|
||||
let s = std::f32::consts::FRAC_PI_4.sin();
|
||||
let angle_rad = (angle as f32).to_radians();
|
||||
let c = angle_rad.cos();
|
||||
let s = angle_rad.sin();
|
||||
surface.push_transform(&Transform::from_row(c, -s, s, c, 0.0, 0.0));
|
||||
chart_text_end(
|
||||
surface,
|
||||
@@ -1599,8 +1601,10 @@ mod tests {
|
||||
header: None,
|
||||
footer: None,
|
||||
format_config: None,
|
||||
locale: None,
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Auto,
|
||||
@@ -1625,6 +1629,7 @@ mod tests {
|
||||
children: vec![
|
||||
TemplateElement::StaticText(StaticTextElement {
|
||||
id: "title".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
@@ -1643,6 +1648,7 @@ mod tests {
|
||||
}),
|
||||
TemplateElement::Line(LineElement {
|
||||
id: "line1".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
@@ -1659,6 +1665,7 @@ mod tests {
|
||||
}),
|
||||
TemplateElement::Text(TextElement {
|
||||
id: "firma".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
@@ -1700,6 +1707,300 @@ mod tests {
|
||||
println!("Full pipeline PDF: {}", out_path.display());
|
||||
}
|
||||
|
||||
// --- parse_color tests ---
|
||||
|
||||
#[test]
|
||||
fn test_parse_color_6_digit_hex() {
|
||||
let c = parse_color("#FF8800");
|
||||
assert_eq!(c, rgb::Color::new(255, 136, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_color_3_digit_hex() {
|
||||
let c = parse_color("#F80");
|
||||
assert_eq!(c, rgb::Color::new(255, 136, 0)); // F*17=255, 8*17=136, 0*17=0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_color_without_hash() {
|
||||
let c = parse_color("00FF00");
|
||||
assert_eq!(c, rgb::Color::new(0, 255, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_color_black() {
|
||||
let c = parse_color("#000000");
|
||||
assert_eq!(c, rgb::Color::new(0, 0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_color_white() {
|
||||
let c = parse_color("#FFFFFF");
|
||||
assert_eq!(c, rgb::Color::new(255, 255, 255));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_color_invalid_length() {
|
||||
// Invalid length → defaults to (0,0,0)
|
||||
let c = parse_color("#ABCD");
|
||||
assert_eq!(c, rgb::Color::new(0, 0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_color_empty() {
|
||||
let c = parse_color("");
|
||||
assert_eq!(c, rgb::Color::new(0, 0, 0));
|
||||
}
|
||||
|
||||
// --- build_rect_path tests ---
|
||||
|
||||
#[test]
|
||||
fn test_build_rect_path_no_radius() {
|
||||
let path = build_rect_path(10.0, 20.0, 100.0, 50.0, 0.0);
|
||||
assert!(path.is_some(), "should produce valid rect path with no radius");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_rect_path_with_radius() {
|
||||
let path = build_rect_path(0.0, 0.0, 100.0, 50.0, 5.0);
|
||||
assert!(path.is_some(), "should produce valid rounded rect path");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_rect_path_radius_clamped() {
|
||||
// Radius larger than half the smaller dimension → should be clamped
|
||||
let path = build_rect_path(0.0, 0.0, 20.0, 10.0, 100.0);
|
||||
assert!(path.is_some(), "should clamp radius and produce valid path");
|
||||
}
|
||||
|
||||
// --- build_ellipse_path tests ---
|
||||
|
||||
#[test]
|
||||
fn test_build_ellipse_path() {
|
||||
let path = build_ellipse_path(10.0, 20.0, 60.0, 40.0);
|
||||
assert!(path.is_some(), "should produce valid ellipse path");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_ellipse_path_circle() {
|
||||
// Equal width and height → circle
|
||||
let path = build_ellipse_path(0.0, 0.0, 50.0, 50.0);
|
||||
assert!(path.is_some(), "should produce valid circle path");
|
||||
}
|
||||
|
||||
// --- mm/pt conversion tests ---
|
||||
|
||||
#[test]
|
||||
fn test_mm_to_pt_conversion() {
|
||||
// 25.4mm = 72pt (1 inch)
|
||||
let result = mm(25.4);
|
||||
assert!((result - 72.0).abs() < 0.01, "25.4mm should be ~72pt, got {}", result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mm_zero() {
|
||||
assert_eq!(mm(0.0), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pt_conversion() {
|
||||
let result = pt(25.4);
|
||||
assert!((result - 72.0).abs() < 0.01);
|
||||
}
|
||||
|
||||
// --- render_pdf integration with various element types ---
|
||||
|
||||
#[test]
|
||||
fn test_render_pdf_with_line_element() {
|
||||
let layout = LayoutResult {
|
||||
pages: vec![PageLayout {
|
||||
page_index: 0,
|
||||
width_mm: 210.0,
|
||||
height_mm: 297.0,
|
||||
elements: vec![ElementLayout {
|
||||
id: "line1".to_string(),
|
||||
x_mm: 15.0,
|
||||
y_mm: 50.0,
|
||||
width_mm: 180.0,
|
||||
height_mm: 0.5,
|
||||
element_type: "line".to_string(),
|
||||
content: Some(ResolvedContent::Line),
|
||||
style: ResolvedStyle {
|
||||
stroke_color: Some("#FF0000".to_string()),
|
||||
stroke_width: Some(1.0),
|
||||
..Default::default()
|
||||
},
|
||||
children: vec![],
|
||||
}],
|
||||
}],
|
||||
};
|
||||
let fonts = test_fonts();
|
||||
let pdf = render_pdf(&layout, &fonts).expect("should render line element");
|
||||
assert!(pdf.starts_with(b"%PDF"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_pdf_with_container_background() {
|
||||
let layout = LayoutResult {
|
||||
pages: vec![PageLayout {
|
||||
page_index: 0,
|
||||
width_mm: 210.0,
|
||||
height_mm: 297.0,
|
||||
elements: vec![ElementLayout {
|
||||
id: "box".to_string(),
|
||||
x_mm: 20.0,
|
||||
y_mm: 20.0,
|
||||
width_mm: 170.0,
|
||||
height_mm: 100.0,
|
||||
element_type: "container".to_string(),
|
||||
content: None,
|
||||
style: ResolvedStyle {
|
||||
background_color: Some("#E0E0E0".to_string()),
|
||||
border_color: Some("#333333".to_string()),
|
||||
border_width: Some(0.5),
|
||||
border_radius: Some(3.0),
|
||||
..Default::default()
|
||||
},
|
||||
children: vec![],
|
||||
}],
|
||||
}],
|
||||
};
|
||||
let fonts = test_fonts();
|
||||
let pdf = render_pdf(&layout, &fonts).expect("should render container bg");
|
||||
assert!(pdf.starts_with(b"%PDF"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_pdf_with_shape_element() {
|
||||
let layout = LayoutResult {
|
||||
pages: vec![PageLayout {
|
||||
page_index: 0,
|
||||
width_mm: 210.0,
|
||||
height_mm: 297.0,
|
||||
elements: vec![ElementLayout {
|
||||
id: "shape1".to_string(),
|
||||
x_mm: 50.0,
|
||||
y_mm: 50.0,
|
||||
width_mm: 40.0,
|
||||
height_mm: 40.0,
|
||||
element_type: "shape".to_string(),
|
||||
content: Some(ResolvedContent::Shape {
|
||||
shape_type: "ellipse".to_string(),
|
||||
}),
|
||||
style: ResolvedStyle {
|
||||
background_color: Some("#3366FF".to_string()),
|
||||
border_color: Some("#000000".to_string()),
|
||||
border_width: Some(1.0),
|
||||
..Default::default()
|
||||
},
|
||||
children: vec![],
|
||||
}],
|
||||
}],
|
||||
};
|
||||
let fonts = test_fonts();
|
||||
let pdf = render_pdf(&layout, &fonts).expect("should render shape element");
|
||||
assert!(pdf.starts_with(b"%PDF"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_pdf_with_checkbox() {
|
||||
let layout = LayoutResult {
|
||||
pages: vec![PageLayout {
|
||||
page_index: 0,
|
||||
width_mm: 210.0,
|
||||
height_mm: 297.0,
|
||||
elements: vec![
|
||||
ElementLayout {
|
||||
id: "cb_checked".to_string(),
|
||||
x_mm: 15.0,
|
||||
y_mm: 15.0,
|
||||
width_mm: 5.0,
|
||||
height_mm: 5.0,
|
||||
element_type: "checkbox".to_string(),
|
||||
content: Some(ResolvedContent::Checkbox { checked: true }),
|
||||
style: ResolvedStyle::default(),
|
||||
children: vec![],
|
||||
},
|
||||
ElementLayout {
|
||||
id: "cb_unchecked".to_string(),
|
||||
x_mm: 15.0,
|
||||
y_mm: 25.0,
|
||||
width_mm: 5.0,
|
||||
height_mm: 5.0,
|
||||
element_type: "checkbox".to_string(),
|
||||
content: Some(ResolvedContent::Checkbox { checked: false }),
|
||||
style: ResolvedStyle::default(),
|
||||
children: vec![],
|
||||
},
|
||||
],
|
||||
}],
|
||||
};
|
||||
let fonts = test_fonts();
|
||||
let pdf = render_pdf(&layout, &fonts).expect("should render checkbox elements");
|
||||
assert!(pdf.starts_with(b"%PDF"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_pdf_empty_page() {
|
||||
let layout = LayoutResult {
|
||||
pages: vec![PageLayout {
|
||||
page_index: 0,
|
||||
width_mm: 210.0,
|
||||
height_mm: 297.0,
|
||||
elements: vec![],
|
||||
}],
|
||||
};
|
||||
let fonts = test_fonts();
|
||||
let pdf = render_pdf(&layout, &fonts).expect("empty page should still render");
|
||||
assert!(pdf.starts_with(b"%PDF"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_pdf_multi_page() {
|
||||
let layout = LayoutResult {
|
||||
pages: vec![
|
||||
PageLayout {
|
||||
page_index: 0,
|
||||
width_mm: 210.0,
|
||||
height_mm: 297.0,
|
||||
elements: vec![ElementLayout {
|
||||
id: "p1".to_string(),
|
||||
x_mm: 15.0,
|
||||
y_mm: 15.0,
|
||||
width_mm: 180.0,
|
||||
height_mm: 10.0,
|
||||
element_type: "static_text".to_string(),
|
||||
content: Some(ResolvedContent::Text { value: "Page 1".to_string() }),
|
||||
style: ResolvedStyle { font_size: Some(12.0), ..Default::default() },
|
||||
children: vec![],
|
||||
}],
|
||||
},
|
||||
PageLayout {
|
||||
page_index: 1,
|
||||
width_mm: 210.0,
|
||||
height_mm: 297.0,
|
||||
elements: vec![ElementLayout {
|
||||
id: "p2".to_string(),
|
||||
x_mm: 15.0,
|
||||
y_mm: 15.0,
|
||||
width_mm: 180.0,
|
||||
height_mm: 10.0,
|
||||
element_type: "static_text".to_string(),
|
||||
content: Some(ResolvedContent::Text { value: "Page 2".to_string() }),
|
||||
style: ResolvedStyle { font_size: Some(12.0), ..Default::default() },
|
||||
children: vec![],
|
||||
}],
|
||||
},
|
||||
],
|
||||
};
|
||||
let fonts = test_fonts();
|
||||
let pdf = render_pdf(&layout, &fonts).expect("multi-page should render");
|
||||
assert!(pdf.starts_with(b"%PDF"));
|
||||
assert!(pdf.len() > 200, "multi-page PDF should have reasonable size");
|
||||
}
|
||||
|
||||
// --- detect_image_format tests ---
|
||||
|
||||
#[test]
|
||||
fn test_detect_png() {
|
||||
let data = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
|
||||
|
||||
@@ -328,6 +328,7 @@ mod tests {
|
||||
fn test_container_to_style_direction() {
|
||||
let el = ContainerElement {
|
||||
id: "test".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
direction: "row".to_string(),
|
||||
@@ -354,6 +355,7 @@ mod tests {
|
||||
fn test_container_to_style_absolute() {
|
||||
let el = ContainerElement {
|
||||
id: "test".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Absolute { x: 20.0, y: 30.0 },
|
||||
size: SizeConstraint::default(),
|
||||
direction: "column".to_string(),
|
||||
|
||||
@@ -233,6 +233,7 @@ pub fn expand_table(
|
||||
.map(|(i, col)| {
|
||||
let text = TemplateElement::StaticText(StaticTextElement {
|
||||
id: format!("{}_hdr_{}", table.id, i),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
@@ -254,6 +255,7 @@ pub fn expand_table(
|
||||
});
|
||||
TemplateElement::Container(ContainerElement {
|
||||
id: format!("{}_hdr_{}_wrap", table.id, i),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: effective_widths[i].clone(),
|
||||
@@ -282,6 +284,7 @@ pub fn expand_table(
|
||||
|
||||
children.push(TemplateElement::Container(ContainerElement {
|
||||
id: format!("{}_header", table.id),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
@@ -313,6 +316,7 @@ pub fn expand_table(
|
||||
if table.style.border_color.is_some() {
|
||||
children.push(TemplateElement::Line(LineElement {
|
||||
id: format!("{}_header_line", table.id),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
@@ -340,6 +344,7 @@ pub fn expand_table(
|
||||
|
||||
let text = TemplateElement::StaticText(StaticTextElement {
|
||||
id: format!("{}_r{}c{}", table.id, row_idx, col_idx),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
@@ -361,6 +366,7 @@ pub fn expand_table(
|
||||
});
|
||||
TemplateElement::Container(ContainerElement {
|
||||
id: format!("{}_r{}c{}_wrap", table.id, row_idx, col_idx),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: effective_widths[col_idx].clone(),
|
||||
@@ -396,6 +402,7 @@ pub fn expand_table(
|
||||
|
||||
children.push(TemplateElement::Container(ContainerElement {
|
||||
id: format!("{}_row_{}", table.id, row_idx),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
@@ -427,6 +434,7 @@ pub fn expand_table(
|
||||
// Wrapper container (column direction, tüm tablo)
|
||||
ContainerElement {
|
||||
id: table.id.clone(),
|
||||
condition: None,
|
||||
position: table.position.clone(),
|
||||
size: table.size.clone(),
|
||||
direction: "column".to_string(),
|
||||
@@ -471,6 +479,7 @@ mod tests {
|
||||
|
||||
RepeatingTableElement {
|
||||
id: "tbl".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
@@ -497,6 +506,7 @@ mod tests {
|
||||
page_number_formats: HashMap::new(),
|
||||
rich_texts: HashMap::new(),
|
||||
charts: HashMap::new(),
|
||||
hidden_elements: std::collections::HashSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -729,6 +739,7 @@ mod tests {
|
||||
|
||||
let table = RepeatingTableElement {
|
||||
id: "tbl".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
|
||||
@@ -245,6 +245,10 @@ fn build_container(
|
||||
let mut children_ids = Vec::new();
|
||||
|
||||
for child in &el.children {
|
||||
// Koşullu render: hidden_elements'te olan elemanları atla
|
||||
if resolved.hidden_elements.contains(child.id()) {
|
||||
continue;
|
||||
}
|
||||
let child_node = build_element(
|
||||
child,
|
||||
taffy,
|
||||
@@ -896,8 +900,10 @@ mod tests {
|
||||
header: None,
|
||||
footer: None,
|
||||
format_config: None,
|
||||
locale: None,
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Auto,
|
||||
@@ -922,6 +928,7 @@ mod tests {
|
||||
children: vec![
|
||||
TemplateElement::StaticText(StaticTextElement {
|
||||
id: "title".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
@@ -940,6 +947,7 @@ mod tests {
|
||||
}),
|
||||
TemplateElement::Line(LineElement {
|
||||
id: "line1".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
@@ -956,6 +964,7 @@ mod tests {
|
||||
}),
|
||||
TemplateElement::StaticText(StaticTextElement {
|
||||
id: "body".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
@@ -1040,8 +1049,10 @@ mod tests {
|
||||
header: None,
|
||||
footer: None,
|
||||
format_config: None,
|
||||
locale: None,
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Auto,
|
||||
@@ -1065,6 +1076,7 @@ mod tests {
|
||||
break_inside: "auto".to_string(),
|
||||
children: vec![TemplateElement::Container(ContainerElement {
|
||||
id: "row".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
@@ -1089,6 +1101,7 @@ mod tests {
|
||||
children: vec![
|
||||
TemplateElement::StaticText(StaticTextElement {
|
||||
id: "left".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
@@ -1106,6 +1119,7 @@ mod tests {
|
||||
}),
|
||||
TemplateElement::StaticText(StaticTextElement {
|
||||
id: "right".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
@@ -1168,8 +1182,10 @@ mod tests {
|
||||
header: None,
|
||||
footer: None,
|
||||
format_config: None,
|
||||
locale: None,
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Auto,
|
||||
@@ -1193,6 +1209,7 @@ mod tests {
|
||||
break_inside: "auto".to_string(),
|
||||
children: vec![TemplateElement::StaticText(StaticTextElement {
|
||||
id: "abs_text".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Absolute { x: 50.0, y: 80.0 },
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fixed { value: 100.0 },
|
||||
@@ -1257,8 +1274,10 @@ mod tests {
|
||||
header: None,
|
||||
footer: None,
|
||||
format_config: None,
|
||||
locale: None,
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: sz_auto.clone(),
|
||||
direction: "column".to_string(),
|
||||
@@ -1277,6 +1296,7 @@ mod tests {
|
||||
// Header row
|
||||
TemplateElement::Container(ContainerElement {
|
||||
id: "c_header".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: sz_fr_auto.clone(),
|
||||
direction: "row".to_string(),
|
||||
@@ -1290,6 +1310,7 @@ mod tests {
|
||||
// Sol: firma bilgileri
|
||||
TemplateElement::Container(ContainerElement {
|
||||
id: "c_firma".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: sz_fr_auto.clone(),
|
||||
direction: "column".to_string(),
|
||||
@@ -1302,6 +1323,7 @@ mod tests {
|
||||
children: vec![
|
||||
TemplateElement::StaticText(StaticTextElement {
|
||||
id: "el_firma_unvan".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: sz_auto.clone(),
|
||||
style: TextStyle {
|
||||
@@ -1313,6 +1335,7 @@ mod tests {
|
||||
}),
|
||||
TemplateElement::StaticText(StaticTextElement {
|
||||
id: "el_firma_adres".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: sz_auto.clone(),
|
||||
style: TextStyle {
|
||||
@@ -1324,6 +1347,7 @@ mod tests {
|
||||
}),
|
||||
TemplateElement::StaticText(StaticTextElement {
|
||||
id: "el_firma_il".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: sz_auto.clone(),
|
||||
style: TextStyle {
|
||||
@@ -1334,6 +1358,7 @@ mod tests {
|
||||
}),
|
||||
TemplateElement::StaticText(StaticTextElement {
|
||||
id: "el_firma_tel".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: sz_auto.clone(),
|
||||
style: TextStyle {
|
||||
@@ -1344,6 +1369,7 @@ mod tests {
|
||||
}),
|
||||
TemplateElement::StaticText(StaticTextElement {
|
||||
id: "el_firma_vd".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: sz_auto.clone(),
|
||||
style: TextStyle {
|
||||
@@ -1354,6 +1380,7 @@ mod tests {
|
||||
}),
|
||||
TemplateElement::StaticText(StaticTextElement {
|
||||
id: "el_firma_vn".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: sz_auto.clone(),
|
||||
style: TextStyle {
|
||||
@@ -1367,6 +1394,7 @@ mod tests {
|
||||
// Sağ: fatura başlığı
|
||||
TemplateElement::Container(ContainerElement {
|
||||
id: "c_fatura_baslik".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: sz_auto.clone(),
|
||||
direction: "column".to_string(),
|
||||
@@ -1379,6 +1407,7 @@ mod tests {
|
||||
children: vec![
|
||||
TemplateElement::StaticText(StaticTextElement {
|
||||
id: "el_fatura_baslik".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: sz_auto.clone(),
|
||||
style: TextStyle {
|
||||
@@ -1390,6 +1419,7 @@ mod tests {
|
||||
}),
|
||||
TemplateElement::StaticText(StaticTextElement {
|
||||
id: "el_fatura_no".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: sz_auto.clone(),
|
||||
style: TextStyle {
|
||||
@@ -1400,6 +1430,7 @@ mod tests {
|
||||
}),
|
||||
TemplateElement::StaticText(StaticTextElement {
|
||||
id: "el_fatura_tarih".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: sz_auto.clone(),
|
||||
style: TextStyle {
|
||||
@@ -1410,6 +1441,7 @@ mod tests {
|
||||
}),
|
||||
TemplateElement::StaticText(StaticTextElement {
|
||||
id: "el_fatura_vade".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: sz_auto.clone(),
|
||||
style: TextStyle {
|
||||
|
||||
@@ -25,12 +25,14 @@ fn base_template() -> Template {
|
||||
header: None,
|
||||
footer: None,
|
||||
format_config: None,
|
||||
locale: None,
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
direction: "column".to_string(),
|
||||
gap: 5.0,
|
||||
condition: None,
|
||||
padding: Padding {
|
||||
top: 15.0,
|
||||
right: 15.0,
|
||||
@@ -57,6 +59,7 @@ fn test_1_2_text_wrapping_layout_height() {
|
||||
tpl.root.children.push(TemplateElement::StaticText(StaticTextElement {
|
||||
id: "long_text".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
condition: None,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fixed { value: 40.0 }, // 40mm genişlik — kısa
|
||||
height: SizeValue::Auto,
|
||||
@@ -92,6 +95,7 @@ fn test_1_2_text_wrapping_pdf_renders() {
|
||||
let mut tpl = base_template();
|
||||
tpl.root.children.push(TemplateElement::StaticText(StaticTextElement {
|
||||
id: "wrap_pdf".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fixed { value: 50.0 },
|
||||
@@ -123,6 +127,7 @@ fn test_1_3_image_object_fit_in_layout() {
|
||||
tpl.root.children.push(TemplateElement::Image(ImageElement {
|
||||
id: "img_contain".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
condition: None,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fixed { value: 40.0 },
|
||||
height: SizeValue::Fixed { value: 30.0 },
|
||||
@@ -164,6 +169,7 @@ fn test_1_4_italic_font_in_pdf() {
|
||||
.push(TemplateElement::StaticText(StaticTextElement {
|
||||
id: "italic_text".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
condition: None,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
height: SizeValue::Auto,
|
||||
@@ -201,6 +207,7 @@ fn test_1_4_bold_italic_font_in_pdf() {
|
||||
.push(TemplateElement::StaticText(StaticTextElement {
|
||||
id: "bold_italic".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
condition: None,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
height: SizeValue::Auto,
|
||||
@@ -234,6 +241,7 @@ fn test_2_1_repeat_header_false_no_repeat_on_second_page() {
|
||||
.push(TemplateElement::RepeatingTable(RepeatingTableElement {
|
||||
id: "tbl_no_repeat".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
condition: None,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
height: SizeValue::Auto,
|
||||
@@ -299,6 +307,7 @@ fn test_2_1_repeat_header_true_repeats_on_second_page() {
|
||||
.push(TemplateElement::RepeatingTable(RepeatingTableElement {
|
||||
id: "tbl_repeat".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
condition: None,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
height: SizeValue::Auto,
|
||||
@@ -382,6 +391,7 @@ fn test_2_2_table_column_format_currency() {
|
||||
.push(TemplateElement::RepeatingTable(RepeatingTableElement {
|
||||
id: "tbl_fmt".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
condition: None,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
height: SizeValue::Auto,
|
||||
@@ -451,6 +461,7 @@ fn test_2_3_rounded_rectangle_renders() {
|
||||
tpl.root.children.push(TemplateElement::Shape(ShapeElement {
|
||||
id: "rounded_shape".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
condition: None,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fixed { value: 50.0 },
|
||||
height: SizeValue::Fixed { value: 30.0 },
|
||||
@@ -496,6 +507,7 @@ fn test_2_3_container_border_radius_renders() {
|
||||
.push(TemplateElement::StaticText(StaticTextElement {
|
||||
id: "text_in_rounded".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
condition: None,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
height: SizeValue::Auto,
|
||||
@@ -594,6 +606,7 @@ fn test_ellipse_shape_renders() {
|
||||
tpl.root.children.push(TemplateElement::Shape(ShapeElement {
|
||||
id: "ellipse".to_string(),
|
||||
position: PositionMode::Flow,
|
||||
condition: None,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fixed { value: 40.0 },
|
||||
height: SizeValue::Fixed { value: 20.0 },
|
||||
@@ -613,3 +626,437 @@ fn test_ellipse_shape_renders() {
|
||||
let pdf = dreport_layout::pdf_render::render_pdf(&layout, &fonts).unwrap();
|
||||
assert!(pdf.starts_with(b"%PDF"));
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 7.1 Conditional Rendering
|
||||
// =============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_7_1_condition_gt_hides_element() {
|
||||
let mut tpl = base_template();
|
||||
tpl.root.children.push(TemplateElement::StaticText(StaticTextElement {
|
||||
id: "always_visible".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
style: TextStyle { font_size: Some(10.0), ..Default::default() },
|
||||
content: "Visible".to_string(),
|
||||
}));
|
||||
tpl.root.children.push(TemplateElement::Text(TextElement {
|
||||
id: "conditional_text".to_string(),
|
||||
condition: Some(Condition {
|
||||
path: "toplamlar.iskonto".to_string(),
|
||||
operator: "gt".to_string(),
|
||||
value: Some(serde_json::json!(0)),
|
||||
}),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
style: TextStyle { font_size: Some(10.0), ..Default::default() },
|
||||
content: None,
|
||||
binding: ScalarBinding { path: "toplamlar.iskonto".to_string() },
|
||||
}));
|
||||
|
||||
let fonts = load_test_fonts();
|
||||
|
||||
// iskonto = 0 → koşul sağlanmaz, element gizlenmeli
|
||||
let data_no_iskonto = serde_json::json!({ "toplamlar": { "iskonto": 0 } });
|
||||
let layout = compute_layout(&tpl, &data_no_iskonto, &fonts).unwrap();
|
||||
let page = &layout.pages[0];
|
||||
assert!(
|
||||
!page.elements.iter().any(|e| e.id == "conditional_text"),
|
||||
"iskonto=0 durumunda conditional_text gizlenmeli"
|
||||
);
|
||||
assert!(
|
||||
page.elements.iter().any(|e| e.id == "always_visible"),
|
||||
"koşulsuz eleman her zaman görünmeli"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_7_1_condition_gt_shows_element() {
|
||||
let mut tpl = base_template();
|
||||
tpl.root.children.push(TemplateElement::Text(TextElement {
|
||||
id: "conditional_text".to_string(),
|
||||
condition: Some(Condition {
|
||||
path: "toplamlar.iskonto".to_string(),
|
||||
operator: "gt".to_string(),
|
||||
value: Some(serde_json::json!(0)),
|
||||
}),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
style: TextStyle { font_size: Some(10.0), ..Default::default() },
|
||||
content: None,
|
||||
binding: ScalarBinding { path: "toplamlar.iskonto".to_string() },
|
||||
}));
|
||||
|
||||
let fonts = load_test_fonts();
|
||||
|
||||
// iskonto = 500 → koşul sağlanır, element görünmeli
|
||||
let data_with_iskonto = serde_json::json!({ "toplamlar": { "iskonto": 500 } });
|
||||
let layout = compute_layout(&tpl, &data_with_iskonto, &fonts).unwrap();
|
||||
let page = &layout.pages[0];
|
||||
assert!(
|
||||
page.elements.iter().any(|e| e.id == "conditional_text"),
|
||||
"iskonto>0 durumunda conditional_text görünmeli"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_7_1_condition_eq_operator() {
|
||||
let mut tpl = base_template();
|
||||
tpl.root.children.push(TemplateElement::StaticText(StaticTextElement {
|
||||
id: "status_text".to_string(),
|
||||
condition: Some(Condition {
|
||||
path: "durum".to_string(),
|
||||
operator: "eq".to_string(),
|
||||
value: Some(serde_json::json!("aktif")),
|
||||
}),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
style: TextStyle { font_size: Some(10.0), ..Default::default() },
|
||||
content: "Aktif".to_string(),
|
||||
}));
|
||||
|
||||
let fonts = load_test_fonts();
|
||||
|
||||
// durum = "aktif" → görünür
|
||||
let layout = compute_layout(&tpl, &serde_json::json!({"durum": "aktif"}), &fonts).unwrap();
|
||||
assert!(layout.pages[0].elements.iter().any(|e| e.id == "status_text"));
|
||||
|
||||
// durum = "pasif" → gizli
|
||||
let layout = compute_layout(&tpl, &serde_json::json!({"durum": "pasif"}), &fonts).unwrap();
|
||||
assert!(!layout.pages[0].elements.iter().any(|e| e.id == "status_text"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_7_1_condition_empty_not_empty() {
|
||||
let mut tpl = base_template();
|
||||
tpl.root.children.push(TemplateElement::StaticText(StaticTextElement {
|
||||
id: "show_if_exists".to_string(),
|
||||
condition: Some(Condition {
|
||||
path: "note".to_string(),
|
||||
operator: "not_empty".to_string(),
|
||||
value: None,
|
||||
}),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
style: TextStyle { font_size: Some(10.0), ..Default::default() },
|
||||
content: "Has note".to_string(),
|
||||
}));
|
||||
|
||||
let fonts = load_test_fonts();
|
||||
|
||||
// note yok → gizli
|
||||
let layout = compute_layout(&tpl, &serde_json::json!({}), &fonts).unwrap();
|
||||
assert!(!layout.pages[0].elements.iter().any(|e| e.id == "show_if_exists"));
|
||||
|
||||
// note var → görünür
|
||||
let layout = compute_layout(&tpl, &serde_json::json!({"note": "merhaba"}), &fonts).unwrap();
|
||||
assert!(layout.pages[0].elements.iter().any(|e| e.id == "show_if_exists"));
|
||||
|
||||
// note boş string → gizli
|
||||
let layout = compute_layout(&tpl, &serde_json::json!({"note": ""}), &fonts).unwrap();
|
||||
assert!(!layout.pages[0].elements.iter().any(|e| e.id == "show_if_exists"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_7_1_condition_on_container_hides_children() {
|
||||
let mut tpl = base_template();
|
||||
tpl.root.children.push(TemplateElement::Container(ContainerElement {
|
||||
id: "cond_container".to_string(),
|
||||
condition: Some(Condition {
|
||||
path: "show".to_string(),
|
||||
operator: "eq".to_string(),
|
||||
value: Some(serde_json::json!(true)),
|
||||
}),
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
direction: "column".to_string(),
|
||||
gap: 0.0,
|
||||
padding: Padding::default(),
|
||||
align: "stretch".to_string(),
|
||||
justify: "start".to_string(),
|
||||
style: ContainerStyle::default(),
|
||||
break_inside: "auto".to_string(),
|
||||
children: vec![TemplateElement::StaticText(StaticTextElement {
|
||||
id: "child_text".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
style: TextStyle { font_size: Some(10.0), ..Default::default() },
|
||||
content: "Child".to_string(),
|
||||
})],
|
||||
}));
|
||||
|
||||
let fonts = load_test_fonts();
|
||||
|
||||
// show=false → container ve çocukları gizli
|
||||
let layout = compute_layout(&tpl, &serde_json::json!({"show": false}), &fonts).unwrap();
|
||||
assert!(!layout.pages[0].elements.iter().any(|e| e.id == "cond_container"));
|
||||
assert!(!layout.pages[0].elements.iter().any(|e| e.id == "child_text"));
|
||||
|
||||
// show=true → container ve çocukları görünür
|
||||
let layout = compute_layout(&tpl, &serde_json::json!({"show": true}), &fonts).unwrap();
|
||||
assert!(layout.pages[0].elements.iter().any(|e| e.id == "cond_container"));
|
||||
assert!(layout.pages[0].elements.iter().any(|e| e.id == "child_text"));
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 7.5 Localization / FormatConfig from locale
|
||||
// =============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_7_5_locale_en_us_currency() {
|
||||
let config = FormatConfig::from_locale("en-US");
|
||||
assert_eq!(config.thousands_separator, ",");
|
||||
assert_eq!(config.decimal_separator, ".");
|
||||
assert_eq!(config.currency_symbol, "$");
|
||||
assert_eq!(config.currency_position, "prefix");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_7_5_locale_de_de_currency() {
|
||||
let config = FormatConfig::from_locale("de-DE");
|
||||
assert_eq!(config.thousands_separator, ".");
|
||||
assert_eq!(config.decimal_separator, ",");
|
||||
assert_eq!(config.currency_symbol, "€");
|
||||
assert_eq!(config.currency_position, "suffix");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_7_5_locale_fr_fr_currency() {
|
||||
let config = FormatConfig::from_locale("fr-FR");
|
||||
assert_eq!(config.thousands_separator, " ");
|
||||
assert_eq!(config.decimal_separator, ",");
|
||||
assert_eq!(config.currency_symbol, "€");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_7_5_locale_tr_default() {
|
||||
let config = FormatConfig::from_locale("tr-TR");
|
||||
assert_eq!(config, FormatConfig::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_7_5_unknown_locale_falls_back_to_default() {
|
||||
let config = FormatConfig::from_locale("xx-XX");
|
||||
assert_eq!(config, FormatConfig::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_7_5_effective_format_config_priority() {
|
||||
// format_config set → onu kullan
|
||||
let tpl = Template {
|
||||
id: "t1".to_string(),
|
||||
name: "Test".to_string(),
|
||||
page: PageSettings { width: 210.0, height: 297.0 },
|
||||
fonts: vec![],
|
||||
header: None,
|
||||
footer: None,
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
direction: "column".to_string(),
|
||||
gap: 0.0,
|
||||
padding: Padding::default(),
|
||||
align: "stretch".to_string(),
|
||||
justify: "start".to_string(),
|
||||
style: ContainerStyle::default(),
|
||||
break_inside: "auto".to_string(),
|
||||
children: vec![],
|
||||
},
|
||||
format_config: Some(FormatConfig {
|
||||
thousands_separator: ",".to_string(),
|
||||
decimal_separator: ".".to_string(),
|
||||
currency_symbol: "$".to_string(),
|
||||
currency_position: "prefix".to_string(),
|
||||
}),
|
||||
locale: Some("de-DE".to_string()),
|
||||
};
|
||||
let fc = tpl.effective_format_config();
|
||||
assert_eq!(fc.currency_symbol, "$"); // format_config kullanılır, de-DE değil
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_7_5_effective_format_config_locale_fallback() {
|
||||
let tpl = Template {
|
||||
id: "t1".to_string(),
|
||||
name: "Test".to_string(),
|
||||
page: PageSettings { width: 210.0, height: 297.0 },
|
||||
fonts: vec![],
|
||||
header: None,
|
||||
footer: None,
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
direction: "column".to_string(),
|
||||
gap: 0.0,
|
||||
padding: Padding::default(),
|
||||
align: "stretch".to_string(),
|
||||
justify: "start".to_string(),
|
||||
style: ContainerStyle::default(),
|
||||
break_inside: "auto".to_string(),
|
||||
children: vec![],
|
||||
},
|
||||
format_config: None,
|
||||
locale: Some("en-US".to_string()),
|
||||
};
|
||||
let fc = tpl.effective_format_config();
|
||||
assert_eq!(fc.currency_symbol, "$");
|
||||
assert_eq!(fc.currency_position, "prefix");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_7_5_locale_affects_table_currency_format() {
|
||||
let mut tpl = base_template();
|
||||
tpl.locale = Some("en-US".to_string());
|
||||
tpl.root.children.push(TemplateElement::RepeatingTable(RepeatingTableElement {
|
||||
id: "tbl_locale".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
height: SizeValue::Auto,
|
||||
..Default::default()
|
||||
},
|
||||
data_source: ArrayBinding { path: "items".to_string() },
|
||||
columns: vec![
|
||||
TableColumn {
|
||||
id: "col_price".to_string(),
|
||||
field: "price".to_string(),
|
||||
title: "Price".to_string(),
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
align: "right".to_string(),
|
||||
format: Some("currency".to_string()),
|
||||
},
|
||||
],
|
||||
style: TableStyle::default(),
|
||||
repeat_header: Some(true),
|
||||
}));
|
||||
|
||||
let data = serde_json::json!({
|
||||
"items": [
|
||||
{ "price": 1500 }
|
||||
]
|
||||
});
|
||||
|
||||
// data_resolve seviyesinde kontrol: locale en-US → $ prefix, comma thousands
|
||||
let resolved = dreport_layout::data_resolve::resolve_template(&tpl, &data);
|
||||
let table = resolved.tables.get("tbl_locale").expect("tbl_locale should be resolved");
|
||||
assert_eq!(table.rows.len(), 1);
|
||||
assert_eq!(table.rows[0][0], "$1,500.00");
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 8.1 Chart Legend — tek seri durumunda da render edilmeli
|
||||
// =============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_8_1_legend_renders_for_single_series() {
|
||||
use dreport_layout::chart_render::render_svg;
|
||||
use dreport_layout::data_resolve::{ChartSeries, ResolvedChartData};
|
||||
|
||||
let data = ResolvedChartData {
|
||||
chart_type: ChartType::Bar,
|
||||
categories: vec!["A".to_string(), "B".to_string()],
|
||||
series: vec![ChartSeries {
|
||||
name: "Revenue".to_string(),
|
||||
values: vec![100.0, 200.0],
|
||||
}],
|
||||
title: None,
|
||||
legend: Some(ChartLegend { show: true, position: None, font_size: None }),
|
||||
labels: None,
|
||||
axis: None,
|
||||
style: ChartStyle::default(),
|
||||
group_mode: None,
|
||||
};
|
||||
|
||||
let svg = render_svg(&data, 100.0, 60.0);
|
||||
let has_swatch = svg.contains(r#"width="2.5" height="2.5""#);
|
||||
assert!(has_swatch, "tek serili chart'ta legend.show=true olunca legend render edilmeli");
|
||||
assert!(svg.contains("Revenue"), "legend seri adını göstermeli");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_8_1_legend_hidden_when_show_false() {
|
||||
use dreport_layout::chart_render::render_svg;
|
||||
use dreport_layout::data_resolve::{ChartSeries, ResolvedChartData};
|
||||
|
||||
let data = ResolvedChartData {
|
||||
chart_type: ChartType::Bar,
|
||||
categories: vec!["A".to_string()],
|
||||
series: vec![ChartSeries {
|
||||
name: "Sales".to_string(),
|
||||
values: vec![50.0],
|
||||
}],
|
||||
title: None,
|
||||
legend: Some(ChartLegend { show: false, position: None, font_size: None }),
|
||||
labels: None,
|
||||
axis: None,
|
||||
style: ChartStyle::default(),
|
||||
group_mode: None,
|
||||
};
|
||||
|
||||
let svg = render_svg(&data, 100.0, 60.0);
|
||||
let has_swatch = svg.contains(r#"width="2.5" height="2.5""#);
|
||||
assert!(!has_swatch, "legend.show=false olunca legend render edilmemeli");
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 8.2 Pie Chart Label Kontrolü
|
||||
// =============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_8_2_pie_labels_hidden_when_show_false() {
|
||||
use dreport_layout::chart_render::render_svg;
|
||||
use dreport_layout::data_resolve::{ChartSeries, ResolvedChartData};
|
||||
|
||||
let data = ResolvedChartData {
|
||||
chart_type: ChartType::Pie,
|
||||
categories: vec!["Gida".to_string(), "Ulasim".to_string(), "Kira".to_string()],
|
||||
series: vec![ChartSeries {
|
||||
name: "data".to_string(),
|
||||
values: vec![50.0, 30.0, 20.0],
|
||||
}],
|
||||
title: None,
|
||||
legend: None,
|
||||
labels: Some(ChartLabels { show: false, font_size: None, color: None }),
|
||||
axis: None,
|
||||
style: ChartStyle::default(),
|
||||
group_mode: None,
|
||||
};
|
||||
|
||||
let svg = render_svg(&data, 80.0, 80.0);
|
||||
assert!(!svg.contains("Gida"), "labels.show=false iken kategori adı görünmemeli");
|
||||
assert!(!svg.contains("Ulasim"), "labels.show=false iken kategori adı görünmemeli");
|
||||
assert!(!svg.contains("50%"), "labels.show=false iken yüzde etiketi görünmemeli");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_8_2_pie_labels_shown_when_show_true() {
|
||||
use dreport_layout::chart_render::render_svg;
|
||||
use dreport_layout::data_resolve::{ChartSeries, ResolvedChartData};
|
||||
|
||||
let data = ResolvedChartData {
|
||||
chart_type: ChartType::Pie,
|
||||
categories: vec!["Gida".to_string(), "Ulasim".to_string()],
|
||||
series: vec![ChartSeries {
|
||||
name: "data".to_string(),
|
||||
values: vec![75.0, 25.0],
|
||||
}],
|
||||
title: None,
|
||||
legend: None,
|
||||
labels: Some(ChartLabels { show: true, font_size: None, color: None }),
|
||||
axis: None,
|
||||
style: ChartStyle::default(),
|
||||
group_mode: None,
|
||||
};
|
||||
|
||||
let svg = render_svg(&data, 80.0, 80.0);
|
||||
assert!(svg.contains("Gida"), "labels.show=true iken kategori adı görünmeli");
|
||||
assert!(svg.contains("75%"), "labels.show=true iken yüzde etiketi görünmeli");
|
||||
}
|
||||
|
||||
@@ -18,8 +18,10 @@ fn simple_template() -> Template {
|
||||
header: None,
|
||||
footer: None,
|
||||
format_config: None,
|
||||
locale: None,
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
direction: "column".to_string(),
|
||||
@@ -36,6 +38,7 @@ fn simple_template() -> Template {
|
||||
break_inside: "auto".to_string(),
|
||||
children: vec![TemplateElement::StaticText(StaticTextElement {
|
||||
id: "title".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
@@ -161,8 +164,10 @@ fn test_compute_layout_with_data_binding() {
|
||||
header: None,
|
||||
footer: None,
|
||||
format_config: None,
|
||||
locale: None,
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
direction: "column".to_string(),
|
||||
@@ -179,6 +184,7 @@ fn test_compute_layout_with_data_binding() {
|
||||
break_inside: "auto".to_string(),
|
||||
children: vec![TemplateElement::Text(TextElement {
|
||||
id: "bound_text".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
@@ -227,8 +233,10 @@ fn test_compute_layout_multiple_children_ordering() {
|
||||
header: None,
|
||||
footer: None,
|
||||
format_config: None,
|
||||
locale: None,
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
direction: "column".to_string(),
|
||||
@@ -246,6 +254,7 @@ fn test_compute_layout_multiple_children_ordering() {
|
||||
children: vec![
|
||||
TemplateElement::StaticText(StaticTextElement {
|
||||
id: "first".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
@@ -260,6 +269,7 @@ fn test_compute_layout_multiple_children_ordering() {
|
||||
}),
|
||||
TemplateElement::StaticText(StaticTextElement {
|
||||
id: "second".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
|
||||
@@ -21,8 +21,10 @@ fn simple_template() -> Template {
|
||||
header: None,
|
||||
footer: None,
|
||||
format_config: None,
|
||||
locale: None,
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
direction: "column".to_string(),
|
||||
@@ -39,6 +41,7 @@ fn simple_template() -> Template {
|
||||
break_inside: "auto".to_string(),
|
||||
children: vec![TemplateElement::StaticText(StaticTextElement {
|
||||
id: "title".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
@@ -89,8 +92,10 @@ fn test_render_pdf_with_multiple_elements() {
|
||||
header: None,
|
||||
footer: None,
|
||||
format_config: None,
|
||||
locale: None,
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
direction: "column".to_string(),
|
||||
@@ -108,6 +113,7 @@ fn test_render_pdf_with_multiple_elements() {
|
||||
children: vec![
|
||||
TemplateElement::StaticText(StaticTextElement {
|
||||
id: "header".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
@@ -123,6 +129,7 @@ fn test_render_pdf_with_multiple_elements() {
|
||||
}),
|
||||
TemplateElement::Line(LineElement {
|
||||
id: "sep".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
@@ -136,6 +143,7 @@ fn test_render_pdf_with_multiple_elements() {
|
||||
}),
|
||||
TemplateElement::StaticText(StaticTextElement {
|
||||
id: "body".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
@@ -182,8 +190,10 @@ fn test_render_pdf_with_container_styles() {
|
||||
header: None,
|
||||
footer: None,
|
||||
format_config: None,
|
||||
locale: None,
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
direction: "column".to_string(),
|
||||
@@ -205,6 +215,7 @@ fn test_render_pdf_with_container_styles() {
|
||||
break_inside: "auto".to_string(),
|
||||
children: vec![TemplateElement::StaticText(StaticTextElement {
|
||||
id: "text".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
@@ -244,8 +255,10 @@ fn test_page_break_produces_multiple_pages() {
|
||||
header: None,
|
||||
footer: None,
|
||||
format_config: None,
|
||||
locale: None,
|
||||
root: ContainerElement {
|
||||
id: "root".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint::default(),
|
||||
direction: "column".to_string(),
|
||||
@@ -263,6 +276,7 @@ fn test_page_break_produces_multiple_pages() {
|
||||
children: vec![
|
||||
TemplateElement::StaticText(StaticTextElement {
|
||||
id: "t1".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
@@ -277,9 +291,11 @@ fn test_page_break_produces_multiple_pages() {
|
||||
}),
|
||||
TemplateElement::PageBreak(PageBreakElement {
|
||||
id: "pb1".to_string(),
|
||||
condition: None,
|
||||
}),
|
||||
TemplateElement::StaticText(StaticTextElement {
|
||||
id: "t2".to_string(),
|
||||
condition: None,
|
||||
position: PositionMode::Flow,
|
||||
size: SizeConstraint {
|
||||
width: SizeValue::Fr { value: 1.0 },
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user