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
+339 -389
View File
File diff suppressed because it is too large Load Diff
Generated
+278 -2444
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,3 +1,3 @@
[workspace]
members = ["core", "backend"]
members = ["core", "backend", "layout-engine"]
resolver = "2"
+1 -4
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"
+31 -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
}
+2 -2
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))
}
+2 -2
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())
+8 -13
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))
}
-117
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)
}
-17
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")
}
-4
View File
@@ -1,4 +0,0 @@
pub mod compiler;
pub mod fonts;
pub use dreport_core::template_to_typst;
+35 -3
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 {
+79 -13
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,13 +92,74 @@ 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) {
@@ -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;
@@ -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>
@@ -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>
@@ -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>
+137
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,
}
}
+65
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
}
+1
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 ---
+17 -2
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 {
+46 -4
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,
+42
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 {
+88
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 })
}
}
}
+16 -3
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
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 = []
+286
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']));
}
}
+161
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
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>,
}
+680
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
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));
}
}
+191
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,
}
}
+277
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})"
);
}
}
File diff suppressed because it is too large Load Diff
+138
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)
}