This commit is contained in:
2026-03-29 19:17:09 +03:00
parent 9b17d2aef4
commit 1cbe42ed75
34 changed files with 4690 additions and 3105 deletions

738
CLAUDE.md

File diff suppressed because it is too large Load Diff

2722
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,3 @@
[workspace]
members = ["core", "backend"]
members = ["core", "backend", "layout-engine"]
resolver = "2"

View File

@@ -5,6 +5,7 @@ edition = "2024"
[dependencies]
dreport-core = { path = "../core" }
dreport-layout = { path = "../layout-engine" }
axum = "0.8"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
@@ -12,7 +13,3 @@ serde_json = "1"
tower-http = { version = "0.6", features = ["cors"] }
thiserror = "2"
anyhow = "1"
typst = "0.14"
typst-pdf = "0.14"
typst-kit = { version = "0.14", features = ["fonts"] }
chrono = "0.4"

View File

@@ -1,18 +1,17 @@
use axum::{Router, serve};
use dreport_layout::FontData;
use std::sync::Arc;
use tokio::net::TcpListener;
use tower_http::cors::{Any, CorsLayer};
mod models;
mod routes;
mod typst_engine;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Fontları bir kez yükle — tüm request'lerde paylaşılacak
println!("Fontlar yukleniyor...");
let fonts = Arc::new(typst_engine::fonts::load_fonts());
println!("Fontlar yuklendi ({} font)", fonts.fonts.len());
let fonts = Arc::new(load_fonts());
println!("Fontlar yuklendi ({} font dosyasi)", fonts.len());
let cors = CorsLayer::new()
.allow_origin(Any)
@@ -30,3 +29,31 @@ async fn main() -> anyhow::Result<()> {
Ok(())
}
/// Proje fontlarını yükler (backend/fonts/ dizininden).
fn load_fonts() -> Vec<FontData> {
let font_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("fonts");
let mut fonts = Vec::new();
let entries = std::fs::read_dir(&font_dir).expect("backend/fonts dizini bulunamadi");
for entry in entries {
let entry = entry.unwrap();
let path = entry.path();
if path.extension().is_some_and(|e| e == "ttf" || e == "otf") {
let data = std::fs::read(&path).unwrap();
let family = if path
.file_name()
.unwrap()
.to_str()
.unwrap()
.contains("Mono")
{
"Noto Sans Mono".to_string()
} else {
"Noto Sans".to_string()
};
fonts.push(FontData { family, data });
}
}
fonts
}

View File

@@ -1,7 +1,7 @@
use axum::{Router, routing::get, Json};
use dreport_layout::FontData;
use serde::Serialize;
use std::sync::Arc;
use typst_kit::fonts::Fonts;
#[derive(Serialize)]
struct HealthResponse {
@@ -16,6 +16,6 @@ async fn health() -> Json<HealthResponse> {
})
}
pub fn router() -> Router<Arc<Fonts>> {
pub fn router() -> Router<Arc<Vec<FontData>>> {
Router::new().route("/api/health", get(health))
}

View File

@@ -2,10 +2,10 @@ mod health;
mod render;
use axum::Router;
use dreport_layout::FontData;
use std::sync::Arc;
use typst_kit::fonts::Fonts;
pub fn router() -> Router<Arc<Fonts>> {
pub fn router() -> Router<Arc<Vec<FontData>>> {
Router::new()
.merge(health::router())
.merge(render::router())

View File

@@ -6,13 +6,11 @@ use axum::{
routing::post,
Json,
};
use dreport_layout::FontData;
use serde::Deserialize;
use std::sync::Arc;
use crate::models::Template;
use crate::typst_engine::compiler::compile_pdf;
use crate::typst_engine::template_to_typst::{self, RenderMode};
use typst_kit::fonts::Fonts;
#[derive(Deserialize)]
pub struct RenderRequest {
@@ -22,17 +20,14 @@ pub struct RenderRequest {
/// POST /api/render — Template + Data → PDF
pub async fn render(
State(fonts): State<Arc<Fonts>>,
State(fonts): State<Arc<Vec<FontData>>>,
Json(payload): Json<RenderRequest>,
) -> impl IntoResponse {
// 1. Template JSON → Typst markup
let typst_markup = template_to_typst::template_to_typst(&payload.template, &payload.data, RenderMode::Pdf);
// 1. Layout hesapla
let layout = dreport_layout::compute_layout(&payload.template, &payload.data, &fonts);
// 2. Base64 image'ları çıkar
let files = template_to_typst::extract_image_files(&payload.template);
// 3. Typst markup → PDF
match compile_pdf(typst_markup, &fonts, files) {
// 2. PDF render
match dreport_layout::pdf_render::render_pdf(&layout, &fonts) {
Ok(pdf_bytes) => (
StatusCode::OK,
[(header::CONTENT_TYPE, "application/pdf")],
@@ -41,12 +36,12 @@ pub async fn render(
.into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("PDF derleme hatasi: {}", err),
format!("PDF render hatasi: {}", err),
)
.into_response(),
}
}
pub fn router() -> Router<Arc<Fonts>> {
pub fn router() -> Router<Arc<Vec<FontData>>> {
Router::new().route("/api/render", post(render))
}

View File

@@ -1,117 +0,0 @@
use std::collections::HashMap;
use typst::diag::{FileError, FileResult};
use typst::foundations::{Bytes, Datetime, Smart};
use typst::layout::PagedDocument;
use typst::syntax::{FileId, Source, VirtualPath};
use typst::text::{Font, FontBook};
use typst::utils::LazyHash;
use typst::{Library, LibraryExt, World};
use typst_kit::fonts::Fonts;
use typst_pdf::{PdfOptions, pdf};
/// Typst World implementasyonu — dreport backend için.
/// Fonts referans olarak tutulur (clone edilemez).
pub struct DreportWorld<'a> {
library: LazyHash<Library>,
book: LazyHash<FontBook>,
fonts: &'a Fonts,
main_source: Source,
/// Sanal dosyalar (ör: base64 image'lar)
files: HashMap<String, Bytes>,
}
impl<'a> DreportWorld<'a> {
pub fn new(typst_markup: String, fonts: &'a Fonts, files: HashMap<String, Vec<u8>>) -> Self {
let main_id = FileId::new_fake(VirtualPath::new("main.typ"));
Self {
library: LazyHash::new(Library::default()),
book: LazyHash::new(fonts.book.clone()),
fonts,
main_source: Source::new(main_id, typst_markup),
files: files.into_iter().map(|(k, v)| (k, Bytes::new(v))).collect(),
}
}
}
impl World for DreportWorld<'_> {
fn library(&self) -> &LazyHash<Library> {
&self.library
}
fn book(&self) -> &LazyHash<FontBook> {
&self.book
}
fn main(&self) -> FileId {
self.main_source.id()
}
fn source(&self, id: FileId) -> FileResult<Source> {
if id == self.main_source.id() {
Ok(self.main_source.clone())
} else {
Err(FileError::NotFound(id.vpath().as_rooted_path().into()))
}
}
fn file(&self, id: FileId) -> FileResult<Bytes> {
let path = id.vpath().as_rooted_path();
let path_str = path.to_string_lossy();
// Baştaki "/" veya "./" kaldır
let clean_path = path_str.trim_start_matches('/').trim_start_matches("./");
if let Some(bytes) = self.files.get(clean_path) {
Ok(bytes.clone())
} else {
Err(FileError::NotFound(path.into()))
}
}
fn font(&self, index: usize) -> Option<Font> {
self.fonts.fonts.get(index)?.get()
}
fn today(&self, offset: Option<i64>) -> Option<Datetime> {
let now = chrono::Utc::now();
let offset_secs = offset.unwrap_or(0) * 3600;
let tz = chrono::FixedOffset::east_opt(offset_secs as i32)?;
let local = now.with_timezone(&tz);
use chrono::Datelike;
Datetime::from_ymd(
local.year(),
local.month().try_into().ok()?,
local.day().try_into().ok()?,
)
}
}
/// Typst markup → PDF bytes
pub fn compile_pdf(typst_markup: String, fonts: &Fonts, files: HashMap<String, Vec<u8>>) -> Result<Vec<u8>, String> {
let world = DreportWorld::new(typst_markup, fonts, files);
// Derleme
let warned = typst::compile::<PagedDocument>(&world);
let document = warned.output.map_err(|errs| {
errs.into_iter()
.map(|e| e.message.to_string())
.collect::<Vec<_>>()
.join("; ")
})?;
// PDF export
let options = PdfOptions {
ident: Smart::Auto,
timestamp: None,
page_ranges: None,
standards: Default::default(),
tagged: false,
};
let pdf_bytes = pdf(&document, &options).map_err(|errs| {
errs.into_iter()
.map(|e| e.message.to_string())
.collect::<Vec<_>>()
.join("; ")
})?;
Ok(pdf_bytes)
}

View File

@@ -1,17 +0,0 @@
use std::path::PathBuf;
use typst_kit::fonts::{FontSearcher, Fonts};
/// Proje fontlarını yükler (backend/fonts/ dizininden).
/// Uygulama başlangıcında bir kez çağrılır ve paylaşılır.
pub fn load_fonts() -> Fonts {
let font_dir = font_dir();
FontSearcher::new()
.include_system_fonts(false)
.search_with(&[font_dir])
}
fn font_dir() -> PathBuf {
// Cargo manifest dizinine göre fonts/ klasörü
let manifest_dir = env!("CARGO_MANIFEST_DIR");
PathBuf::from(manifest_dir).join("fonts")
}

View File

@@ -1,4 +0,0 @@
pub mod compiler;
pub mod fonts;
pub use dreport_core::template_to_typst;

View File

@@ -14,7 +14,7 @@ pub enum SizeValue {
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[serde(rename_all = "camelCase", default)]
pub struct SizeConstraint {
pub width: SizeValue,
pub height: SizeValue,
@@ -24,25 +24,43 @@ pub struct SizeConstraint {
pub max_height: Option<f64>,
}
impl Default for SizeConstraint {
fn default() -> Self {
Self {
width: SizeValue::Auto,
height: SizeValue::Auto,
min_width: None,
min_height: None,
max_width: None,
max_height: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PageSettings {
pub width: f64,
pub height: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Padding {
#[serde(default)]
pub top: f64,
#[serde(default)]
pub right: f64,
#[serde(default)]
pub bottom: f64,
#[serde(default)]
pub left: f64,
}
// --- Positioning ---
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum PositionMode {
#[default]
#[serde(rename = "flow")]
Flow,
#[serde(rename = "absolute")]
@@ -124,6 +142,7 @@ pub struct TableStyle {
#[serde(rename_all = "camelCase", default)]
pub struct BarcodeStyle {
pub color: Option<String>,
pub include_text: Option<bool>,
}
// --- Element tipleri ---
@@ -200,17 +219,30 @@ impl TemplateElement {
#[serde(rename_all = "camelCase")]
pub struct ContainerElement {
pub id: String,
#[serde(default)]
pub position: PositionMode,
#[serde(default)]
pub size: SizeConstraint,
#[serde(default = "default_column")]
pub direction: String,
#[serde(default)]
pub gap: f64,
#[serde(default)]
pub padding: Padding,
#[serde(default = "default_stretch")]
pub align: String,
#[serde(default = "default_start")]
pub justify: String,
#[serde(default)]
pub style: ContainerStyle,
#[serde(default)]
pub children: Vec<TemplateElement>,
}
fn default_column() -> String { "column".to_string() }
fn default_stretch() -> String { "stretch".to_string() }
fn default_start() -> String { "start".to_string() }
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StaticTextElement {

View File

@@ -1,10 +1,10 @@
<script setup lang="ts">
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
import { computed, ref, watch, provide, onMounted, onBeforeUnmount } from 'vue'
import { storeToRefs } from 'pinia'
import { useTemplateStore } from '../../stores/template'
import { useEditorStore } from '../../stores/editor'
import { useTypstCompiler } from '../../composables/useTypstCompiler'
import TypstSvgLayer from './TypstSvgLayer.vue'
import { useLayoutEngine } from '../../composables/useLayoutEngine'
import LayoutRenderer from './LayoutRenderer.vue'
import InteractionOverlay from './InteractionOverlay.vue'
const props = withDefaults(defineProps<{
@@ -15,7 +15,7 @@ const props = withDefaults(defineProps<{
const templateStore = useTemplateStore()
const editorStore = useEditorStore()
const { template, mockData } = storeToRefs(templateStore)
const { template, mockData, layoutVersion } = storeToRefs(templateStore)
const containerRef = ref<HTMLElement | null>(null)
const containerWidth = ref(800)
@@ -24,8 +24,11 @@ const emit = defineEmits<{
'compile-error': [error: string | null]
}>()
// Typst compiler — template + data'yı worker'a gönderir, WASM ile derlenir
const { svg, error, compiling, layout, dispose } = useTypstCompiler(template, mockData)
// Layout engine — template + data'yı worker'a gönderir, WASM ile layout hesaplar
const { layout, layoutMap, error, computing: compiling, generateBarcode, dispose } = useLayoutEngine(template, mockData, layoutVersion)
// LayoutRenderer'ın barcode üretmek için kullanabileceği fonksiyon
provide('generateBarcode', generateBarcode)
watch(error, (val) => emit('compile-error', val))
@@ -89,15 +92,76 @@ onBeforeUnmount(() => {
window.removeEventListener('keyup', onKeyUp)
})
// Zoom
// Zoom & Pan via wheel/trackpad
const pageRef = ref<HTMLElement | null>(null)
let zoomRAF: number | null = null
let zoomDeltaAccum = 0
let zoomClientX = 0
let zoomClientY = 0
function onWheel(e: WheelEvent) {
if (e.ctrlKey || e.metaKey) {
e.preventDefault()
const delta = e.deltaY > 0 ? -0.1 : 0.1
editorStore.setZoom(editorStore.zoom + delta)
zoomDeltaAccum += e.deltaY
zoomClientX = e.clientX
zoomClientY = e.clientY
if (zoomRAF === null) {
zoomRAF = requestAnimationFrame(() => {
const delta = Math.max(-4, Math.min(4, zoomDeltaAccum))
if (Math.abs(delta) > 0.01) {
applyZoom(delta, zoomClientX, zoomClientY)
}
zoomDeltaAccum = 0
zoomRAF = null
})
}
} else {
// İki parmak pan (touchpad) veya normal scroll
e.preventDefault()
editorStore.setPan(
editorStore.panX - e.deltaX,
editorStore.panY - e.deltaY,
)
}
}
function applyZoom(delta: number, clientX: number, clientY: number) {
const pageEl = pageRef.value
if (!pageEl) return
const oldZoom = editorStore.zoom
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)
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
// Flex centering kayması: sayfa genişliği değişince ortalama kayar
// X ekseni: justify-content: center → kayma = (eskiBoyut - yeniBoyut) / 2
const pageW = templateStore.template.page.width
const centerShiftX = pageW * (oldScale - newScale) / 2
// Y ekseni: align-items: flex-start → kayma yok
const centerShiftY = 0
// 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)
editorStore.setZoom(newZoom)
editorStore.setPan(newPanX, newPanY)
}
function onKeyDown(e: KeyboardEvent) {
if (e.code === 'Space' && !e.repeat && !(e.target instanceof HTMLInputElement || e.target instanceof HTMLSelectElement || e.target instanceof HTMLTextAreaElement)) {
e.preventDefault()
@@ -146,9 +210,9 @@ function onPointerUp(e: PointerEvent) {
@pointerup="onPointerUp"
>
<!-- Sayfa -->
<div class="editor-canvas__page" :style="[pageStyle, panTransform ? { transform: panTransform } : {}]">
<TypstSvgLayer :svg="svg" />
<InteractionOverlay :scale="scale" :layout="layout" :page-width-pt="templateStore.template.page.width * 2.8346" />
<div ref="pageRef" class="editor-canvas__page" :style="[pageStyle, panTransform ? { transform: panTransform } : {}]">
<LayoutRenderer :layout="layout" :scale="scale" />
<InteractionOverlay :scale="scale" :layout-map="layoutMap" />
</div>
</div>
@@ -170,12 +234,14 @@ function onPointerUp(e: PointerEvent) {
flex: 1;
position: relative;
min-height: 0;
min-width: 0;
overflow: hidden;
}
.editor-canvas {
width: 100%;
height: 100%;
overflow: auto;
overflow: hidden;
background: #e5e7eb;
display: flex;
align-items: flex-start;

View File

@@ -2,25 +2,18 @@
import { computed, ref } from 'vue'
import { useTemplateStore } from '../../stores/template'
import { useEditorStore } from '../../stores/editor'
import type { ElementLayout } from '../../core/template-to-typst'
import type { ElementLayout } from '../../core/layout-types'
import type { TemplateElement, SizeValue, ContainerElement } from '../../core/types'
import { isContainer, sz } from '../../core/types'
const props = defineProps<{
scale: number
layout: Record<string, ElementLayout>
pageWidthPt: number
layoutMap: Record<string, ElementLayout>
}>()
const templateStore = useTemplateStore()
const editorStore = useEditorStore()
// pt→px dönüşüm katsayısı
const ptToPx = computed(() => {
const pageWidthPx = templateStore.template.page.width * props.scale
return props.pageWidthPt > 0 ? pageWidthPx / props.pageWidthPt : 1
})
// Tüm elemanları flat olarak topla (root hariç)
const flatElements = computed(() => {
const result: TemplateElement[] = []
@@ -50,20 +43,20 @@ const allContainers = computed(() => {
})
function getElementStyle(el: TemplateElement) {
const l = props.layout[el.id]
const l = props.layoutMap[el.id]
if (!l) return { display: 'none' }
const s = ptToPx.value
const h = l.height * s
const s = props.scale
const h = l.height_mm * s
const minH = 8
const actualH = Math.max(h, minH)
const yOffset = h < minH ? (minH - h) / 2 : 0
return {
position: 'absolute' as const,
left: `${l.x * s}px`,
top: `${l.y * s - yOffset}px`,
width: `${l.width * s}px`,
left: `${l.x_mm * s}px`,
top: `${l.y_mm * s - yOffset}px`,
width: `${l.width_mm * s}px`,
height: `${actualH}px`,
}
}
@@ -90,23 +83,23 @@ const dropLogicalIndex = ref<number | null>(null)
/** Mouse pozisyonuna göre en derin container'ı bul */
function findDeepestContainer(mouseX: number, mouseY: number, excludeId?: string): ContainerElement {
const s = ptToPx.value
const s = props.scale
let best: ContainerElement = templateStore.template.root
for (const c of allContainers.value) {
if (c.id === excludeId) continue
const l = props.layout[c.id]
const l = props.layoutMap[c.id]
if (!l) continue
const cx = l.x * s
const cy = l.y * s
const cw = l.width * s
const ch = l.height * s
const cx = l.x_mm * s
const cy = l.y_mm * s
const cw = l.width_mm * s
const ch = l.height_mm * s
if (mouseX >= cx && mouseX <= cx + cw && mouseY >= cy && mouseY <= cy + ch) {
// Daha küçük (daha derin) container'ı tercih et
const bestL = props.layout[best.id]
if (!bestL || (cw * ch < bestL.width * s * bestL.height * s)) {
const bestL = props.layoutMap[best.id]
if (!bestL || (cw * ch < bestL.width_mm * s * bestL.height_mm * s)) {
best = c
}
}
@@ -116,20 +109,20 @@ function findDeepestContainer(mouseX: number, mouseY: number, excludeId?: string
/** Container içinde drop index hesapla */
function computeDropIndex(container: ContainerElement, mouseX: number, mouseY: number, excludeId?: string) {
const s = ptToPx.value
const s = props.scale
const flowChildren = container.children.filter(c => c.position.type !== 'absolute' && c.id !== excludeId)
const isRow = container.direction === 'row'
let visualIdx = flowChildren.length
for (let i = 0; i < flowChildren.length; i++) {
const l = props.layout[flowChildren[i].id]
const l = props.layoutMap[flowChildren[i].id]
if (!l) continue
if (isRow) {
const centerX = l.x * s + (l.width * s) / 2
const centerX = l.x_mm * s + (l.width_mm * s) / 2
if (mouseX < centerX) { visualIdx = i; break }
} else {
const centerY = l.y * s + (l.height * s) / 2
const centerY = l.y_mm * s + (l.height_mm * s) / 2
if (mouseY < centerY) { visualIdx = i; break }
}
}
@@ -184,7 +177,7 @@ const dropIndicatorStyle = computed(() => {
const container = templateStore.getElementById(dropTargetContainerId.value)
if (!container || !isContainer(container)) return { display: 'none' }
const s = ptToPx.value
const s = props.scale
const idx = dropVisualIndex.value
const isRow = container.direction === 'row'
@@ -192,37 +185,37 @@ const dropIndicatorStyle = computed(() => {
const dragId = dragElementId.value
const flowChildren = container.children.filter(c => c.position.type !== 'absolute' && c.id !== dragId)
const cl = props.layout[container.id]
const cl = props.layoutMap[container.id]
if (!cl) return { display: 'none' }
if (isRow) {
// Row container: dikey gösterge çizgisi
let x = 0
if (idx === 0 && flowChildren.length > 0) {
const l = props.layout[flowChildren[0].id]
if (l) x = (cl.x * s + l.x * s) / 2
else x = cl.x * s
const l = props.layoutMap[flowChildren[0].id]
if (l) x = (cl.x_mm * s + l.x_mm * s) / 2
else x = cl.x_mm * s
} else if (idx < flowChildren.length && idx > 0) {
const left = props.layout[flowChildren[idx - 1].id]
const right = props.layout[flowChildren[idx].id]
const left = props.layoutMap[flowChildren[idx - 1].id]
const right = props.layoutMap[flowChildren[idx].id]
if (left && right) {
const leftEnd = (left.x + left.width) * s
const rightStart = right.x * s
const leftEnd = (left.x_mm + left.width_mm) * s
const rightStart = right.x_mm * s
x = (leftEnd + rightStart) / 2
}
} else if (idx === 0 && flowChildren.length === 0) {
x = cl.x * s + 8
x = cl.x_mm * s + 8
} else if (flowChildren.length > 0) {
const last = flowChildren[flowChildren.length - 1]
const l = props.layout[last.id]
const l = props.layoutMap[last.id]
if (l) {
const gapPx = container.gap * props.scale
x = (l.x + l.width) * s + gapPx / 2
x = (l.x_mm + l.width_mm) * s + gapPx / 2
}
}
const top = cl.y * s
const height = cl.height * s
const top = cl.y_mm * s
const height = cl.height_mm * s
return {
position: 'absolute' as const,
@@ -240,33 +233,33 @@ const dropIndicatorStyle = computed(() => {
// Column container: yatay gösterge çizgisi
let y = 0
if (idx === 0 && flowChildren.length > 0) {
const l = props.layout[flowChildren[0].id]
const l = props.layoutMap[flowChildren[0].id]
if (l) {
y = (cl.y * s + l.y * s) / 2
y = (cl.y_mm * s + l.y_mm * s) / 2
} else {
y = cl.y * s - 4
y = cl.y_mm * s - 4
}
} else if (idx < flowChildren.length && idx > 0) {
const above = props.layout[flowChildren[idx - 1].id]
const below = props.layout[flowChildren[idx].id]
const above = props.layoutMap[flowChildren[idx - 1].id]
const below = props.layoutMap[flowChildren[idx].id]
if (above && below) {
const aboveBottom = (above.y + above.height) * s
const belowTop = below.y * s
const aboveBottom = (above.y_mm + above.height_mm) * s
const belowTop = below.y_mm * s
y = (aboveBottom + belowTop) / 2
}
} else if (idx === 0 && flowChildren.length === 0) {
y = cl.y * s + 8
y = cl.y_mm * s + 8
} else if (flowChildren.length > 0) {
const last = flowChildren[flowChildren.length - 1]
const l = props.layout[last.id]
const l = props.layoutMap[last.id]
if (l) {
const gapPx = container.gap * props.scale
y = (l.y + l.height) * s + gapPx / 2
y = (l.y_mm + l.height_mm) * s + gapPx / 2
}
}
const x = cl.x * s
const width = cl.width * s
const x = cl.x_mm * s
const width = cl.width_mm * s
return {
position: 'absolute' as const,
@@ -297,20 +290,20 @@ function onDragStart(e: PointerEvent, el: TemplateElement) {
return
}
const l = props.layout[el.id]
const l = props.layoutMap[el.id]
if (!l) return
const s = ptToPx.value
const s = props.scale
dragElementId.value = el.id
didDrag.value = false
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
dragOffset.value = { x: e.clientX - rect.left, y: e.clientY - rect.top }
dragGhost.value = {
x: l.x * s,
y: l.y * s,
width: l.width * s,
height: l.height * s,
x: l.x_mm * s,
y: l.y_mm * s,
width: l.width_mm * s,
height: l.height_mm * s,
}
window.addEventListener('pointermove', onDragMove)
@@ -440,27 +433,26 @@ function onResizeStart(e: PointerEvent, elId: string, handle: string) {
e.stopPropagation()
e.preventDefault()
const l = props.layout[elId]
const l = props.layoutMap[elId]
if (!l) return
resizeElementId.value = elId
resizeHandle.value = handle
isResizing.value = true
const s = ptToPx.value
const ptToMm = 1 / 2.8346
const s = props.scale
// Barkod elemanları için aspect ratio'yu kaydet
// Barkod ve görsel elemanları için aspect ratio'yu kaydet
const el = flatElements.value.find(e => e.id === elId)
resizeAspectRatio.value = (el?.type === 'barcode' && l.height > 0) ? l.width / l.height : 0
resizeAspectRatio.value = ((el?.type === 'barcode' || el?.type === 'image') && l.height_mm > 0) ? l.width_mm / l.height_mm : 0
resizeStart.value = {
mouseX: e.clientX, mouseY: e.clientY,
x: l.x * s, y: l.y * s,
width: l.width * s, height: l.height * s,
x: l.x_mm * s, y: l.y_mm * s,
width: l.width_mm * s, height: l.height_mm * s,
}
resizeGhost.value = { x: l.x * s, y: l.y * s, width: l.width * s, height: l.height * s }
resizeFinalMm.value = { width: l.width * ptToMm, height: l.height * ptToMm }
resizeGhost.value = { x: l.x_mm * s, y: l.y_mm * s, width: l.width_mm * s, height: l.height_mm * s }
resizeFinalMm.value = { width: l.width_mm, height: l.height_mm }
window.addEventListener('pointermove', onResizeMove)
window.addEventListener('pointerup', onResizeEnd)
@@ -511,9 +503,15 @@ function onResizeEnd() {
if (resizeElementId.value) {
const handle = resizeHandle.value
const ar = resizeAspectRatio.value
const sizeUpdate: { width?: SizeValue; height?: SizeValue } = {}
if (handle.includes('e') || handle.includes('w')) sizeUpdate.width = sz.fixed(resizeFinalMm.value.width)
if (handle.includes('s') || handle.includes('n')) sizeUpdate.height = sz.fixed(resizeFinalMm.value.height)
// Aspect ratio aktifken her zaman hem width hem height güncelle
if (ar > 0) {
sizeUpdate.width = sz.fixed(resizeFinalMm.value.width)
sizeUpdate.height = sz.fixed(resizeFinalMm.value.height)
}
templateStore.updateElementSize(resizeElementId.value, sizeUpdate)
}
@@ -589,8 +587,8 @@ const isAnyDragActive = computed(() =>
<!-- Resize handles -->
<template v-if="editorStore.selectedElementId === el.id && !isResizing">
<template v-if="el.type === 'barcode'">
<!-- Barkod: sadece yatay resize (aspect ratio korunur) -->
<template v-if="el.type === 'barcode' || el.type === 'image'">
<!-- Barkod/Görsel: sadece yatay resize (aspect ratio korunur) -->
<div class="resize-handle resize-handle--e" @pointerdown="(e: PointerEvent) => onResizeStart(e, el.id, 'e')" />
<div class="resize-handle resize-handle--w" @pointerdown="(e: PointerEvent) => onResizeStart(e, el.id, 'w')" />
</template>

View File

@@ -0,0 +1,262 @@
<script setup lang="ts">
import { computed, inject, watch, nextTick } from 'vue'
import type { ElementLayout, LayoutResult } from '../../core/layout-types'
const props = defineProps<{
layout: LayoutResult | null
scale: number
}>()
// WASM barcode üretme fonksiyonu (EditorCanvas'tan provide edilir)
const generateBarcode = inject<(format: string, value: string, width: number, height: number) => Promise<{ width: number; height: number; rgba: ArrayBuffer } | null>>('generateBarcode')
const pageElements = computed(() => {
if (!props.layout || props.layout.pages.length === 0) return []
return props.layout.pages[0].elements
})
function elStyle(el: ElementLayout): Record<string, string> {
const s = props.scale
return {
position: 'absolute',
left: `${el.x_mm * s}px`,
top: `${el.y_mm * s}px`,
width: `${el.width_mm * s}px`,
height: `${el.height_mm * s}px`,
}
}
function textStyle(el: ElementLayout): Record<string, string> {
const s = props.scale
const st = el.style
const result: Record<string, string> = {}
// fontSize pt cinsinden → mm'ye çevir (1pt = 0.3528mm), sonra scale ile px'e
if (st.fontSize) result.fontSize = `${st.fontSize * 0.3528 * s}px`
if (st.fontWeight) result.fontWeight = st.fontWeight
if (st.fontFamily) result.fontFamily = st.fontFamily
if (st.color) result.color = st.color
if (st.textAlign) result.textAlign = st.textAlign
result.lineHeight = '1.2'
result.overflow = 'hidden'
result.wordBreak = 'break-word'
return result
}
function containerStyle(el: ElementLayout): Record<string, string> {
const st = el.style
const result: Record<string, string> = {}
if (st.backgroundColor) result.backgroundColor = st.backgroundColor
if (st.borderColor && st.borderWidth) {
result.border = `${st.borderWidth * props.scale}px ${st.borderStyle ?? 'solid'} ${st.borderColor}`
}
if (st.borderRadius) result.borderRadius = `${st.borderRadius * props.scale}px`
return result
}
function lineStyle(el: ElementLayout): Record<string, string> {
const st = el.style
return {
borderTop: `${(st.strokeWidth ?? 0.5) * props.scale}px solid ${st.strokeColor ?? '#000'}`,
width: '100%',
height: '0',
}
}
// --- Barcode rendering (WASM ile) ---
async function renderBarcodeToCanvas(canvas: HTMLCanvasElement, format: string, value: string, includeText: boolean = false) {
if (!value || !generateBarcode) return
try {
// WASM'dan yüksek çözünürlüklü pixel verisi al
// QR her zaman kare
const isQr = format === 'qr'
const size = isQr ? 300 : 400
const height = isQr ? 300 : 150
const result = await generateBarcode(format, value, size, height, isQr ? false : includeText)
if (!result) return
// Canvas boyutlarını WASM çıktısına ayarla (crisp rendering)
canvas.width = result.width
canvas.height = result.height
const ctx = canvas.getContext('2d')
if (!ctx) return
const imageData = new ImageData(
new Uint8ClampedArray(result.rgba),
result.width,
result.height,
)
ctx.putImageData(imageData, 0, 0)
} catch (e) {
console.warn(`[dreport] WASM barcode render hatası (${format}):`, e)
renderBarcodeFallback(canvas, format)
}
}
function renderBarcodeFallback(canvas: HTMLCanvasElement, format: string) {
canvas.width = 200
canvas.height = 80
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.fillStyle = '#f3f4f6'
ctx.fillRect(0, 0, 200, 80)
ctx.fillStyle = '#ef4444'
ctx.font = '11px sans-serif'
ctx.textAlign = 'center'
ctx.fillText(`[${format}] hata`, 100, 44)
}
/** Canvas mount olduğunda render et */
function onBarcodeCanvasMounted(el: HTMLCanvasElement | null) {
if (!el) return
const format = el.dataset.format
const value = el.dataset.value
const includeText = el.dataset.includeText === 'true'
if (format && value) {
renderBarcodeToCanvas(el, format, value, includeText)
}
}
// Layout değiştiğinde tüm barcode canvas'ları yeniden render et
watch(
() => props.layout,
async () => {
await nextTick()
await nextTick()
const canvases = document.querySelectorAll<HTMLCanvasElement>('canvas[data-barcode]')
canvases.forEach(canvas => {
const format = canvas.dataset.format
const value = canvas.dataset.value
const includeText = canvas.dataset.includeText === 'true'
if (format && value) {
renderBarcodeToCanvas(canvas, format, value, includeText)
}
})
},
{ deep: true }
)
</script>
<template>
<div class="layout-renderer" v-if="layout">
<template v-for="el in pageElements" :key="el.id">
<!-- Container -->
<div
v-if="el.element_type === 'container'"
class="layout-el layout-el--container"
:style="{ ...elStyle(el), ...containerStyle(el) }"
/>
<!-- Static text / Text / Page number -->
<div
v-else-if="el.element_type === 'static_text' || el.element_type === 'text' || el.element_type === 'page_number'"
class="layout-el layout-el--text"
:style="{ ...elStyle(el), ...textStyle(el) }"
>
{{ el.content?.type === 'text' ? el.content.value : '' }}
</div>
<!-- Line -->
<div
v-else-if="el.element_type === 'line'"
class="layout-el layout-el--line"
:style="elStyle(el)"
>
<div :style="lineStyle(el)" />
</div>
<!-- Image -->
<div
v-else-if="el.element_type === 'image'"
class="layout-el layout-el--image"
:style="elStyle(el)"
>
<img
v-if="el.content?.type === 'image' && el.content.src"
:src="el.content.src"
:style="{
width: '100%',
height: '100%',
objectFit: 'fill',
}"
/>
<div v-else class="layout-el__placeholder">Görsel</div>
</div>
<!-- Barcode -->
<div
v-else-if="el.element_type === 'barcode'"
class="layout-el layout-el--barcode"
:style="elStyle(el)"
>
<canvas
v-if="el.content?.type === 'barcode' && el.content.value"
:ref="(ref) => onBarcodeCanvasMounted(ref as HTMLCanvasElement)"
data-barcode
:data-format="el.content.format"
:data-value="el.content.value"
:data-include-text="el.style.barcodeIncludeText ?? (el.content.format === 'ean13' || el.content.format === 'ean8')"
:style="{ width: '100%', height: '100%', display: 'block' }"
/>
<div v-else class="layout-el__placeholder">
{{ el.content?.type === 'barcode' ? `[${el.content.format}]` : '[barcode]' }}
</div>
</div>
</template>
</div>
<div class="layout-renderer layout-renderer--empty" v-else>
<span>Hesaplanıyor...</span>
</div>
</template>
<style scoped>
.layout-renderer {
position: absolute;
inset: 0;
pointer-events: none;
user-select: none;
}
.layout-renderer--empty {
display: flex;
align-items: center;
justify-content: center;
color: #999;
font-size: 14px;
}
.layout-el {
box-sizing: border-box;
}
.layout-el--text {
white-space: pre-wrap;
font-family: 'Noto Sans', sans-serif;
}
.layout-el--line {
display: flex;
align-items: center;
}
.layout-el__placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: #f3f4f6;
color: #9ca3af;
font-size: 11px;
border: 1px dashed #d1d5db;
border-radius: 2px;
}
</style>

View File

@@ -397,8 +397,8 @@ function deleteElement() {
<div v-if="selectedElement.type === 'line'" class="prop-section">
<div class="prop-section__title">Çizgi Stili</div>
<div class="prop-row">
<label class="prop-label">Kalınlık (pt)</label>
<input class="prop-input" type="number" step="0.25" min="0.25"
<label class="prop-label">Kalınlık (mm)</label>
<input class="prop-input" type="number" step="0.1" min="0.1"
:value="(selectedElement as LineElement).style.strokeWidth ?? 0.5"
@input="(e) => updateStyle('strokeWidth', parseFloat((e.target as HTMLInputElement).value) || 0.5)" />
</div>
@@ -509,6 +509,12 @@ function deleteElement() {
<button v-if="(selectedElement as BarcodeElement).style.color" class="prop-clear" @click="updateStyle('color', undefined)">x</button>
</div>
</div>
<div v-if="(selectedElement as BarcodeElement).format !== 'qr'" class="prop-row">
<label class="prop-label">Metin Goster</label>
<input type="checkbox"
:checked="(selectedElement as BarcodeElement).style.includeText ?? ((selectedElement as BarcodeElement).format === 'ean13' || (selectedElement as BarcodeElement).format === 'ean8')"
@change="(e) => updateStyle('includeText', (e.target as HTMLInputElement).checked)" />
</div>
<div v-if="schemaStore.scalarFields.length > 0" class="prop-row">
<label class="prop-label">Veri Baglama</label>
<select class="prop-input prop-select"
@@ -613,8 +619,8 @@ function deleteElement() {
</div>
</div>
<div class="prop-row">
<label class="prop-label">Kenarlık (pt)</label>
<input class="prop-input" type="number" step="0.5" min="0"
<label class="prop-label">Kenarlık (mm)</label>
<input class="prop-input" type="number" step="0.1" min="0"
:value="(selectedElement as ContainerElement).style.borderWidth ?? 0"
@input="(e) => updateStyle('borderWidth', parseFloat((e.target as HTMLInputElement).value) || 0)" />
</div>
@@ -638,8 +644,8 @@ function deleteElement() {
</select>
</div>
<div class="prop-row">
<label class="prop-label">Radius (pt)</label>
<input class="prop-input" type="number" step="1" min="0"
<label class="prop-label">Radius (mm)</label>
<input class="prop-input" type="number" step="0.5" min="0"
:value="(selectedElement as ContainerElement).style.borderRadius ?? 0"
@input="(e) => updateStyle('borderRadius', parseFloat((e.target as HTMLInputElement).value) || 0)" />
</div>
@@ -791,8 +797,8 @@ function deleteElement() {
</div>
</div>
<div class="prop-row">
<label class="prop-label">Kenarlık (pt)</label>
<input class="prop-input" type="number" step="0.25" min="0"
<label class="prop-label">Kenarlık (mm)</label>
<input class="prop-input" type="number" step="0.1" min="0"
:value="(selectedElement as RepeatingTableElement).style.borderWidth ?? 0.5"
@input="(e) => updateTableStyle('borderWidth', parseFloat((e.target as HTMLInputElement).value) || 0)" />
</div>

View File

@@ -0,0 +1,137 @@
import { ref, watch, type Ref } from 'vue'
import type { Template } from '../core/types'
import type { LayoutResult, ElementLayout } from '../core/layout-types'
export type { ElementLayout }
export function useLayoutEngine(
template: Ref<Template>,
data: Ref<Record<string, unknown>>,
layoutVersion?: Ref<number>,
) {
const layout = ref<LayoutResult | null>(null)
const error = ref<string | null>(null)
const computing = ref(false)
// Uyumluluk: InteractionOverlay'ın beklediği flat layout map (id → ElementLayout)
const layoutMap = ref<Record<string, ElementLayout>>({})
let worker: Worker | null = null
let requestId = 0
function initWorker() {
worker = new Worker(new URL('../workers/layout.worker.ts', import.meta.url), {
type: 'module',
})
worker.onmessage = (e: MessageEvent<any>) => {
const msg = e.data
// Barcode yanıtları
if (msg.type === 'barcode-result' || msg.type === 'barcode-error') {
handleBarcodeResponse(msg)
return
}
if (msg.id !== requestId) return
computing.value = false
if (msg.type === 'result' && msg.layout) {
layout.value = msg.layout
error.value = null
// Flat map oluştur: id → ElementLayout
const map: Record<string, ElementLayout> = {}
for (const page of msg.layout.pages) {
for (const el of page.elements) {
map[el.id] = el
}
}
layoutMap.value = map
} else if (msg.type === 'error') {
error.value = msg.error ?? 'Bilinmeyen layout hatası'
}
}
worker.onerror = () => {
computing.value = false
error.value = 'Worker hatası — yeniden başlatılıyor'
worker?.terminate()
worker = null
setTimeout(initWorker, 500)
}
}
function compute() {
if (!worker) initWorker()
requestId++
computing.value = true
worker!.postMessage({
type: 'compile',
templateJson: JSON.stringify(template.value),
dataJson: JSON.stringify(data.value),
id: requestId,
})
}
// template veya data değiştiğinde yeniden hesapla.
// layoutVersion verilmişse sadece onu izle (cheap integer comparison).
// Verilmemişse eski davranış: deep watch (geriye uyumluluk).
if (layoutVersion) {
watch(
layoutVersion,
() => {
compute()
},
{ immediate: true },
)
} else {
watch(
[template, data],
() => {
compute()
},
{ immediate: true, deep: true },
)
}
// --- Barcode üretimi (WASM üzerinden) ---
let barcodeReqId = 0
const barcodeCallbacks = new Map<number, (result: { width: number; height: number; rgba: ArrayBuffer } | null) => void>()
function generateBarcode(format: string, value: string, width: number, height: number, includeText: boolean = false): Promise<{ width: number; height: number; rgba: ArrayBuffer } | null> {
if (!worker) initWorker()
return new Promise(resolve => {
barcodeReqId++
const id = barcodeReqId + 100000 // compile id'leriyle çakışmasın
barcodeCallbacks.set(id, resolve)
worker!.postMessage({ type: 'barcode', format, value, width, height, includeText, id })
})
}
function handleBarcodeResponse(msg: any) {
if (msg.type === 'barcode-result' || msg.type === 'barcode-error') {
const cb = barcodeCallbacks.get(msg.id)
if (cb) {
barcodeCallbacks.delete(msg.id)
cb(msg.type === 'barcode-result' ? { width: msg.width, height: msg.height, rgba: msg.rgba } : null)
}
}
}
function dispose() {
worker?.terminate()
worker = null
barcodeCallbacks.clear()
}
return {
layout,
layoutMap,
error,
computing,
compute,
generateBarcode,
dispose,
}
}

View File

@@ -0,0 +1,65 @@
// Layout engine çıktı tipleri — Rust LayoutResult ile birebir eşleşir
export interface LayoutResult {
pages: PageLayout[]
}
export interface PageLayout {
page_index: number
width_mm: number
height_mm: number
elements: ElementLayout[]
}
export interface ElementLayout {
id: string
x_mm: number
y_mm: number
width_mm: number
height_mm: number
element_type: string
content: ResolvedContent | null
style: ResolvedStyle
children: string[]
}
export type ResolvedContent =
| { type: 'text'; value: string }
| { type: 'image'; src: string }
| { type: 'line' }
| { type: 'barcode'; format: string; value: string }
| { type: 'page_number'; current: number; total: number }
| { type: 'table'; headers: TableHeaderCell[]; rows: TableCell[][]; column_widths_mm: number[] }
export interface TableHeaderCell {
text: string
align: string
}
export interface TableCell {
text: string
align: string
}
export interface ResolvedStyle {
fontSize?: number
fontWeight?: string
fontFamily?: string
color?: string
textAlign?: string
strokeColor?: string
strokeWidth?: number
backgroundColor?: string
borderColor?: string
borderWidth?: number
borderRadius?: number
borderStyle?: string
headerBg?: string
headerColor?: string
zebraOdd?: string
zebraEven?: string
headerFontSize?: number
objectFit?: string
barcodeColor?: string
barcodeIncludeText?: boolean
}

View File

@@ -109,6 +109,7 @@ export type BarcodeFormat = 'qr' | 'ean13' | 'ean8' | 'code128' | 'code39'
export interface BarcodeStyle {
color?: string // ön plan rengi (varsayılan: siyah)
includeText?: boolean // barkod altına değer yazılsın mı (QR hariç)
}
// --- Element tipleri ---

View File

@@ -110,8 +110,22 @@ function onKeyDown(e: KeyboardEvent) {
}
}
onMounted(() => window.addEventListener('keydown', onKeyDown))
onBeforeUnmount(() => window.removeEventListener('keydown', onKeyDown))
// Browser'ın native pinch-zoom'unu editör alanında engelle
function onGlobalWheel(e: WheelEvent) {
if (e.ctrlKey || e.metaKey) {
e.preventDefault()
}
}
onMounted(() => {
window.addEventListener('keydown', onKeyDown)
// passive: false olmadan preventDefault çalışmaz
document.addEventListener('wheel', onGlobalWheel, { passive: false })
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', onKeyDown)
document.removeEventListener('wheel', onGlobalWheel)
})
// --- Exposed API ---
@@ -179,6 +193,7 @@ defineExpose({
flex: 1;
min-height: 0;
height: 100%;
overflow: hidden;
}
.dreport-editor__sidebar {

View File

@@ -52,12 +52,36 @@ export const useTemplateStore = defineStore('template', () => {
const mockData = computed(() => overrideData.value ?? generateMockData(template.value))
/**
* Layout version counter — her template/data mutasyonunda artar.
* useLayoutEngine bu counter'ı izler (deep watch yerine).
* Vue'nun tüm template ağacını recursive karşılaştırması yerine
* tek bir sayı karşılaştırması yapılır.
*/
const layoutVersion = ref(0)
/** Layout yeniden hesaplamasını tetikle */
function bumpLayoutVersion() {
layoutVersion.value++
}
function setOverrideData(data: Record<string, unknown> | null) {
overrideData.value = data
bumpLayoutVersion()
}
// Undo / Redo
const { undo, redo, canUndo, canRedo } = useUndoRedo(template)
const { undo: _undo, redo: _redo, canUndo, canRedo } = useUndoRedo(template)
function undo() {
_undo()
bumpLayoutVersion()
}
function redo() {
_redo()
bumpLayoutVersion()
}
// --- Element CRUD ---
@@ -78,6 +102,7 @@ export const useTemplateStore = defineStore('template', () => {
} else {
parent.children.push(element)
}
bumpLayoutVersion()
}
/** Element'i ağaçtan kaldır */
@@ -85,13 +110,18 @@ export const useTemplateStore = defineStore('template', () => {
const parent = getParent(elementId)
if (!parent) return
const idx = parent.children.findIndex(c => c.id === elementId)
if (idx !== -1) parent.children.splice(idx, 1)
if (idx !== -1) {
parent.children.splice(idx, 1)
bumpLayoutVersion()
}
}
/** Element'i başka bir container'a taşı */
function moveElement(elementId: string, targetParentId: string, index?: number) {
const el = getElementById(elementId)
if (!el) return
// removeElement bump'lar, addChild de bump'lar — ama tek mantıksal operasyon.
// Fazladan 1 bump sorun değil (debounce var), ama istersek optimize edebiliriz.
removeElement(elementId)
addChild(targetParentId, el, index)
}
@@ -99,7 +129,10 @@ export const useTemplateStore = defineStore('template', () => {
/** Absolute pozisyon güncelle */
function updateElementPosition(elementId: string, position: PositionMode) {
const el = getElementById(elementId)
if (el) el.position = position
if (el) {
el.position = position
bumpLayoutVersion()
}
}
/** Boyut güncelle */
@@ -107,13 +140,17 @@ export const useTemplateStore = defineStore('template', () => {
const el = getElementById(elementId)
if (el) {
el.size = { ...el.size, ...size }
bumpLayoutVersion()
}
}
/** Herhangi bir element özelliğini güncelle */
function updateElement(elementId: string, updates: Partial<TemplateElement>) {
const el = getElementById(elementId)
if (el) Object.assign(el, updates)
if (el) {
Object.assign(el, updates)
bumpLayoutVersion()
}
}
/** Çocuk sırasını değiştir (aynı parent içinde) */
@@ -122,6 +159,7 @@ export const useTemplateStore = defineStore('template', () => {
if (!parent || !isContainer(parent)) return
const [moved] = parent.children.splice(fromIndex, 1)
parent.children.splice(toIndex, 0, moved)
bumpLayoutVersion()
}
/** Şablonu JSON olarak dışa aktar */
@@ -133,16 +171,20 @@ export const useTemplateStore = defineStore('template', () => {
function importTemplate(json: string) {
const parsed = JSON.parse(json) as Template
template.value = parsed
bumpLayoutVersion()
}
/** Yeni boş şablon oluştur */
function resetTemplate() {
template.value = createDefaultTemplate()
bumpLayoutVersion()
}
return {
template,
mockData,
layoutVersion,
bumpLayoutVersion,
getElementById,
getParent,
addChild,

View File

@@ -1,3 +1,43 @@
@font-face {
font-family: 'Noto Sans';
src: url('/fonts/NotoSans-Regular.ttf') format('truetype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Noto Sans';
src: url('/fonts/NotoSans-Bold.ttf') format('truetype');
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Noto Sans';
src: url('/fonts/NotoSans-Italic.ttf') format('truetype');
font-weight: 400;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'Noto Sans';
src: url('/fonts/NotoSans-BoldItalic.ttf') format('truetype');
font-weight: 700;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'Noto Sans Mono';
src: url('/fonts/NotoSansMono-Regular.ttf') format('truetype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
*,
*::before,
*::after {
@@ -12,6 +52,8 @@ html, body {
color: #1e293b;
background: #f1f5f9;
-webkit-font-smoothing: antialiased;
/* Browser native pinch-zoom'u engelle — editörün kendi zoom'u var */
touch-action: pan-x pan-y;
}
#app {

View File

@@ -0,0 +1,88 @@
/// Layout Engine Web Worker
/// Template JSON + Data JSON → Layout WASM → LayoutResult
import init, { loadFonts, computeLayout, generateBarcode } from '../core/wasm-layout/dreport_layout.js'
import type { LayoutResult } from '../core/layout-types'
let initPromise: Promise<void> | null = null
const FONT_FILES = [
{ path: '/fonts/NotoSans-Regular.ttf', family: 'Noto Sans' },
{ path: '/fonts/NotoSans-Bold.ttf', family: 'Noto Sans' },
{ path: '/fonts/NotoSans-Italic.ttf', family: 'Noto Sans' },
{ path: '/fonts/NotoSans-BoldItalic.ttf', family: 'Noto Sans' },
{ path: '/fonts/NotoSansMono-Regular.ttf', family: 'Noto Sans Mono' },
]
async function doInit() {
console.log('[layout-worker] WASM başlatılıyor...')
await init({ module_or_path: '/wasm/dreport_layout_bg.wasm' })
console.log('[layout-worker] Fontlar yükleniyor...')
const families: string[] = []
const buffers: Uint8Array[] = []
await Promise.all(
FONT_FILES.map(async (f) => {
const res = await fetch(new URL(f.path, self.location.origin).href)
const buf = await res.arrayBuffer()
families.push(f.family)
buffers.push(new Uint8Array(buf))
})
)
loadFonts(JSON.stringify(families), buffers)
console.log('[layout-worker] Hazır')
}
function ensureInit(): Promise<void> {
if (!initPromise) {
initPromise = doInit()
}
return initPromise
}
type WorkerMessage =
| { type: 'compile'; templateJson: string; dataJson: string; id: number }
| { type: 'barcode'; format: string; value: string; width: number; height: number; includeText: boolean; id: number }
self.onmessage = async (e: MessageEvent<WorkerMessage>) => {
const msg = e.data
if (msg.type === 'compile') {
try {
await ensureInit()
const t0 = performance.now()
const resultJson = computeLayout(msg.templateJson, msg.dataJson)
const layout: LayoutResult = JSON.parse(resultJson)
console.log(`[layout-worker] render ${(performance.now() - t0).toFixed(1)}ms`)
self.postMessage({ type: 'result', layout, id: msg.id })
} catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err)
console.error(`[layout-worker] Hata (id: ${msg.id}):`, err)
self.postMessage({ type: 'error', error: errorMsg, id: msg.id })
}
} else if (msg.type === 'barcode') {
try {
await ensureInit()
const raw = generateBarcode(msg.format, msg.value, msg.width, msg.height, msg.includeText)
// İlk 8 byte header: width (4 byte LE) + height (4 byte LE)
const dv = new DataView(raw.buffer, raw.byteOffset, 8)
const w = dv.getUint32(0, true)
const h = dv.getUint32(4, true)
const rgba = raw.slice(8)
self.postMessage(
{ type: 'barcode-result', width: w, height: h, rgba: rgba.buffer, id: msg.id },
[rgba.buffer] as any,
)
} catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err)
console.error(`[layout-worker] Barcode hatası (id: ${msg.id}):`, err)
self.postMessage({ type: 'barcode-error', error: errorMsg, id: msg.id })
}
}
}

View File

@@ -8,12 +8,25 @@ front:
back:
cargo run -p dreport-backend
# Frontend + Backend birlikte
# Frontend + Backend + WASM watch birlikte
dev:
just front & just back & wait
just front & just back & just wasm-watch & wait
# WASM build (core -> frontend)
# Layout engine WASM build
wasm:
wasm-pack build layout-engine --target web --release --out-dir ../frontend/src/core/wasm-pkg-layout
mkdir -p frontend/src/core/wasm-layout frontend/public/wasm
cp frontend/src/core/wasm-pkg-layout/dreport_layout.js frontend/src/core/wasm-layout/dreport_layout.js
cp frontend/src/core/wasm-pkg-layout/dreport_layout.d.ts frontend/src/core/wasm-layout/dreport_layout.d.ts
cp frontend/src/core/wasm-pkg-layout/dreport_layout_bg.wasm frontend/public/wasm/dreport_layout_bg.wasm
cp frontend/src/core/wasm-pkg-layout/dreport_layout_bg.wasm.d.ts frontend/src/core/wasm-layout/dreport_layout_bg.wasm.d.ts
# Layout engine WASM watch (rebuild on change)
wasm-watch:
watchexec -w layout-engine/src -w core/src -e rs -- just wasm
# Eski core WASM build (typst-based, deprecated)
wasm-legacy:
wasm-pack build core --target web --release --out-dir ../frontend/src/core/wasm-pkg -- --features wasm
cp frontend/src/core/wasm-pkg/dreport_core.js frontend/src/core/wasm/dreport_core.js
cp frontend/src/core/wasm-pkg/dreport_core.d.ts frontend/src/core/wasm/dreport_core.d.ts

29
layout-engine/Cargo.toml Normal file
View File

@@ -0,0 +1,29 @@
[package]
name = "dreport-layout"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
dreport-core = { path = "../core" }
taffy = "0.7"
cosmic-text = { version = "0.12", default-features = false, features = ["std", "swash"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rxing = { version = "0.8", default-features = false, features = ["encoding_rs"] }
[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen = "0.2"
js-sys = "0.3"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
krilla = { version = "0.6", features = ["raster-images", "simple-text"] }
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
base64 = "0.22"
[features]
default = []
wasm = []

View File

@@ -0,0 +1,286 @@
//! Barcode/QR code üretimi — rxing ile.
//! Hem native hem WASM'da derlenir.
//! Text rendering: cosmic-text (font varsa) veya bitmap fallback.
use cosmic_text::{Attrs, Buffer, Family, FontSystem, Metrics, Shaping, SwashCache};
use rxing::{BarcodeFormat, EncodeHints, Writer};
use crate::FontData;
/// dreport format string → rxing BarcodeFormat
fn to_rxing_format(format: &str) -> Result<BarcodeFormat, String> {
match format {
"qr" => Ok(BarcodeFormat::QR_CODE),
"ean13" => Ok(BarcodeFormat::EAN_13),
"ean8" => Ok(BarcodeFormat::EAN_8),
"code128" => Ok(BarcodeFormat::CODE_128),
"code39" => Ok(BarcodeFormat::CODE_39),
_ => Err(format!("Desteklenmeyen barcode formatı: {format}")),
}
}
/// Barcode üretim sonucu — ham grayscale pixel verisi
pub struct BarcodePixels {
/// Grayscale pixel verileri (0=siyah, 255=beyaz), row-major
pub pixels: Vec<u8>,
pub width: u32,
pub height: u32,
}
/// Herhangi bir barcode formatında ham pixel verisi üret.
/// `include_text`: true ise lineer barkodların altına değer yazılır (QR için etkisiz).
/// `font_data`: Verilirse cosmic-text ile güzel font rendering, yoksa bitmap fallback.
pub fn generate_barcode_pixels(
format: &str,
value: &str,
width_px: u32,
height_px: u32,
include_text: bool,
font_data: Option<&[FontData]>,
) -> Result<BarcodePixels, String> {
if value.is_empty() {
return Err("Boş barcode değeri".to_string());
}
let bc_format = to_rxing_format(format)?;
let is_qr = bc_format == BarcodeFormat::QR_CODE;
// QR kod her zaman kare olmalı
let (req_w, req_h) = if is_qr {
let side = width_px.min(height_px);
(side, side)
} else {
(width_px, height_px)
};
// Metin alanı hesapla (QR hariç, include_text true ise)
let text_area_h = if !is_qr && include_text {
(req_h / 5).max(16).min(48)
} else {
0
};
let bar_h = req_h - text_area_h;
let mut hints = EncodeHints::default();
hints.Margin = Some("1".to_string());
if is_qr {
hints.ErrorCorrection = Some("M".to_string());
}
let writer = rxing::MultiFormatWriter::default();
let matrix = writer
.encode_with_hints(value, &bc_format, req_w as i32, bar_h as i32, &hints)
.map_err(|e| format!("Barcode encode hatası ({format}): {e}"))?;
let mat_w = matrix.width() as u32;
let mat_h = matrix.height() as u32;
// Çıktı boyutu: bar matrisi + metin alanı
let out_w = mat_w;
let out_h = mat_h + text_area_h;
let mut pixels = vec![255u8; (out_w * out_h) as usize];
// Bar matrisini çiz
for y in 0..mat_h {
for x in 0..mat_w {
if matrix.get(x, y) {
pixels[(y * out_w + x) as usize] = 0;
}
}
}
// Metin rendering
if text_area_h > 0 && !is_qr {
render_text_cosmic(&mut pixels, out_w, out_h, mat_h, text_area_h, value, font_data);
}
Ok(BarcodePixels { pixels, width: out_w, height: out_h })
}
/// cosmic-text ile metin render et — gerçek font rendering
fn render_text_cosmic(
pixels: &mut [u8],
img_w: u32,
img_h: u32,
text_y: u32,
text_h: u32,
text: &str,
font_data: Option<&[FontData]>,
) {
let mut font_system = FontSystem::new_with_locale_and_db(
"tr-TR".to_string(),
cosmic_text::fontdb::Database::new(),
);
match font_data {
Some(fonts) if !fonts.is_empty() => {
for f in fonts {
font_system.db_mut().load_font_data(f.data.clone());
}
}
_ => return, // Font yoksa metin render edemeyiz
}
// Font boyutunu text alanına göre ayarla (px cinsinden)
let font_size_px = (text_h as f32 * 0.7).max(10.0);
let line_height_px = font_size_px * 1.2;
let metrics = Metrics::new(font_size_px, line_height_px);
let mut buffer = Buffer::new(&mut font_system, metrics);
buffer.set_size(&mut font_system, Some(img_w as f32), Some(text_h as f32));
let attrs = Attrs::new().family(Family::SansSerif);
buffer.set_text(&mut font_system, text, attrs, Shaping::Advanced);
buffer.shape_until_scroll(&mut font_system, false);
let mut swash_cache = SwashCache::new();
// Text genişliğini hesapla (ortalama için)
let mut text_width: f32 = 0.0;
for run in buffer.layout_runs() {
for glyph in run.glyphs.iter() {
let end = glyph.x + glyph.w;
if end > text_width {
text_width = end;
}
}
}
// Ortalama offset
let offset_x = if (text_width as u32) < img_w {
((img_w as f32 - text_width) / 2.0) as i32
} else {
0
};
let offset_y = text_y as i32 + ((text_h as f32 - line_height_px) / 2.0).max(0.0) as i32;
// Glyph'leri pixel buffer'a çiz
for run in buffer.layout_runs() {
let line_y = offset_y + run.line_y as i32;
for glyph in run.glyphs.iter() {
let physical = glyph.physical((offset_x as f32, line_y as f32), 1.0);
let Some(image) = swash_cache.get_image_uncached(&mut font_system, physical.cache_key) else {
continue;
};
let gx = physical.x + image.placement.left;
let gy = physical.y - image.placement.top;
let gw = image.placement.width as i32;
let gh = image.placement.height as i32;
for row in 0..gh {
for col in 0..gw {
let px = gx + col;
let py = gy + row;
if px < 0 || py < 0 || px >= img_w as i32 || py >= img_h as i32 {
continue;
}
let src_idx = (row * gw + col) as usize;
if src_idx >= image.data.len() { continue; }
let alpha = image.data[src_idx];
if alpha == 0 { continue; }
let dst_idx = (py as u32 * img_w + px as u32) as usize;
if dst_idx >= pixels.len() { continue; }
// Alpha blending: beyaz arka plan üzerine siyah metin
let bg = pixels[dst_idx] as f32;
let a = alpha as f32 / 255.0;
pixels[dst_idx] = (bg * (1.0 - a)) as u8;
}
}
}
}
}
/// PNG bytes olarak barcode üret (sadece native).
#[cfg(not(target_arch = "wasm32"))]
pub fn generate_barcode_png(
format: &str,
value: &str,
width_px: u32,
height_px: u32,
include_text: bool,
font_data: Option<&[FontData]>,
) -> Result<Vec<u8>, String> {
let result = generate_barcode_pixels(format, value, width_px, height_px, include_text, font_data)?;
let img = image::GrayImage::from_raw(result.width, result.height, result.pixels)
.ok_or_else(|| "Pixel buffer boyutu uyumsuz".to_string())?;
let mut buf = Vec::new();
let encoder = image::codecs::png::PngEncoder::new(&mut buf);
image::ImageEncoder::write_image(
encoder,
img.as_raw(),
img.width(),
img.height(),
image::ExtendedColorType::L8,
)
.map_err(|e| format!("PNG encode hatası: {e}"))?;
Ok(buf)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_qr_is_square() {
let result = generate_barcode_pixels("qr", "https://example.com", 300, 200, false, None).unwrap();
assert_eq!(result.width, result.height);
}
#[test]
fn test_ean13_with_text() {
let result = generate_barcode_pixels("ean13", "5901234123457", 300, 100, true, None).unwrap();
assert!(result.width > 0);
assert!(result.height > 0);
}
#[test]
fn test_ean13_without_text() {
let result = generate_barcode_pixels("ean13", "5901234123457", 300, 100, false, None).unwrap();
assert!(result.width > 0);
assert!(result.height > 0);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn test_ean13_with_font_rendering() {
let fonts = crate::text_measure::load_test_fonts();
let result = generate_barcode_pixels("ean13", "5901234123457", 400, 150, true, Some(&fonts)).unwrap();
assert!(result.width > 0);
assert!(result.height > 0);
// Metin alanında siyah pikseller olmalı (font rendering çalıştı)
let text_start = (result.height - result.height / 5) * result.width;
let text_pixels = &result.pixels[text_start as usize..];
assert!(text_pixels.iter().any(|&p| p < 128), "Font rendering metin üretmeli");
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn test_qr_png() {
let png = generate_barcode_png("qr", "https://example.com", 200, 200, false, None).unwrap();
assert!(png.starts_with(&[0x89, b'P', b'N', b'G']));
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn test_ean13_png_with_text() {
let fonts = crate::text_measure::load_test_fonts();
let png = generate_barcode_png("ean13", "5901234123457", 400, 150, true, Some(&fonts)).unwrap();
assert!(png.starts_with(&[0x89, b'P', b'N', b'G']));
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn test_code128_png() {
let png = generate_barcode_png("code128", "ABC-123", 300, 80, true, None).unwrap();
assert!(png.starts_with(&[0x89, b'P', b'N', b'G']));
}
}

View File

@@ -0,0 +1,161 @@
use dreport_core::models::*;
use serde_json::Value;
use std::collections::HashMap;
/// Her element ID'si için çözümlenmiş text içeriğini tutar.
/// Table ve barcode gibi özel tipler de burada çözülür.
#[derive(Debug, Clone)]
pub struct ResolvedData {
/// element_id → çözümlenmiş text içeriği
pub texts: HashMap<String, String>,
/// element_id → çözümlenmiş tablo verileri (headers, rows)
pub tables: HashMap<String, ResolvedTable>,
/// element_id → çözümlenmiş barcode değeri
pub barcodes: HashMap<String, String>,
/// element_id → çözümlenmiş image src
pub images: HashMap<String, String>,
}
#[derive(Debug, Clone)]
pub struct ResolvedTable {
pub rows: Vec<Vec<String>>,
}
/// JSON path ile veri çek: "firma.unvan" → data["firma"]["unvan"]
fn resolve_path<'a>(data: &'a Value, path: &str) -> &'a Value {
let mut current = data;
for key in path.split('.') {
current = match current {
Value::Object(map) => map.get(key).unwrap_or(&Value::Null),
_ => &Value::Null,
};
}
current
}
/// JSON Value → display string
fn value_to_string(v: &Value) -> String {
match v {
Value::String(s) => s.clone(),
Value::Number(n) => n.to_string(),
Value::Bool(b) => b.to_string(),
Value::Null => String::new(),
_ => v.to_string(),
}
}
/// Template'deki tüm binding'leri çözümle.
pub fn resolve_template(template: &Template, data: &Value) -> ResolvedData {
let mut resolved = ResolvedData {
texts: HashMap::new(),
tables: HashMap::new(),
barcodes: HashMap::new(),
images: HashMap::new(),
};
resolve_element(&TemplateElement::Container(template.root.clone()), data, &mut resolved);
resolved
}
fn resolve_element(el: &TemplateElement, data: &Value, resolved: &mut ResolvedData) {
match el {
TemplateElement::StaticText(e) => {
resolved.texts.insert(e.id.clone(), e.content.clone());
}
TemplateElement::Text(e) => {
let bound_value = value_to_string(resolve_path(data, &e.binding.path));
let text = match &e.content {
Some(prefix) if !prefix.is_empty() => format!("{}{}", prefix, bound_value),
_ => bound_value,
};
resolved.texts.insert(e.id.clone(), text);
}
TemplateElement::PageNumber(e) => {
// Sayfa numarası layout sonrasında çözülecek, placeholder koy
let fmt = e.format.as_deref().unwrap_or("{current} / {total}");
resolved.texts.insert(e.id.clone(), fmt.replace("{current}", "1").replace("{total}", "1"));
}
TemplateElement::Barcode(e) => {
let value = if let Some(binding) = &e.binding {
value_to_string(resolve_path(data, &binding.path))
} else {
e.value.clone().unwrap_or_default()
};
resolved.barcodes.insert(e.id.clone(), value);
}
TemplateElement::Image(e) => {
let src = if let Some(binding) = &e.binding {
value_to_string(resolve_path(data, &binding.path))
} else {
e.src.clone().unwrap_or_default()
};
resolved.images.insert(e.id.clone(), src);
}
TemplateElement::RepeatingTable(e) => {
let array = resolve_path(data, &e.data_source.path);
let rows = match array {
Value::Array(items) => {
items
.iter()
.map(|item| {
e.columns
.iter()
.map(|col| {
let v = resolve_path(item, &col.field);
value_to_string(v)
})
.collect()
})
.collect()
}
_ => vec![],
};
resolved.tables.insert(e.id.clone(), ResolvedTable { rows });
}
TemplateElement::Container(e) => {
for child in &e.children {
resolve_element(child, data, resolved);
}
}
TemplateElement::Line(_) => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_resolve_path() {
let data: Value = serde_json::json!({
"firma": {
"unvan": "Acme A.Ş.",
"vergiNo": "123"
}
});
assert_eq!(
value_to_string(resolve_path(&data, "firma.unvan")),
"Acme A.Ş."
);
assert_eq!(
value_to_string(resolve_path(&data, "firma.vergiNo")),
"123"
);
assert_eq!(
value_to_string(resolve_path(&data, "nonexistent.path")),
""
);
}
#[test]
fn test_resolve_array() {
let data: Value = serde_json::json!({
"kalemler": [
{ "adi": "Widget", "tutar": 100 },
{ "adi": "Gadget", "tutar": 200 }
]
});
let arr = resolve_path(&data, "kalemler");
assert!(arr.is_array());
assert_eq!(arr.as_array().unwrap().len(), 2);
}
}

142
layout-engine/src/lib.rs Normal file
View File

@@ -0,0 +1,142 @@
pub mod sizing;
pub mod text_measure;
pub mod data_resolve;
pub mod table_layout;
pub mod tree;
#[cfg(target_arch = "wasm32")]
pub mod wasm_api;
pub mod barcode_gen;
#[cfg(not(target_arch = "wasm32"))]
pub mod pdf_render;
use dreport_core::models::Template;
use serde::{Deserialize, Serialize};
// --- Layout sonuç tipleri ---
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LayoutResult {
pub pages: Vec<PageLayout>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PageLayout {
pub page_index: usize,
pub width_mm: f64,
pub height_mm: f64,
pub elements: Vec<ElementLayout>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ElementLayout {
pub id: String,
pub x_mm: f64,
pub y_mm: f64,
pub width_mm: f64,
pub height_mm: f64,
pub element_type: String,
pub content: Option<ResolvedContent>,
pub style: ResolvedStyle,
pub children: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum ResolvedContent {
#[serde(rename = "text")]
Text { value: String },
#[serde(rename = "image")]
Image { src: String },
#[serde(rename = "line")]
Line,
#[serde(rename = "barcode")]
Barcode { format: String, value: String },
#[serde(rename = "page_number")]
PageNumber { current: usize, total: usize },
#[serde(rename = "table")]
Table {
headers: Vec<TableHeaderCell>,
rows: Vec<Vec<TableCell>>,
column_widths_mm: Vec<f64>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TableHeaderCell {
pub text: String,
pub align: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TableCell {
pub text: String,
pub align: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ResolvedStyle {
// Text
pub font_size: Option<f64>,
pub font_weight: Option<String>,
pub font_family: Option<String>,
pub color: Option<String>,
pub text_align: Option<String>,
// Line
pub stroke_color: Option<String>,
pub stroke_width: Option<f64>,
// Container
pub background_color: Option<String>,
pub border_color: Option<String>,
pub border_width: Option<f64>,
pub border_radius: Option<f64>,
pub border_style: Option<String>,
// Table
pub header_bg: Option<String>,
pub header_color: Option<String>,
pub zebra_odd: Option<String>,
pub zebra_even: Option<String>,
pub header_font_size: Option<f64>,
// Image
pub object_fit: Option<String>,
// Barcode
pub barcode_color: Option<String>,
pub barcode_include_text: Option<bool>,
}
/// Ana layout hesaplama fonksiyonu.
/// Template + data + font verileri alır, her element için pozisyon döner.
pub fn compute_layout(
template: &Template,
data: &serde_json::Value,
font_data: &[FontData],
) -> LayoutResult {
let mut measurer = text_measure::TextMeasurer::new(font_data);
let resolved = data_resolve::resolve_template(template, data);
tree::compute(template, &resolved, &mut measurer)
}
/// Cache-aware layout hesaplama.
/// Önceki çağrıdan kalan text measurement cache'ini alır, hesaplama sonrası
/// güncellenen cache'i geri döner. WASM tarafında cross-call persist için kullanılır.
pub fn compute_layout_cached(
template: &Template,
data: &serde_json::Value,
font_data: &[FontData],
text_cache: text_measure::TextMeasureCache,
) -> (LayoutResult, text_measure::TextMeasureCache) {
let mut measurer = text_measure::TextMeasurer::new_with_cache(font_data, text_cache);
let resolved = data_resolve::resolve_template(template, data);
let result = tree::compute(template, &resolved, &mut measurer);
(result, measurer.take_cache())
}
/// Font verisi (ham TTF/OTF bytes)
#[derive(Debug, Clone)]
pub struct FontData {
pub family: String,
pub data: Vec<u8>,
}

View File

@@ -0,0 +1,680 @@
//! LayoutResult → PDF bytes (krilla ile).
//! Sadece native (non-WASM) hedeflerde derlenir.
use std::collections::HashMap;
use krilla::color::rgb;
use krilla::geom::{PathBuilder, Point, Size, Transform};
use krilla::num::NormalizedF32;
use krilla::page::PageSettings;
use krilla::paint::{Fill, Stroke};
use krilla::text::{Font as KrillaFont, TextDirection};
use krilla::Document;
use crate::text_measure::TextMeasurer;
use crate::{ElementLayout, FontData, LayoutResult, PageLayout, ResolvedContent, ResolvedStyle};
/// mm → pt dönüşümü (1mm = 2.83465pt)
const MM_TO_PT: f32 = 72.0 / 25.4;
fn mm(v: f64) -> f32 {
v as f32 * MM_TO_PT
}
/// Hex renk (#RRGGBB veya #RGB) → rgb::Color
fn parse_color(hex: &str) -> rgb::Color {
let hex = hex.trim_start_matches('#');
let (r, g, b) = match hex.len() {
6 => (
u8::from_str_radix(&hex[0..2], 16).unwrap_or(0),
u8::from_str_radix(&hex[2..4], 16).unwrap_or(0),
u8::from_str_radix(&hex[4..6], 16).unwrap_or(0),
),
3 => {
let r = u8::from_str_radix(&hex[0..1], 16).unwrap_or(0);
let g = u8::from_str_radix(&hex[1..2], 16).unwrap_or(0);
let b = u8::from_str_radix(&hex[2..3], 16).unwrap_or(0);
(r * 17, g * 17, b * 17)
}
_ => (0, 0, 0),
};
rgb::Color::new(r, g, b)
}
fn fill_from_color(color: rgb::Color) -> Fill {
Fill {
paint: color.into(),
opacity: NormalizedF32::ONE,
rule: Default::default(),
}
}
/// Font koleksiyonu — family + weight + italic → KrillaFont mapping
struct FontCollection {
/// (family_lower, is_bold, is_italic) → KrillaFont
fonts: HashMap<(String, bool, bool), KrillaFont>,
/// Fallback font (ilk yüklenen regular)
default: Option<KrillaFont>,
}
impl FontCollection {
fn new(font_data: &[FontData]) -> Self {
let mut fonts = HashMap::new();
let mut default = None;
for fd in font_data {
let Some(font) = KrillaFont::new(
krilla::Data::from(fd.data.clone()),
0,
) else {
continue;
};
let family_lower = fd.family.to_lowercase();
let is_bold = is_font_bold(&fd.data);
let is_italic = is_font_italic(&fd.data);
// Default font: ilk regular (non-bold, non-italic)
if default.is_none() && !is_bold && !is_italic {
default = Some(font.clone());
}
fonts.insert((family_lower.clone(), is_bold, is_italic), font);
}
// Hiç regular bulamadıysak ilk font'u default yap
if default.is_none() {
if let Some(fd) = font_data.first() {
default = KrillaFont::new(krilla::Data::from(fd.data.clone()), 0);
}
}
Self { fonts, default }
}
fn get(&self, family: Option<&str>, weight: Option<&str>) -> Option<&KrillaFont> {
let is_bold = matches!(weight, Some("bold"));
let family_lower = family.unwrap_or("noto sans").to_lowercase();
// Her zaman non-italic font ara (italic desteği henüz yok)
self.fonts
.get(&(family_lower.clone(), is_bold, false))
.or_else(|| self.fonts.get(&(family_lower, false, false)))
.or(self.default.as_ref())
}
}
/// TTF OS/2 tablosunun offset'ini bul
fn find_os2_table(data: &[u8]) -> Option<usize> {
if data.len() < 12 {
return None;
}
let num_tables = u16::from_be_bytes([data[4], data[5]]) as usize;
let mut offset = 12;
for _ in 0..num_tables {
if offset + 16 > data.len() {
break;
}
let tag = &data[offset..offset + 4];
if tag == b"OS/2" {
let table_offset =
u32::from_be_bytes([data[offset + 8], data[offset + 9], data[offset + 10], data[offset + 11]])
as usize;
return Some(table_offset);
}
offset += 16;
}
None
}
/// TTF dosyasının bold olup olmadığını OS/2 tablosundan kontrol et
fn is_font_bold(data: &[u8]) -> bool {
let Some(table_offset) = find_os2_table(data) else {
return false;
};
// usWeightClass is at offset 4 in OS/2 table
if table_offset + 6 <= data.len() {
let weight_class = u16::from_be_bytes([data[table_offset + 4], data[table_offset + 5]]);
return weight_class >= 700;
}
false
}
/// TTF dosyasının italic olup olmadığını OS/2 tablosundan kontrol et
fn is_font_italic(data: &[u8]) -> bool {
let Some(table_offset) = find_os2_table(data) else {
return false;
};
// fsSelection is at offset 62 in OS/2 table, bit 0 = ITALIC
if table_offset + 64 <= data.len() {
let fs_selection = u16::from_be_bytes([data[table_offset + 62], data[table_offset + 63]]);
return fs_selection & 0x0001 != 0;
}
false
}
/// LayoutResult → PDF bytes
pub fn render_pdf(layout: &LayoutResult, font_data: &[FontData]) -> Result<Vec<u8>, String> {
let fonts = FontCollection::new(font_data);
let mut measurer = TextMeasurer::new(font_data);
let mut doc = Document::new();
for page in &layout.pages {
render_page(&mut doc, page, &fonts, font_data, &mut measurer)?;
}
doc.finish().map_err(|e| format!("PDF oluşturma hatası: {e:?}"))
}
fn render_page(
doc: &mut Document,
page: &PageLayout,
fonts: &FontCollection,
font_data: &[FontData],
measurer: &mut TextMeasurer,
) -> Result<(), String> {
let w = mm(page.width_mm);
let h = mm(page.height_mm);
let page_settings =
PageSettings::from_wh(w, h).ok_or_else(|| "Geçersiz sayfa boyutu".to_string())?;
let mut pdf_page = doc.start_page_with(page_settings);
let mut surface = pdf_page.surface();
for el in &page.elements {
render_element(&mut surface, el, fonts, font_data, measurer);
}
surface.finish();
pdf_page.finish();
Ok(())
}
fn render_element(
surface: &mut krilla::surface::Surface<'_>,
el: &ElementLayout,
fonts: &FontCollection,
font_data: &[FontData],
measurer: &mut TextMeasurer,
) {
let x = mm(el.x_mm);
let y = mm(el.y_mm);
let w = mm(el.width_mm);
let h = mm(el.height_mm);
// Container background/border
if el.element_type == "container" {
render_container_bg(surface, x, y, w, h, &el.style);
}
let Some(ref content) = el.content else {
return;
};
match content {
ResolvedContent::Text { value } => {
render_text(surface, x, y, w, h, value, &el.style, fonts, measurer);
}
ResolvedContent::Line => {
render_line(surface, x, y, w, &el.style);
}
ResolvedContent::Image { src } => {
render_image(surface, x, y, w, h, src);
}
ResolvedContent::PageNumber { current, total } => {
let text = format!("{current} / {total}");
render_text(surface, x, y, w, h, &text, &el.style, fonts, measurer);
}
ResolvedContent::Table { .. } => {
// Tablolar expand edilerek container + text olarak render edilir.
// Bu branch'e normalde düşmemeli.
}
ResolvedContent::Barcode { format, value } => {
render_barcode(surface, x, y, w, h, format, value, &el.style, font_data);
}
}
}
fn render_container_bg(
surface: &mut krilla::surface::Surface<'_>,
x: f32,
y: f32,
w: f32,
h: f32,
style: &ResolvedStyle,
) {
let has_bg = style.background_color.is_some();
let has_border = style.border_color.is_some() && style.border_width.unwrap_or(0.0) > 0.0;
if !has_bg && !has_border {
return;
}
// Fill
if let Some(ref bg) = style.background_color {
surface.set_fill(Some(fill_from_color(parse_color(bg))));
} else {
surface.set_fill(None);
}
// Stroke
if has_border {
let border_color = parse_color(style.border_color.as_deref().unwrap_or("#000000"));
let border_width = mm(style.border_width.unwrap_or(0.5));
surface.set_stroke(Some(Stroke {
paint: border_color.into(),
width: border_width,
opacity: NormalizedF32::ONE,
..Default::default()
}));
} else {
surface.set_stroke(None);
}
let rect_path = {
let mut pb = PathBuilder::new();
if let Some(rect) = krilla::geom::Rect::from_xywh(x, y, w, h) {
pb.push_rect(rect);
}
pb.finish()
};
if let Some(path) = rect_path {
surface.draw_path(&path);
}
// Reset
surface.set_fill(None);
surface.set_stroke(None);
}
fn render_text(
surface: &mut krilla::surface::Surface<'_>,
x: f32,
y: f32,
w: f32,
_h: f32,
text: &str,
style: &ResolvedStyle,
fonts: &FontCollection,
measurer: &mut TextMeasurer,
) {
if text.is_empty() {
return;
}
let font_size = style.font_size.unwrap_or(11.0) as f32;
let color = style
.color
.as_deref()
.map(parse_color)
.unwrap_or(rgb::Color::new(0, 0, 0));
let Some(font) = fonts.get(
style.font_family.as_deref(),
style.font_weight.as_deref(),
) else {
return;
};
surface.set_fill(Some(fill_from_color(color)));
surface.set_stroke(None);
// Text baseline: y + ascent (yaklaşık font_size * 0.8)
let baseline_y = y + font_size * 0.8;
// Hizalama — cosmic-text ile text genişliğini ölçerek gerçek pozisyon hesapla
let text_x = match style.text_align.as_deref() {
Some("center") | Some("right") => {
let (text_width_pt, _) = measurer.measure(
text,
style.font_family.as_deref(),
font_size,
style.font_weight.as_deref(),
None,
);
if style.text_align.as_deref() == Some("center") {
x + (w - text_width_pt) / 2.0
} else {
x + w - text_width_pt
}
}
_ => x,
};
surface.draw_text(
Point::from_xy(text_x, baseline_y),
font.clone(),
font_size,
text,
false,
TextDirection::Auto,
);
}
fn render_line(
surface: &mut krilla::surface::Surface<'_>,
x: f32,
y: f32,
w: f32,
style: &ResolvedStyle,
) {
let stroke_color = style
.stroke_color
.as_deref()
.map(parse_color)
.unwrap_or(rgb::Color::new(0, 0, 0));
let stroke_width = mm(style.stroke_width.unwrap_or(0.5));
surface.set_fill(None);
surface.set_stroke(Some(Stroke {
paint: stroke_color.into(),
width: stroke_width,
opacity: NormalizedF32::ONE,
..Default::default()
}));
let line_y = y + stroke_width / 2.0;
let path = {
let mut pb = PathBuilder::new();
pb.move_to(x, line_y);
pb.line_to(x + w, line_y);
pb.finish()
};
if let Some(p) = path {
surface.draw_path(&p);
}
surface.set_stroke(None);
}
fn render_image(
surface: &mut krilla::surface::Surface<'_>,
x: f32,
y: f32,
w: f32,
h: f32,
src: &str,
) {
if src.is_empty() {
return;
}
// data:image/png;base64,... veya data:image/jpeg;base64,...
let Some(base64_part) = src.split(',').nth(1) else {
eprintln!("[dreport] Image src data URI değil, atlanıyor: {}...", &src[..src.len().min(60)]);
return;
};
use base64::Engine;
let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(base64_part) else {
eprintln!("[dreport] Image base64 decode hatası");
return;
};
// Tüm formatları image crate ile decode edip PNG'ye çevir (krilla JPEG desteği sınırlı)
let png_data = match decode_to_png(&decoded) {
Some(data) => data,
None => {
eprintln!("[dreport] Image decode/re-encode hatası, ham veri deneniyor");
decoded
}
};
embed_png(surface, x, y, w, h, &png_data);
}
fn render_barcode(
surface: &mut krilla::surface::Surface<'_>,
x: f32,
y: f32,
w: f32,
h: f32,
format: &str,
value: &str,
style: &ResolvedStyle,
font_data: &[FontData],
) {
if value.is_empty() {
return;
}
// Hedef piksel boyutları (yüksek çözünürlük için 4x, minimum 1px)
let w_px = ((w * 4.0) as u32).max(1);
let h_px = ((h * 4.0) as u32).max(1);
let include_text = style.barcode_include_text.unwrap_or(false);
let png_result = crate::barcode_gen::generate_barcode_png(format, value, w_px, h_px, include_text, Some(font_data));
match png_result {
Ok(png_bytes) => {
embed_png(surface, x, y, w, h, &png_bytes);
}
Err(e) => {
eprintln!("[dreport] Barcode üretim hatası ({format}): {e}");
}
}
}
/// image crate ile herhangi bir formattan decode edip PNG bytes'a çevir
fn decode_to_png(raw: &[u8]) -> Option<Vec<u8>> {
let img = image::load_from_memory(raw).ok()?;
let rgba = img.to_rgba8();
let mut buf = Vec::new();
let encoder = image::codecs::png::PngEncoder::new(&mut buf);
image::ImageEncoder::write_image(
encoder,
rgba.as_raw(),
rgba.width(),
rgba.height(),
image::ExtendedColorType::Rgba8,
)
.ok()?;
Some(buf)
}
/// PNG bytes'ı PDF'e göm
fn embed_png(
surface: &mut krilla::surface::Surface<'_>,
x: f32,
y: f32,
w: f32,
h: f32,
data: &[u8],
) {
let data_vec: Vec<u8> = data.to_vec();
let Ok(img) = krilla::image::Image::from_png(data_vec.into(), true) else {
eprintln!("[dreport] PNG krilla embed hatası");
return;
};
let Some(size) = Size::from_wh(w, h) else {
return;
};
surface.push_transform(&Transform::from_translate(x, y));
surface.draw_image(img, size);
surface.pop();
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{ElementLayout, PageLayout, ResolvedContent, ResolvedStyle};
fn test_fonts() -> Vec<FontData> {
crate::text_measure::load_test_fonts()
}
#[test]
fn test_simple_pdf() {
let layout = LayoutResult {
pages: vec![PageLayout {
page_index: 0,
width_mm: 210.0,
height_mm: 297.0,
elements: vec![
ElementLayout {
id: "title".to_string(),
x_mm: 15.0,
y_mm: 15.0,
width_mm: 180.0,
height_mm: 20.0,
element_type: "static_text".to_string(),
content: Some(ResolvedContent::Text {
value: "FATURA".to_string(),
}),
style: ResolvedStyle {
font_size: Some(18.0),
font_weight: Some("bold".to_string()),
..Default::default()
},
children: vec![],
},
ElementLayout {
id: "line1".to_string(),
x_mm: 15.0,
y_mm: 38.0,
width_mm: 180.0,
height_mm: 0.5,
element_type: "line".to_string(),
content: Some(ResolvedContent::Line),
style: ResolvedStyle {
stroke_color: Some("#000000".to_string()),
stroke_width: Some(0.5),
..Default::default()
},
children: vec![],
},
ElementLayout {
id: "body".to_string(),
x_mm: 15.0,
y_mm: 42.0,
width_mm: 180.0,
height_mm: 14.0,
element_type: "static_text".to_string(),
content: Some(ResolvedContent::Text {
value: "Bu bir test belgesidir.".to_string(),
}),
style: ResolvedStyle {
font_size: Some(11.0),
..Default::default()
},
children: vec![],
},
],
}],
};
let fonts = test_fonts();
let pdf = render_pdf(&layout, &fonts).expect("PDF oluşturulabilmeli");
// PDF magic bytes kontrolü
assert!(pdf.starts_with(b"%PDF"), "Geçerli PDF çıktısı olmalı");
assert!(pdf.len() > 100, "PDF boyutu çok küçük: {} bytes", pdf.len());
// Debug: dosyaya yaz
let out_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.join("test_output.pdf");
std::fs::write(&out_path, &pdf).unwrap();
println!("Test PDF yazıldı: {}", out_path.display());
}
#[test]
fn test_full_pipeline() {
use dreport_core::models::*;
use serde_json::json;
let template = Template {
id: "test".to_string(),
name: "Test".to_string(),
page: PageSettings { width: 210.0, height: 297.0 },
fonts: vec!["Noto Sans".to_string()],
root: ContainerElement {
id: "root".to_string(),
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Auto,
height: SizeValue::Auto,
min_width: None, min_height: None, max_width: None, max_height: None,
},
direction: "column".to_string(),
gap: 5.0,
padding: Padding { top: 15.0, right: 15.0, bottom: 15.0, left: 15.0 },
align: "stretch".to_string(),
justify: "start".to_string(),
style: ContainerStyle::default(),
children: vec![
TemplateElement::StaticText(StaticTextElement {
id: "title".to_string(),
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
min_width: None, min_height: None, max_width: None, max_height: None,
},
style: TextStyle {
font_size: Some(18.0),
font_weight: Some("bold".to_string()),
..Default::default()
},
content: "FATURA".to_string(),
}),
TemplateElement::Line(LineElement {
id: "line1".to_string(),
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
min_width: None, min_height: None, max_width: None, max_height: None,
},
style: LineStyle {
stroke_color: Some("#000000".to_string()),
stroke_width: Some(0.5),
},
}),
TemplateElement::Text(TextElement {
id: "firma".to_string(),
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
min_width: None, min_height: None, max_width: None, max_height: None,
},
style: TextStyle {
font_size: Some(11.0),
..Default::default()
},
content: None,
binding: dreport_core::models::ScalarBinding {
path: "firma.unvan".to_string(),
},
}),
],
},
};
let data = json!({
"firma": { "unvan": "Acme Teknoloji A.Ş." }
});
let fonts = test_fonts();
let layout = crate::compute_layout(&template, &data, &fonts);
let pdf = render_pdf(&layout, &fonts).expect("Full pipeline PDF");
assert!(pdf.starts_with(b"%PDF"));
assert!(pdf.len() > 200);
let out_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.join("test_output_full.pdf");
std::fs::write(&out_path, &pdf).unwrap();
println!("Full pipeline PDF: {}", out_path.display());
}
}

222
layout-engine/src/sizing.rs Normal file
View File

@@ -0,0 +1,222 @@
use dreport_core::models::{ContainerElement, PositionMode, SizeConstraint, SizeValue};
use taffy::prelude::*;
/// 1mm = 72/25.4 pt (kesin değer)
const MM_TO_PT_F64: f64 = 72.0 / 25.4;
pub fn mm_to_pt(mm: f64) -> f32 {
(mm * MM_TO_PT_F64) as f32
}
pub fn pt_to_mm(pt: f32) -> f64 {
(pt as f64) / MM_TO_PT_F64
}
/// SizeValue → taffy Dimension (width veya height için)
fn size_value_to_dimension(sv: &SizeValue) -> Dimension {
match sv {
SizeValue::Fixed { value } => Dimension::Length(mm_to_pt(*value)),
SizeValue::Auto => Dimension::Auto,
// Fr için dimension Auto, flex_grow ayrıca set edilir
SizeValue::Fr { .. } => Dimension::Auto,
}
}
/// SizeValue → taffy LengthPercentage (min/max constraint'ler için)
fn mm_to_length(mm: f64) -> Dimension {
Dimension::Length(mm_to_pt(mm))
}
/// Fr değerini döndür (yoksa 0)
fn fr_value(sv: &SizeValue) -> f32 {
match sv {
SizeValue::Fr { value } => *value as f32,
_ => 0.0,
}
}
/// SizeConstraint'ten taffy Style'a flex_grow ayarını da dahil ederek dönüştür.
/// `main_axis` parametresi parent container'ın direction'ına göre
/// hangi eksenin flex_grow kullanacağını belirler.
pub fn apply_size_to_style(
style: &mut Style,
size: &SizeConstraint,
parent_direction: Option<&str>,
) {
style.size = Size {
width: size_value_to_dimension(&size.width),
height: size_value_to_dimension(&size.height),
};
// Min/max constraint'ler
if let Some(min_w) = size.min_width {
style.min_size.width = mm_to_length(min_w);
}
if let Some(min_h) = size.min_height {
style.min_size.height = mm_to_length(min_h);
}
if let Some(max_w) = size.max_width {
style.max_size.width = mm_to_length(max_w);
}
if let Some(max_h) = size.max_height {
style.max_size.height = mm_to_length(max_h);
}
// Fr → flex_grow (main axis'e göre)
let main_fr = match parent_direction {
Some("row") => fr_value(&size.width),
Some("column") | _ => fr_value(&size.height),
};
// Cross axis fr: row'da height fr, column'da width fr
let cross_fr = match parent_direction {
Some("row") => fr_value(&size.height),
Some("column") | _ => fr_value(&size.width),
};
// Eğer main axis fr ise, flex_grow ayarla ve flex_basis 0 yap
if main_fr > 0.0 {
style.flex_grow = main_fr;
style.flex_shrink = 1.0;
style.flex_basis = Dimension::Length(0.0);
// min-width: 0 (row) veya min-height: 0 (column) ayarla —
// taffy'de min_size default Auto = içerik boyutunun altına küçülemez.
// Fr elemanların içerik taşırması engellemek için min_size 0 olmalı.
match parent_direction {
Some("row") => {
if size.min_width.is_none() {
style.min_size.width = Dimension::Length(0.0);
}
}
_ => {
if size.min_height.is_none() {
style.min_size.height = Dimension::Length(0.0);
}
}
}
}
// Cross axis fr ise, align_self stretch yeterli
// (taffy'de cross axis flex_grow doğrudan yok, stretch ile çözülür)
if cross_fr > 0.0 {
style.align_self = Some(AlignSelf::Stretch);
}
}
/// ContainerElement → taffy Style
pub fn container_to_style(el: &ContainerElement, parent_direction: Option<&str>) -> Style {
let mut style = Style {
display: Display::Flex,
flex_direction: match el.direction.as_str() {
"row" => FlexDirection::Row,
_ => FlexDirection::Column,
},
gap: Size {
width: LengthPercentage::Length(mm_to_pt(el.gap)),
height: LengthPercentage::Length(mm_to_pt(el.gap)),
},
padding: Rect {
top: LengthPercentage::Length(mm_to_pt(el.padding.top)),
right: LengthPercentage::Length(mm_to_pt(el.padding.right)),
bottom: LengthPercentage::Length(mm_to_pt(el.padding.bottom)),
left: LengthPercentage::Length(mm_to_pt(el.padding.left)),
},
align_items: Some(match el.align.as_str() {
"center" => AlignItems::Center,
"end" => AlignItems::FlexEnd,
"stretch" => AlignItems::Stretch,
_ => AlignItems::FlexStart,
}),
justify_content: Some(match el.justify.as_str() {
"center" => JustifyContent::Center,
"end" => JustifyContent::FlexEnd,
"space-between" => JustifyContent::SpaceBetween,
_ => JustifyContent::FlexStart,
}),
..Default::default()
};
// Pozisyon moduna göre
match &el.position {
PositionMode::Absolute { x, y } => {
style.position = Position::Absolute;
style.inset = Rect {
top: LengthPercentageAuto::Length(mm_to_pt(*y)),
left: LengthPercentageAuto::Length(mm_to_pt(*x)),
right: auto(),
bottom: auto(),
};
}
PositionMode::Flow => {}
}
// Boyut
apply_size_to_style(&mut style, &el.size, parent_direction);
// Container border
if let Some(bw) = el.style.border_width {
let bpt = mm_to_pt(bw);
style.border = Rect {
top: LengthPercentage::Length(bpt),
right: LengthPercentage::Length(bpt),
bottom: LengthPercentage::Length(bpt),
left: LengthPercentage::Length(bpt),
};
}
style
}
/// Leaf element (text, line, image vs.) için taffy Style
pub fn leaf_style(
size: &SizeConstraint,
position: &PositionMode,
parent_direction: Option<&str>,
) -> Style {
let mut style = Style::default();
match position {
PositionMode::Absolute { x, y } => {
style.position = Position::Absolute;
style.inset = Rect {
top: LengthPercentageAuto::Length(mm_to_pt(*y)),
left: LengthPercentageAuto::Length(mm_to_pt(*x)),
right: auto(),
bottom: auto(),
};
}
PositionMode::Flow => {}
}
apply_size_to_style(&mut style, size, parent_direction);
style
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_mm_to_pt_conversion() {
let pt = mm_to_pt(210.0);
// A4 width = 210mm ≈ 595.28pt
assert!((pt - 595.28).abs() < 0.1);
}
#[test]
fn test_fixed_size() {
let sv = SizeValue::Fixed { value: 50.0 };
match size_value_to_dimension(&sv) {
Dimension::Length(pt) => assert!((pt - mm_to_pt(50.0)).abs() < 0.01),
_ => panic!("Expected Length"),
}
}
#[test]
fn test_fr_maps_to_auto_dimension() {
let sv = SizeValue::Fr { value: 2.0 };
assert!(matches!(size_value_to_dimension(&sv), Dimension::Auto));
}
}

View File

@@ -0,0 +1,191 @@
use dreport_core::models::*;
use crate::data_resolve::ResolvedData;
/// RepeatingTable element'ini bir container ağacına expand eder.
/// Tablo → column container (header row + data rows)
/// Her row → row container (cell'ler → static_text)
///
/// Bu sayede tablo, normal container layout'u ile hesaplanır.
pub fn expand_table(
table: &RepeatingTableElement,
resolved: &ResolvedData,
) -> ContainerElement {
let resolved_table = resolved.tables.get(&table.id);
let rows = resolved_table
.map(|t| t.rows.as_slice())
.unwrap_or(&[]);
let mut children: Vec<TemplateElement> = Vec::new();
// Header row
let header_cells: Vec<TemplateElement> = table
.columns
.iter()
.enumerate()
.map(|(i, col)| {
TemplateElement::StaticText(StaticTextElement {
id: format!("{}_hdr_{}", table.id, i),
position: PositionMode::Flow,
size: SizeConstraint {
width: col.width.clone(),
height: SizeValue::Auto,
min_width: None,
min_height: None,
max_width: None,
max_height: None,
},
style: TextStyle {
font_size: table.style.header_font_size.or(table.style.font_size),
font_weight: Some("bold".to_string()),
font_family: None,
color: table.style.header_color.clone(),
align: Some(col.align.clone()),
},
content: col.title.clone(),
})
})
.collect();
children.push(TemplateElement::Container(ContainerElement {
id: format!("{}_header", table.id),
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
min_width: None,
min_height: None,
max_width: None,
max_height: None,
},
direction: "row".to_string(),
gap: 0.0,
padding: Padding {
top: 1.0,
right: 0.0,
bottom: 1.0,
left: 0.0,
},
align: "center".to_string(),
justify: "start".to_string(),
style: ContainerStyle {
background_color: table.style.header_bg.clone(),
..Default::default()
},
children: header_cells,
}));
// Header altına ayırıcı çizgi
if table.style.border_color.is_some() {
children.push(TemplateElement::Line(LineElement {
id: format!("{}_header_line", table.id),
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
min_width: None,
min_height: None,
max_width: None,
max_height: None,
},
style: LineStyle {
stroke_color: table.style.border_color.clone(),
stroke_width: table.style.border_width,
},
}));
}
// Data rows
for (row_idx, row_data) in rows.iter().enumerate() {
let cells: Vec<TemplateElement> = table
.columns
.iter()
.enumerate()
.map(|(col_idx, col)| {
let text = row_data
.get(col_idx)
.cloned()
.unwrap_or_default();
TemplateElement::StaticText(StaticTextElement {
id: format!("{}_r{}c{}", table.id, row_idx, col_idx),
position: PositionMode::Flow,
size: SizeConstraint {
width: col.width.clone(),
height: SizeValue::Auto,
min_width: None,
min_height: None,
max_width: None,
max_height: None,
},
style: TextStyle {
font_size: table.style.font_size,
font_weight: None,
font_family: None,
color: None,
align: Some(col.align.clone()),
},
content: text,
})
})
.collect();
// row_idx 0-based: 0. satır görsel olarak 1. (tek/odd), 1. satır 2. (çift/even)
let bg = if row_idx % 2 == 0 {
table.style.zebra_odd.clone()
} else {
table.style.zebra_even.clone()
};
children.push(TemplateElement::Container(ContainerElement {
id: format!("{}_row_{}", table.id, row_idx),
position: PositionMode::Flow,
size: SizeConstraint {
width: SizeValue::Fr { value: 1.0 },
height: SizeValue::Auto,
min_width: None,
min_height: None,
max_width: None,
max_height: None,
},
direction: "row".to_string(),
gap: 0.0,
padding: Padding {
top: 0.5,
right: 0.0,
bottom: 0.5,
left: 0.0,
},
align: "center".to_string(),
justify: "start".to_string(),
style: ContainerStyle {
background_color: bg,
..Default::default()
},
children: cells,
}));
}
// Wrapper container (column direction, tüm tablo)
ContainerElement {
id: table.id.clone(),
position: table.position.clone(),
size: table.size.clone(),
direction: "column".to_string(),
gap: 0.0,
padding: Padding {
top: 0.0,
right: 0.0,
bottom: 0.0,
left: 0.0,
},
align: "stretch".to_string(),
justify: "start".to_string(),
style: ContainerStyle {
border_color: table.style.border_color.clone(),
border_width: table.style.border_width,
..Default::default()
},
children,
}
}

View File

@@ -0,0 +1,277 @@
use std::collections::HashMap;
use std::hash::Hash;
use crate::FontData;
use cosmic_text::{Attrs, Buffer, Family, FontSystem, Metrics, Shaping, Weight};
/// Opak text ölçüm cache'i. `TextMeasurer` call'ları arasında taşınarak
/// aynı parametrelerle yapılan ölçümlerin yeniden hesaplanmasını önler.
#[derive(Default)]
pub struct TextMeasureCache {
entries: HashMap<MeasureCacheKey, (f32, f32)>,
}
impl TextMeasureCache {
/// Cache içeriğini al ve yerine boş cache bırak.
pub fn take(&mut self) -> Self {
Self {
entries: std::mem::take(&mut self.entries),
}
}
}
/// Cache key — text ölçüm parametrelerinin hash'lenebilir temsili.
/// f32 değerler bit-exact karşılaştırma için u32'ye çevrilir.
#[derive(Clone, Eq, PartialEq, Hash)]
struct MeasureCacheKey {
text: String,
font_family: Option<String>,
font_size_bits: u32,
font_weight: Option<String>,
available_width_bits: Option<u32>,
}
impl MeasureCacheKey {
fn new(
text: &str,
font_family: Option<&str>,
font_size_pt: f32,
font_weight: Option<&str>,
available_width_pt: Option<f32>,
) -> Self {
Self {
text: text.to_string(),
font_family: font_family.map(|s| s.to_string()),
font_size_bits: font_size_pt.to_bits(),
font_weight: font_weight.map(|s| s.to_string()),
available_width_bits: available_width_pt.map(|w| w.to_bits()),
}
}
}
/// Text ölçüm motoru. cosmic-text kullanarak verilen font, boyut ve
/// mevcut genişlik kısıtı ile text'in kaplayacağı alanı hesaplar.
/// Ölçüm sonuçları cache'lenir — aynı parametrelerle tekrar çağrılırsa
/// cosmic-text'e gitmeden cache'ten döner.
pub struct TextMeasurer {
font_system: FontSystem,
cache: HashMap<MeasureCacheKey, (f32, f32)>,
}
/// pt → px dönüşümü (cosmic-text px cinsinden çalışır, 1pt = 1.333px @96dpi)
const PT_TO_PX: f32 = 96.0 / 72.0;
impl TextMeasurer {
pub fn new(fonts: &[FontData]) -> Self {
let mut font_system = FontSystem::new_with_locale_and_db(
"tr-TR".to_string(),
cosmic_text::fontdb::Database::new(),
);
for font in fonts {
font_system.db_mut().load_font_data(font.data.clone());
}
Self {
font_system,
cache: HashMap::new(),
}
}
/// Mevcut cache'i koruyarak yeni bir TextMeasurer oluştur.
/// Font seti değişmediyse eski cache geçerliliğini korur.
pub fn new_with_cache(fonts: &[FontData], cache: TextMeasureCache) -> Self {
let mut m = Self::new(fonts);
m.cache = cache.entries;
m
}
/// Cache'i dışarı taşı (persist etmek için).
pub fn take_cache(self) -> TextMeasureCache {
TextMeasureCache { entries: self.cache }
}
/// Text'i ölç. Dönen değerler pt cinsinden (width, height).
/// `available_width_pt`: Mevcut genişlik kısıtı (pt). None ise sınırsız.
/// Sonuç cache'lenir — aynı parametrelerle tekrar çağrılırsa cache'ten döner.
pub fn measure(
&mut self,
text: &str,
font_family: Option<&str>,
font_size_pt: f32,
font_weight: Option<&str>,
available_width_pt: Option<f32>,
) -> (f32, f32) {
if text.is_empty() {
return (0.0, font_size_pt * 1.2);
}
let key = MeasureCacheKey::new(text, font_family, font_size_pt, font_weight, available_width_pt);
if let Some(&cached) = self.cache.get(&key) {
return cached;
}
let result = self.measure_uncached(text, font_family, font_size_pt, font_weight, available_width_pt);
self.cache.insert(key, result);
result
}
/// Cache'siz ölçüm — cosmic-text ile gerçek hesaplama.
fn measure_uncached(
&mut self,
text: &str,
font_family: Option<&str>,
font_size_pt: f32,
font_weight: Option<&str>,
available_width_pt: Option<f32>,
) -> (f32, f32) {
let font_size_px = font_size_pt * PT_TO_PX;
let line_height_px = font_size_px * 1.2;
let metrics = Metrics::new(font_size_px, line_height_px);
let mut buffer = Buffer::new(&mut self.font_system, metrics);
let width_px = available_width_pt.map(|w| w * PT_TO_PX);
buffer.set_size(&mut self.font_system, width_px, None);
let weight = match font_weight {
Some("bold") => Weight::BOLD,
_ => Weight::NORMAL,
};
let family_name = font_family.unwrap_or("Noto Sans");
let attrs = Attrs::new()
.family(Family::Name(family_name))
.weight(weight);
buffer.set_text(&mut self.font_system, text, attrs, Shaping::Advanced);
buffer.shape_until_scroll(&mut self.font_system, false);
let mut max_width: f32 = 0.0;
let mut total_height: f32 = 0.0;
for run in buffer.layout_runs() {
let run_width = run.line_w;
if run_width > max_width {
max_width = run_width;
}
total_height = run.line_top + line_height_px;
}
if total_height == 0.0 {
total_height = line_height_px;
}
let width_pt = max_width / PT_TO_PX;
let height_pt = total_height / PT_TO_PX;
// Text genişliğine küçük bir tolerans ekle (0.5pt ≈ 0.18mm).
// cosmic-text ile browser font engine'i farklı subpixel sonuçlar üretir;
// bu fark zoom değişimlerinde text wrap sınırında flickering'e yol açar.
// 0.5pt baskıda görünmez ama wrapping dengesizliğini önler.
let width_pt = width_pt + 0.5;
(width_pt, height_pt)
}
}
#[cfg(test)]
pub(crate) fn load_test_fonts() -> Vec<crate::FontData> {
let font_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.join("backend/fonts");
let mut fonts = Vec::new();
for entry in std::fs::read_dir(&font_dir).expect("backend/fonts dizini bulunamadı") {
let entry = entry.unwrap();
let path = entry.path();
if path.extension().is_some_and(|e| e == "ttf") {
let data = std::fs::read(&path).unwrap();
let family = if path
.file_name()
.unwrap()
.to_str()
.unwrap()
.contains("Mono")
{
"Noto Sans Mono".to_string()
} else {
"Noto Sans".to_string()
};
fonts.push(crate::FontData { family, data });
}
}
fonts
}
#[cfg(test)]
mod tests {
use super::*;
fn make_measurer() -> TextMeasurer {
TextMeasurer::new(&load_test_fonts())
}
#[test]
fn test_empty_text() {
let mut m = make_measurer();
let (w, h) = m.measure("", None, 12.0, None, None);
assert_eq!(w, 0.0);
assert!(h > 0.0);
}
#[test]
fn test_basic_measurement() {
let mut m = make_measurer();
let (w, h) = m.measure("Hello", None, 12.0, None, None);
assert!(w > 0.0, "Width should be positive, got {w}");
assert!(h > 0.0, "Height should be positive, got {h}");
}
#[test]
fn test_cache_returns_same_result() {
let mut m = make_measurer();
let (w1, h1) = m.measure("Cache test", None, 14.0, Some("bold"), Some(100.0));
let (w2, h2) = m.measure("Cache test", None, 14.0, Some("bold"), Some(100.0));
assert_eq!(w1, w2);
assert_eq!(h1, h2);
// Cache'te 1 entry olmalı (aynı key iki kere çağrıldı)
assert_eq!(m.cache.len(), 1);
}
#[test]
fn test_cache_persists_across_measurers() {
let fonts = load_test_fonts();
let mut m1 = TextMeasurer::new(&fonts);
let (w1, h1) = m1.measure("Persist test", None, 12.0, None, None);
let cache = m1.take_cache();
let mut m2 = TextMeasurer::new_with_cache(&fonts, cache);
assert_eq!(m2.cache.len(), 1);
let (w2, h2) = m2.measure("Persist test", None, 12.0, None, None);
assert_eq!(w1, w2);
assert_eq!(h1, h2);
}
#[test]
fn test_wrapping_reduces_width() {
let mut m = make_measurer();
// Sınırsız genişlikte ölç
let (w_unlimited, h_unlimited) =
m.measure("This is a longer text that should wrap", None, 12.0, None, None);
// Dar genişlikte ölç
let (w_narrow, h_narrow) =
m.measure("This is a longer text that should wrap", None, 12.0, None, Some(50.0));
// Dar genişlikte yükseklik artmalı (wrapping oldu)
assert!(
h_narrow >= h_unlimited,
"Wrapped height ({h_narrow}) should be >= unlimited height ({h_unlimited})"
);
// Dar genişlikte genişlik kısıtlanmış olmalı
assert!(
w_narrow <= w_unlimited + 1.0,
"Wrapped width ({w_narrow}) should be <= unlimited width ({w_unlimited})"
);
}
}

1027
layout-engine/src/tree.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,138 @@
use std::sync::{Mutex, OnceLock};
use std::collections::HashMap;
use wasm_bindgen::prelude::*;
use crate::FontData;
use crate::text_measure::TextMeasureCache;
/// Font verileri worker'da cache'lenir.
static FONTS: OnceLock<Vec<FontData>> = OnceLock::new();
/// Text ölçüm cache'i — layout call'ları arasında persist eder.
/// Aynı text + font + size + weight + available_width → aynı sonuç.
static TEXT_CACHE: OnceLock<Mutex<TextMeasureCache>> = OnceLock::new();
/// Barcode pixel cache — (format, value, width, height, include_text) → RGBA bytes (header dahil).
static BARCODE_CACHE: OnceLock<Mutex<HashMap<BarcodeCacheKey, Vec<u8>>>> = OnceLock::new();
#[derive(Clone, Eq, PartialEq, Hash)]
struct BarcodeCacheKey {
format: String,
value: String,
width: u32,
height: u32,
include_text: bool,
}
/// Font verilerini yükle (worker init sırasında bir kere çağrılır).
/// `families`: JSON array of font family names — ["Noto Sans", "Noto Sans", ...]
/// `buffers`: Her font dosyasının raw bytes'ı (sırayla)
#[wasm_bindgen(js_name = "loadFonts")]
pub fn load_fonts(families: &str, buffers: Vec<js_sys::Uint8Array>) -> Result<(), JsValue> {
let families: Vec<String> =
serde_json::from_str(families).map_err(|e| JsValue::from_str(&e.to_string()))?;
if families.len() != buffers.len() {
return Err(JsValue::from_str("families and buffers length mismatch"));
}
let fonts: Vec<FontData> = families
.into_iter()
.zip(buffers.into_iter())
.map(|(family, buf)| FontData {
family,
data: buf.to_vec(),
})
.collect();
FONTS
.set(fonts)
.map_err(|_| JsValue::from_str("Fonts already loaded"))?;
Ok(())
}
/// Layout hesapla.
/// `template_json`: Template JSON string
/// `data_json`: Data JSON string
/// Dönen değer: LayoutResult JSON string
///
/// Text ölçüm sonuçları cross-call cache'lenir — değişmeyen text elemanları
/// cosmic-text'e gitmeden cache'ten döner.
#[wasm_bindgen(js_name = "computeLayout")]
pub fn compute_layout_wasm(template_json: &str, data_json: &str) -> Result<String, JsValue> {
let template: dreport_core::models::Template =
serde_json::from_str(template_json).map_err(|e| JsValue::from_str(&e.to_string()))?;
let data: serde_json::Value =
serde_json::from_str(data_json).map_err(|e| JsValue::from_str(&e.to_string()))?;
let fonts = FONTS
.get()
.ok_or_else(|| JsValue::from_str("Fonts not loaded. Call loadFonts() first."))?;
// Text cache'i al (veya ilk kullanımda oluştur)
let cache_mutex = TEXT_CACHE.get_or_init(|| Mutex::new(TextMeasureCache::default()));
let text_cache = cache_mutex.lock().unwrap().take();
let (result, new_cache) = crate::compute_layout_cached(&template, &data, fonts, text_cache);
// Güncel cache'i geri koy
*cache_mutex.lock().unwrap() = new_cache;
serde_json::to_string(&result).map_err(|e| JsValue::from_str(&e.to_string()))
}
/// Barcode üret → ham RGBA pixel verisi (header: 8 byte width+height LE, sonra RGBA).
/// Sonuç cache'lenir — aynı parametrelerle tekrar çağrılırsa cache'ten döner.
#[wasm_bindgen(js_name = "generateBarcode")]
pub fn generate_barcode_wasm(format: &str, value: &str, width: u32, height: u32, include_text: bool) -> Result<js_sys::Uint8ClampedArray, JsValue> {
let cache_key = BarcodeCacheKey {
format: format.to_string(),
value: value.to_string(),
width,
height,
include_text,
};
let cache_mutex = BARCODE_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
// Cache hit?
{
let cache = cache_mutex.lock().unwrap();
if let Some(cached_data) = cache.get(&cache_key) {
let arr = js_sys::Uint8ClampedArray::new_with_length(cached_data.len() as u32);
arr.copy_from(cached_data);
return Ok(arr);
}
}
// Cache miss — üret
let fonts = FONTS.get().map(|f| f.as_slice());
let result = crate::barcode_gen::generate_barcode_pixels(format, value, width, height, include_text, fonts)
.map_err(|e| JsValue::from_str(&e))?;
// Grayscale → RGBA (canvas ImageData formatı)
let mut rgba = Vec::with_capacity((result.width * result.height * 4) as usize);
for &gray in &result.pixels {
rgba.push(gray); // R
rgba.push(gray); // G
rgba.push(gray); // B
rgba.push(255); // A
}
// Header (8 byte: width LE + height LE) + RGBA pixel verisi
let mut data = Vec::with_capacity(8 + rgba.len());
data.extend_from_slice(&result.width.to_le_bytes());
data.extend_from_slice(&result.height.to_le_bytes());
data.extend_from_slice(&rgba);
let arr = js_sys::Uint8ClampedArray::new_with_length(data.len() as u32);
arr.copy_from(&data);
// Cache'e kaydet
cache_mutex.lock().unwrap().insert(cache_key, data);
Ok(arr)
}