mirror of
https://github.com/duhanbalci/dreport.git
synced 2026-07-01 18:39:16 +00:00
fix bugs
This commit is contained in:
2722
Cargo.lock
generated
2722
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,3 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
members = ["core", "backend"]
|
members = ["core", "backend", "layout-engine"]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ edition = "2024"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
dreport-core = { path = "../core" }
|
dreport-core = { path = "../core" }
|
||||||
|
dreport-layout = { path = "../layout-engine" }
|
||||||
axum = "0.8"
|
axum = "0.8"
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
@@ -12,7 +13,3 @@ serde_json = "1"
|
|||||||
tower-http = { version = "0.6", features = ["cors"] }
|
tower-http = { version = "0.6", features = ["cors"] }
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
typst = "0.14"
|
|
||||||
typst-pdf = "0.14"
|
|
||||||
typst-kit = { version = "0.14", features = ["fonts"] }
|
|
||||||
chrono = "0.4"
|
|
||||||
|
|||||||
@@ -1,18 +1,17 @@
|
|||||||
use axum::{Router, serve};
|
use axum::{Router, serve};
|
||||||
|
use dreport_layout::FontData;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use tower_http::cors::{Any, CorsLayer};
|
use tower_http::cors::{Any, CorsLayer};
|
||||||
|
|
||||||
mod models;
|
mod models;
|
||||||
mod routes;
|
mod routes;
|
||||||
mod typst_engine;
|
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> anyhow::Result<()> {
|
async fn main() -> anyhow::Result<()> {
|
||||||
// Fontları bir kez yükle — tüm request'lerde paylaşılacak
|
|
||||||
println!("Fontlar yukleniyor...");
|
println!("Fontlar yukleniyor...");
|
||||||
let fonts = Arc::new(typst_engine::fonts::load_fonts());
|
let fonts = Arc::new(load_fonts());
|
||||||
println!("Fontlar yuklendi ({} font)", fonts.fonts.len());
|
println!("Fontlar yuklendi ({} font dosyasi)", fonts.len());
|
||||||
|
|
||||||
let cors = CorsLayer::new()
|
let cors = CorsLayer::new()
|
||||||
.allow_origin(Any)
|
.allow_origin(Any)
|
||||||
@@ -30,3 +29,31 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
Ok(())
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use axum::{Router, routing::get, Json};
|
use axum::{Router, routing::get, Json};
|
||||||
|
use dreport_layout::FontData;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use typst_kit::fonts::Fonts;
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct HealthResponse {
|
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))
|
Router::new().route("/api/health", get(health))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ mod health;
|
|||||||
mod render;
|
mod render;
|
||||||
|
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
|
use dreport_layout::FontData;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use typst_kit::fonts::Fonts;
|
|
||||||
|
|
||||||
pub fn router() -> Router<Arc<Fonts>> {
|
pub fn router() -> Router<Arc<Vec<FontData>>> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.merge(health::router())
|
.merge(health::router())
|
||||||
.merge(render::router())
|
.merge(render::router())
|
||||||
|
|||||||
@@ -6,13 +6,11 @@ use axum::{
|
|||||||
routing::post,
|
routing::post,
|
||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
|
use dreport_layout::FontData;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::models::Template;
|
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)]
|
#[derive(Deserialize)]
|
||||||
pub struct RenderRequest {
|
pub struct RenderRequest {
|
||||||
@@ -22,17 +20,14 @@ pub struct RenderRequest {
|
|||||||
|
|
||||||
/// POST /api/render — Template + Data → PDF
|
/// POST /api/render — Template + Data → PDF
|
||||||
pub async fn render(
|
pub async fn render(
|
||||||
State(fonts): State<Arc<Fonts>>,
|
State(fonts): State<Arc<Vec<FontData>>>,
|
||||||
Json(payload): Json<RenderRequest>,
|
Json(payload): Json<RenderRequest>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
// 1. Template JSON → Typst markup
|
// 1. Layout hesapla
|
||||||
let typst_markup = template_to_typst::template_to_typst(&payload.template, &payload.data, RenderMode::Pdf);
|
let layout = dreport_layout::compute_layout(&payload.template, &payload.data, &fonts);
|
||||||
|
|
||||||
// 2. Base64 image'ları çıkar
|
// 2. PDF render
|
||||||
let files = template_to_typst::extract_image_files(&payload.template);
|
match dreport_layout::pdf_render::render_pdf(&layout, &fonts) {
|
||||||
|
|
||||||
// 3. Typst markup → PDF
|
|
||||||
match compile_pdf(typst_markup, &fonts, files) {
|
|
||||||
Ok(pdf_bytes) => (
|
Ok(pdf_bytes) => (
|
||||||
StatusCode::OK,
|
StatusCode::OK,
|
||||||
[(header::CONTENT_TYPE, "application/pdf")],
|
[(header::CONTENT_TYPE, "application/pdf")],
|
||||||
@@ -41,12 +36,12 @@ pub async fn render(
|
|||||||
.into_response(),
|
.into_response(),
|
||||||
Err(err) => (
|
Err(err) => (
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
format!("PDF derleme hatasi: {}", err),
|
format!("PDF render hatasi: {}", err),
|
||||||
)
|
)
|
||||||
.into_response(),
|
.into_response(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn router() -> Router<Arc<Fonts>> {
|
pub fn router() -> Router<Arc<Vec<FontData>>> {
|
||||||
Router::new().route("/api/render", post(render))
|
Router::new().route("/api/render", post(render))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
pub mod compiler;
|
|
||||||
pub mod fonts;
|
|
||||||
|
|
||||||
pub use dreport_core::template_to_typst;
|
|
||||||
@@ -14,7 +14,7 @@ pub enum SizeValue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase", default)]
|
||||||
pub struct SizeConstraint {
|
pub struct SizeConstraint {
|
||||||
pub width: SizeValue,
|
pub width: SizeValue,
|
||||||
pub height: SizeValue,
|
pub height: SizeValue,
|
||||||
@@ -24,25 +24,43 @@ pub struct SizeConstraint {
|
|||||||
pub max_height: Option<f64>,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct PageSettings {
|
pub struct PageSettings {
|
||||||
pub width: f64,
|
pub width: f64,
|
||||||
pub height: f64,
|
pub height: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
pub struct Padding {
|
pub struct Padding {
|
||||||
|
#[serde(default)]
|
||||||
pub top: f64,
|
pub top: f64,
|
||||||
|
#[serde(default)]
|
||||||
pub right: f64,
|
pub right: f64,
|
||||||
|
#[serde(default)]
|
||||||
pub bottom: f64,
|
pub bottom: f64,
|
||||||
|
#[serde(default)]
|
||||||
pub left: f64,
|
pub left: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Positioning ---
|
// --- Positioning ---
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
#[serde(tag = "type")]
|
#[serde(tag = "type")]
|
||||||
pub enum PositionMode {
|
pub enum PositionMode {
|
||||||
|
#[default]
|
||||||
#[serde(rename = "flow")]
|
#[serde(rename = "flow")]
|
||||||
Flow,
|
Flow,
|
||||||
#[serde(rename = "absolute")]
|
#[serde(rename = "absolute")]
|
||||||
@@ -124,6 +142,7 @@ pub struct TableStyle {
|
|||||||
#[serde(rename_all = "camelCase", default)]
|
#[serde(rename_all = "camelCase", default)]
|
||||||
pub struct BarcodeStyle {
|
pub struct BarcodeStyle {
|
||||||
pub color: Option<String>,
|
pub color: Option<String>,
|
||||||
|
pub include_text: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Element tipleri ---
|
// --- Element tipleri ---
|
||||||
@@ -200,17 +219,30 @@ impl TemplateElement {
|
|||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct ContainerElement {
|
pub struct ContainerElement {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
#[serde(default)]
|
||||||
pub position: PositionMode,
|
pub position: PositionMode,
|
||||||
|
#[serde(default)]
|
||||||
pub size: SizeConstraint,
|
pub size: SizeConstraint,
|
||||||
|
#[serde(default = "default_column")]
|
||||||
pub direction: String,
|
pub direction: String,
|
||||||
|
#[serde(default)]
|
||||||
pub gap: f64,
|
pub gap: f64,
|
||||||
|
#[serde(default)]
|
||||||
pub padding: Padding,
|
pub padding: Padding,
|
||||||
|
#[serde(default = "default_stretch")]
|
||||||
pub align: String,
|
pub align: String,
|
||||||
|
#[serde(default = "default_start")]
|
||||||
pub justify: String,
|
pub justify: String,
|
||||||
|
#[serde(default)]
|
||||||
pub style: ContainerStyle,
|
pub style: ContainerStyle,
|
||||||
|
#[serde(default)]
|
||||||
pub children: Vec<TemplateElement>,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct StaticTextElement {
|
pub struct StaticTextElement {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<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 { storeToRefs } from 'pinia'
|
||||||
import { useTemplateStore } from '../../stores/template'
|
import { useTemplateStore } from '../../stores/template'
|
||||||
import { useEditorStore } from '../../stores/editor'
|
import { useEditorStore } from '../../stores/editor'
|
||||||
import { useTypstCompiler } from '../../composables/useTypstCompiler'
|
import { useLayoutEngine } from '../../composables/useLayoutEngine'
|
||||||
import TypstSvgLayer from './TypstSvgLayer.vue'
|
import LayoutRenderer from './LayoutRenderer.vue'
|
||||||
import InteractionOverlay from './InteractionOverlay.vue'
|
import InteractionOverlay from './InteractionOverlay.vue'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
@@ -15,7 +15,7 @@ const props = withDefaults(defineProps<{
|
|||||||
|
|
||||||
const templateStore = useTemplateStore()
|
const templateStore = useTemplateStore()
|
||||||
const editorStore = useEditorStore()
|
const editorStore = useEditorStore()
|
||||||
const { template, mockData } = storeToRefs(templateStore)
|
const { template, mockData, layoutVersion } = storeToRefs(templateStore)
|
||||||
|
|
||||||
const containerRef = ref<HTMLElement | null>(null)
|
const containerRef = ref<HTMLElement | null>(null)
|
||||||
const containerWidth = ref(800)
|
const containerWidth = ref(800)
|
||||||
@@ -24,8 +24,11 @@ const emit = defineEmits<{
|
|||||||
'compile-error': [error: string | null]
|
'compile-error': [error: string | null]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// Typst compiler — template + data'yı worker'a gönderir, WASM ile derlenir
|
// Layout engine — template + data'yı worker'a gönderir, WASM ile layout hesaplar
|
||||||
const { svg, error, compiling, layout, dispose } = useTypstCompiler(template, mockData)
|
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))
|
watch(error, (val) => emit('compile-error', val))
|
||||||
|
|
||||||
@@ -89,15 +92,76 @@ onBeforeUnmount(() => {
|
|||||||
window.removeEventListener('keyup', onKeyUp)
|
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) {
|
function onWheel(e: WheelEvent) {
|
||||||
if (e.ctrlKey || e.metaKey) {
|
if (e.ctrlKey || e.metaKey) {
|
||||||
e.preventDefault()
|
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) {
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
if (e.code === 'Space' && !e.repeat && !(e.target instanceof HTMLInputElement || e.target instanceof HTMLSelectElement || e.target instanceof HTMLTextAreaElement)) {
|
if (e.code === 'Space' && !e.repeat && !(e.target instanceof HTMLInputElement || e.target instanceof HTMLSelectElement || e.target instanceof HTMLTextAreaElement)) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -146,9 +210,9 @@ function onPointerUp(e: PointerEvent) {
|
|||||||
@pointerup="onPointerUp"
|
@pointerup="onPointerUp"
|
||||||
>
|
>
|
||||||
<!-- Sayfa -->
|
<!-- Sayfa -->
|
||||||
<div class="editor-canvas__page" :style="[pageStyle, panTransform ? { transform: panTransform } : {}]">
|
<div ref="pageRef" class="editor-canvas__page" :style="[pageStyle, panTransform ? { transform: panTransform } : {}]">
|
||||||
<TypstSvgLayer :svg="svg" />
|
<LayoutRenderer :layout="layout" :scale="scale" />
|
||||||
<InteractionOverlay :scale="scale" :layout="layout" :page-width-pt="templateStore.template.page.width * 2.8346" />
|
<InteractionOverlay :scale="scale" :layout-map="layoutMap" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -170,12 +234,14 @@ function onPointerUp(e: PointerEvent) {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
position: relative;
|
position: relative;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-canvas {
|
.editor-canvas {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: auto;
|
overflow: hidden;
|
||||||
background: #e5e7eb;
|
background: #e5e7eb;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
|||||||
@@ -2,25 +2,18 @@
|
|||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useTemplateStore } from '../../stores/template'
|
import { useTemplateStore } from '../../stores/template'
|
||||||
import { useEditorStore } from '../../stores/editor'
|
import { useEditorStore } from '../../stores/editor'
|
||||||
import type { ElementLayout } from '../../core/template-to-typst'
|
import type { ElementLayout } from '../../core/layout-types'
|
||||||
import type { TemplateElement, SizeValue, ContainerElement } from '../../core/types'
|
import type { TemplateElement, SizeValue, ContainerElement } from '../../core/types'
|
||||||
import { isContainer, sz } from '../../core/types'
|
import { isContainer, sz } from '../../core/types'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
scale: number
|
scale: number
|
||||||
layout: Record<string, ElementLayout>
|
layoutMap: Record<string, ElementLayout>
|
||||||
pageWidthPt: number
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const templateStore = useTemplateStore()
|
const templateStore = useTemplateStore()
|
||||||
const editorStore = useEditorStore()
|
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ç)
|
// Tüm elemanları flat olarak topla (root hariç)
|
||||||
const flatElements = computed(() => {
|
const flatElements = computed(() => {
|
||||||
const result: TemplateElement[] = []
|
const result: TemplateElement[] = []
|
||||||
@@ -50,20 +43,20 @@ const allContainers = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function getElementStyle(el: TemplateElement) {
|
function getElementStyle(el: TemplateElement) {
|
||||||
const l = props.layout[el.id]
|
const l = props.layoutMap[el.id]
|
||||||
if (!l) return { display: 'none' }
|
if (!l) return { display: 'none' }
|
||||||
|
|
||||||
const s = ptToPx.value
|
const s = props.scale
|
||||||
const h = l.height * s
|
const h = l.height_mm * s
|
||||||
const minH = 8
|
const minH = 8
|
||||||
const actualH = Math.max(h, minH)
|
const actualH = Math.max(h, minH)
|
||||||
const yOffset = h < minH ? (minH - h) / 2 : 0
|
const yOffset = h < minH ? (minH - h) / 2 : 0
|
||||||
|
|
||||||
return {
|
return {
|
||||||
position: 'absolute' as const,
|
position: 'absolute' as const,
|
||||||
left: `${l.x * s}px`,
|
left: `${l.x_mm * s}px`,
|
||||||
top: `${l.y * s - yOffset}px`,
|
top: `${l.y_mm * s - yOffset}px`,
|
||||||
width: `${l.width * s}px`,
|
width: `${l.width_mm * s}px`,
|
||||||
height: `${actualH}px`,
|
height: `${actualH}px`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -90,23 +83,23 @@ const dropLogicalIndex = ref<number | null>(null)
|
|||||||
|
|
||||||
/** Mouse pozisyonuna göre en derin container'ı bul */
|
/** Mouse pozisyonuna göre en derin container'ı bul */
|
||||||
function findDeepestContainer(mouseX: number, mouseY: number, excludeId?: string): ContainerElement {
|
function findDeepestContainer(mouseX: number, mouseY: number, excludeId?: string): ContainerElement {
|
||||||
const s = ptToPx.value
|
const s = props.scale
|
||||||
let best: ContainerElement = templateStore.template.root
|
let best: ContainerElement = templateStore.template.root
|
||||||
|
|
||||||
for (const c of allContainers.value) {
|
for (const c of allContainers.value) {
|
||||||
if (c.id === excludeId) continue
|
if (c.id === excludeId) continue
|
||||||
const l = props.layout[c.id]
|
const l = props.layoutMap[c.id]
|
||||||
if (!l) continue
|
if (!l) continue
|
||||||
|
|
||||||
const cx = l.x * s
|
const cx = l.x_mm * s
|
||||||
const cy = l.y * s
|
const cy = l.y_mm * s
|
||||||
const cw = l.width * s
|
const cw = l.width_mm * s
|
||||||
const ch = l.height * s
|
const ch = l.height_mm * s
|
||||||
|
|
||||||
if (mouseX >= cx && mouseX <= cx + cw && mouseY >= cy && mouseY <= cy + ch) {
|
if (mouseX >= cx && mouseX <= cx + cw && mouseY >= cy && mouseY <= cy + ch) {
|
||||||
// Daha küçük (daha derin) container'ı tercih et
|
// Daha küçük (daha derin) container'ı tercih et
|
||||||
const bestL = props.layout[best.id]
|
const bestL = props.layoutMap[best.id]
|
||||||
if (!bestL || (cw * ch < bestL.width * s * bestL.height * s)) {
|
if (!bestL || (cw * ch < bestL.width_mm * s * bestL.height_mm * s)) {
|
||||||
best = c
|
best = c
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -116,20 +109,20 @@ function findDeepestContainer(mouseX: number, mouseY: number, excludeId?: string
|
|||||||
|
|
||||||
/** Container içinde drop index hesapla */
|
/** Container içinde drop index hesapla */
|
||||||
function computeDropIndex(container: ContainerElement, mouseX: number, mouseY: number, excludeId?: string) {
|
function computeDropIndex(container: ContainerElement, mouseX: number, mouseY: number, excludeId?: string) {
|
||||||
const s = ptToPx.value
|
const s = props.scale
|
||||||
const flowChildren = container.children.filter(c => c.position.type !== 'absolute' && c.id !== excludeId)
|
const flowChildren = container.children.filter(c => c.position.type !== 'absolute' && c.id !== excludeId)
|
||||||
const isRow = container.direction === 'row'
|
const isRow = container.direction === 'row'
|
||||||
|
|
||||||
let visualIdx = flowChildren.length
|
let visualIdx = flowChildren.length
|
||||||
|
|
||||||
for (let i = 0; i < flowChildren.length; i++) {
|
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 (!l) continue
|
||||||
if (isRow) {
|
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 }
|
if (mouseX < centerX) { visualIdx = i; break }
|
||||||
} else {
|
} 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 }
|
if (mouseY < centerY) { visualIdx = i; break }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -184,7 +177,7 @@ const dropIndicatorStyle = computed(() => {
|
|||||||
const container = templateStore.getElementById(dropTargetContainerId.value)
|
const container = templateStore.getElementById(dropTargetContainerId.value)
|
||||||
if (!container || !isContainer(container)) return { display: 'none' }
|
if (!container || !isContainer(container)) return { display: 'none' }
|
||||||
|
|
||||||
const s = ptToPx.value
|
const s = props.scale
|
||||||
const idx = dropVisualIndex.value
|
const idx = dropVisualIndex.value
|
||||||
const isRow = container.direction === 'row'
|
const isRow = container.direction === 'row'
|
||||||
|
|
||||||
@@ -192,37 +185,37 @@ const dropIndicatorStyle = computed(() => {
|
|||||||
const dragId = dragElementId.value
|
const dragId = dragElementId.value
|
||||||
const flowChildren = container.children.filter(c => c.position.type !== 'absolute' && c.id !== dragId)
|
const flowChildren = container.children.filter(c => c.position.type !== 'absolute' && c.id !== dragId)
|
||||||
|
|
||||||
const cl = props.layout[container.id]
|
const cl = props.layoutMap[container.id]
|
||||||
if (!cl) return { display: 'none' }
|
if (!cl) return { display: 'none' }
|
||||||
|
|
||||||
if (isRow) {
|
if (isRow) {
|
||||||
// Row container: dikey gösterge çizgisi
|
// Row container: dikey gösterge çizgisi
|
||||||
let x = 0
|
let x = 0
|
||||||
if (idx === 0 && flowChildren.length > 0) {
|
if (idx === 0 && flowChildren.length > 0) {
|
||||||
const l = props.layout[flowChildren[0].id]
|
const l = props.layoutMap[flowChildren[0].id]
|
||||||
if (l) x = (cl.x * s + l.x * s) / 2
|
if (l) x = (cl.x_mm * s + l.x_mm * s) / 2
|
||||||
else x = cl.x * s
|
else x = cl.x_mm * s
|
||||||
} else if (idx < flowChildren.length && idx > 0) {
|
} else if (idx < flowChildren.length && idx > 0) {
|
||||||
const left = props.layout[flowChildren[idx - 1].id]
|
const left = props.layoutMap[flowChildren[idx - 1].id]
|
||||||
const right = props.layout[flowChildren[idx].id]
|
const right = props.layoutMap[flowChildren[idx].id]
|
||||||
if (left && right) {
|
if (left && right) {
|
||||||
const leftEnd = (left.x + left.width) * s
|
const leftEnd = (left.x_mm + left.width_mm) * s
|
||||||
const rightStart = right.x * s
|
const rightStart = right.x_mm * s
|
||||||
x = (leftEnd + rightStart) / 2
|
x = (leftEnd + rightStart) / 2
|
||||||
}
|
}
|
||||||
} else if (idx === 0 && flowChildren.length === 0) {
|
} else if (idx === 0 && flowChildren.length === 0) {
|
||||||
x = cl.x * s + 8
|
x = cl.x_mm * s + 8
|
||||||
} else if (flowChildren.length > 0) {
|
} else if (flowChildren.length > 0) {
|
||||||
const last = flowChildren[flowChildren.length - 1]
|
const last = flowChildren[flowChildren.length - 1]
|
||||||
const l = props.layout[last.id]
|
const l = props.layoutMap[last.id]
|
||||||
if (l) {
|
if (l) {
|
||||||
const gapPx = container.gap * props.scale
|
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 top = cl.y_mm * s
|
||||||
const height = cl.height * s
|
const height = cl.height_mm * s
|
||||||
|
|
||||||
return {
|
return {
|
||||||
position: 'absolute' as const,
|
position: 'absolute' as const,
|
||||||
@@ -240,33 +233,33 @@ const dropIndicatorStyle = computed(() => {
|
|||||||
// Column container: yatay gösterge çizgisi
|
// Column container: yatay gösterge çizgisi
|
||||||
let y = 0
|
let y = 0
|
||||||
if (idx === 0 && flowChildren.length > 0) {
|
if (idx === 0 && flowChildren.length > 0) {
|
||||||
const l = props.layout[flowChildren[0].id]
|
const l = props.layoutMap[flowChildren[0].id]
|
||||||
if (l) {
|
if (l) {
|
||||||
y = (cl.y * s + l.y * s) / 2
|
y = (cl.y_mm * s + l.y_mm * s) / 2
|
||||||
} else {
|
} else {
|
||||||
y = cl.y * s - 4
|
y = cl.y_mm * s - 4
|
||||||
}
|
}
|
||||||
} else if (idx < flowChildren.length && idx > 0) {
|
} else if (idx < flowChildren.length && idx > 0) {
|
||||||
const above = props.layout[flowChildren[idx - 1].id]
|
const above = props.layoutMap[flowChildren[idx - 1].id]
|
||||||
const below = props.layout[flowChildren[idx].id]
|
const below = props.layoutMap[flowChildren[idx].id]
|
||||||
if (above && below) {
|
if (above && below) {
|
||||||
const aboveBottom = (above.y + above.height) * s
|
const aboveBottom = (above.y_mm + above.height_mm) * s
|
||||||
const belowTop = below.y * s
|
const belowTop = below.y_mm * s
|
||||||
y = (aboveBottom + belowTop) / 2
|
y = (aboveBottom + belowTop) / 2
|
||||||
}
|
}
|
||||||
} else if (idx === 0 && flowChildren.length === 0) {
|
} else if (idx === 0 && flowChildren.length === 0) {
|
||||||
y = cl.y * s + 8
|
y = cl.y_mm * s + 8
|
||||||
} else if (flowChildren.length > 0) {
|
} else if (flowChildren.length > 0) {
|
||||||
const last = flowChildren[flowChildren.length - 1]
|
const last = flowChildren[flowChildren.length - 1]
|
||||||
const l = props.layout[last.id]
|
const l = props.layoutMap[last.id]
|
||||||
if (l) {
|
if (l) {
|
||||||
const gapPx = container.gap * props.scale
|
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 x = cl.x_mm * s
|
||||||
const width = cl.width * s
|
const width = cl.width_mm * s
|
||||||
|
|
||||||
return {
|
return {
|
||||||
position: 'absolute' as const,
|
position: 'absolute' as const,
|
||||||
@@ -297,20 +290,20 @@ function onDragStart(e: PointerEvent, el: TemplateElement) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const l = props.layout[el.id]
|
const l = props.layoutMap[el.id]
|
||||||
if (!l) return
|
if (!l) return
|
||||||
|
|
||||||
const s = ptToPx.value
|
const s = props.scale
|
||||||
dragElementId.value = el.id
|
dragElementId.value = el.id
|
||||||
didDrag.value = false
|
didDrag.value = false
|
||||||
|
|
||||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
||||||
dragOffset.value = { x: e.clientX - rect.left, y: e.clientY - rect.top }
|
dragOffset.value = { x: e.clientX - rect.left, y: e.clientY - rect.top }
|
||||||
dragGhost.value = {
|
dragGhost.value = {
|
||||||
x: l.x * s,
|
x: l.x_mm * s,
|
||||||
y: l.y * s,
|
y: l.y_mm * s,
|
||||||
width: l.width * s,
|
width: l.width_mm * s,
|
||||||
height: l.height * s,
|
height: l.height_mm * s,
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('pointermove', onDragMove)
|
window.addEventListener('pointermove', onDragMove)
|
||||||
@@ -440,27 +433,26 @@ function onResizeStart(e: PointerEvent, elId: string, handle: string) {
|
|||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
const l = props.layout[elId]
|
const l = props.layoutMap[elId]
|
||||||
if (!l) return
|
if (!l) return
|
||||||
|
|
||||||
resizeElementId.value = elId
|
resizeElementId.value = elId
|
||||||
resizeHandle.value = handle
|
resizeHandle.value = handle
|
||||||
isResizing.value = true
|
isResizing.value = true
|
||||||
|
|
||||||
const s = ptToPx.value
|
const s = props.scale
|
||||||
const ptToMm = 1 / 2.8346
|
|
||||||
|
|
||||||
// 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)
|
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 = {
|
resizeStart.value = {
|
||||||
mouseX: e.clientX, mouseY: e.clientY,
|
mouseX: e.clientX, mouseY: e.clientY,
|
||||||
x: l.x * s, y: l.y * s,
|
x: l.x_mm * s, y: l.y_mm * s,
|
||||||
width: l.width * s, height: l.height * 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 }
|
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 * ptToMm, height: l.height * ptToMm }
|
resizeFinalMm.value = { width: l.width_mm, height: l.height_mm }
|
||||||
|
|
||||||
window.addEventListener('pointermove', onResizeMove)
|
window.addEventListener('pointermove', onResizeMove)
|
||||||
window.addEventListener('pointerup', onResizeEnd)
|
window.addEventListener('pointerup', onResizeEnd)
|
||||||
@@ -511,9 +503,15 @@ function onResizeEnd() {
|
|||||||
|
|
||||||
if (resizeElementId.value) {
|
if (resizeElementId.value) {
|
||||||
const handle = resizeHandle.value
|
const handle = resizeHandle.value
|
||||||
|
const ar = resizeAspectRatio.value
|
||||||
const sizeUpdate: { width?: SizeValue; height?: SizeValue } = {}
|
const sizeUpdate: { width?: SizeValue; height?: SizeValue } = {}
|
||||||
if (handle.includes('e') || handle.includes('w')) sizeUpdate.width = sz.fixed(resizeFinalMm.value.width)
|
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)
|
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)
|
templateStore.updateElementSize(resizeElementId.value, sizeUpdate)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -589,8 +587,8 @@ const isAnyDragActive = computed(() =>
|
|||||||
|
|
||||||
<!-- Resize handles -->
|
<!-- Resize handles -->
|
||||||
<template v-if="editorStore.selectedElementId === el.id && !isResizing">
|
<template v-if="editorStore.selectedElementId === el.id && !isResizing">
|
||||||
<template v-if="el.type === 'barcode'">
|
<template v-if="el.type === 'barcode' || el.type === 'image'">
|
||||||
<!-- Barkod: sadece yatay resize (aspect ratio korunur) -->
|
<!-- Barkod/Görsel: sadece yatay resize (aspect ratio korunur) -->
|
||||||
<div class="resize-handle resize-handle--e" @pointerdown="(e: PointerEvent) => onResizeStart(e, el.id, 'e')" />
|
<div class="resize-handle resize-handle--e" @pointerdown="(e: PointerEvent) => onResizeStart(e, el.id, 'e')" />
|
||||||
<div class="resize-handle resize-handle--w" @pointerdown="(e: PointerEvent) => onResizeStart(e, el.id, 'w')" />
|
<div class="resize-handle resize-handle--w" @pointerdown="(e: PointerEvent) => onResizeStart(e, el.id, 'w')" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
262
frontend/src/components/editor/LayoutRenderer.vue
Normal file
262
frontend/src/components/editor/LayoutRenderer.vue
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, inject, watch, nextTick } from 'vue'
|
||||||
|
import type { ElementLayout, LayoutResult } from '../../core/layout-types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
layout: LayoutResult | null
|
||||||
|
scale: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// WASM barcode üretme fonksiyonu (EditorCanvas'tan provide edilir)
|
||||||
|
const generateBarcode = inject<(format: string, value: string, width: number, height: number) => Promise<{ width: number; height: number; rgba: ArrayBuffer } | null>>('generateBarcode')
|
||||||
|
|
||||||
|
const pageElements = computed(() => {
|
||||||
|
if (!props.layout || props.layout.pages.length === 0) return []
|
||||||
|
return props.layout.pages[0].elements
|
||||||
|
})
|
||||||
|
|
||||||
|
function elStyle(el: ElementLayout): Record<string, string> {
|
||||||
|
const s = props.scale
|
||||||
|
return {
|
||||||
|
position: 'absolute',
|
||||||
|
left: `${el.x_mm * s}px`,
|
||||||
|
top: `${el.y_mm * s}px`,
|
||||||
|
width: `${el.width_mm * s}px`,
|
||||||
|
height: `${el.height_mm * s}px`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function textStyle(el: ElementLayout): Record<string, string> {
|
||||||
|
const s = props.scale
|
||||||
|
const st = el.style
|
||||||
|
const result: Record<string, string> = {}
|
||||||
|
|
||||||
|
// fontSize pt cinsinden → mm'ye çevir (1pt = 0.3528mm), sonra scale ile px'e
|
||||||
|
if (st.fontSize) result.fontSize = `${st.fontSize * 0.3528 * s}px`
|
||||||
|
if (st.fontWeight) result.fontWeight = st.fontWeight
|
||||||
|
if (st.fontFamily) result.fontFamily = st.fontFamily
|
||||||
|
if (st.color) result.color = st.color
|
||||||
|
if (st.textAlign) result.textAlign = st.textAlign
|
||||||
|
|
||||||
|
result.lineHeight = '1.2'
|
||||||
|
result.overflow = 'hidden'
|
||||||
|
result.wordBreak = 'break-word'
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function containerStyle(el: ElementLayout): Record<string, string> {
|
||||||
|
const st = el.style
|
||||||
|
const result: Record<string, string> = {}
|
||||||
|
|
||||||
|
if (st.backgroundColor) result.backgroundColor = st.backgroundColor
|
||||||
|
if (st.borderColor && st.borderWidth) {
|
||||||
|
result.border = `${st.borderWidth * props.scale}px ${st.borderStyle ?? 'solid'} ${st.borderColor}`
|
||||||
|
}
|
||||||
|
if (st.borderRadius) result.borderRadius = `${st.borderRadius * props.scale}px`
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function lineStyle(el: ElementLayout): Record<string, string> {
|
||||||
|
const st = el.style
|
||||||
|
return {
|
||||||
|
borderTop: `${(st.strokeWidth ?? 0.5) * props.scale}px solid ${st.strokeColor ?? '#000'}`,
|
||||||
|
width: '100%',
|
||||||
|
height: '0',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Barcode rendering (WASM ile) ---
|
||||||
|
|
||||||
|
async function renderBarcodeToCanvas(canvas: HTMLCanvasElement, format: string, value: string, includeText: boolean = false) {
|
||||||
|
if (!value || !generateBarcode) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
// WASM'dan yüksek çözünürlüklü pixel verisi al
|
||||||
|
// QR her zaman kare
|
||||||
|
const isQr = format === 'qr'
|
||||||
|
const size = isQr ? 300 : 400
|
||||||
|
const height = isQr ? 300 : 150
|
||||||
|
const result = await generateBarcode(format, value, size, height, isQr ? false : includeText)
|
||||||
|
if (!result) return
|
||||||
|
|
||||||
|
// Canvas boyutlarını WASM çıktısına ayarla (crisp rendering)
|
||||||
|
canvas.width = result.width
|
||||||
|
canvas.height = result.height
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
|
||||||
|
const imageData = new ImageData(
|
||||||
|
new Uint8ClampedArray(result.rgba),
|
||||||
|
result.width,
|
||||||
|
result.height,
|
||||||
|
)
|
||||||
|
ctx.putImageData(imageData, 0, 0)
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[dreport] WASM barcode render hatası (${format}):`, e)
|
||||||
|
renderBarcodeFallback(canvas, format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBarcodeFallback(canvas: HTMLCanvasElement, format: string) {
|
||||||
|
canvas.width = 200
|
||||||
|
canvas.height = 80
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
ctx.fillStyle = '#f3f4f6'
|
||||||
|
ctx.fillRect(0, 0, 200, 80)
|
||||||
|
ctx.fillStyle = '#ef4444'
|
||||||
|
ctx.font = '11px sans-serif'
|
||||||
|
ctx.textAlign = 'center'
|
||||||
|
ctx.fillText(`[${format}] hata`, 100, 44)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Canvas mount olduğunda render et */
|
||||||
|
function onBarcodeCanvasMounted(el: HTMLCanvasElement | null) {
|
||||||
|
if (!el) return
|
||||||
|
const format = el.dataset.format
|
||||||
|
const value = el.dataset.value
|
||||||
|
const includeText = el.dataset.includeText === 'true'
|
||||||
|
if (format && value) {
|
||||||
|
renderBarcodeToCanvas(el, format, value, includeText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layout değiştiğinde tüm barcode canvas'ları yeniden render et
|
||||||
|
watch(
|
||||||
|
() => props.layout,
|
||||||
|
async () => {
|
||||||
|
await nextTick()
|
||||||
|
await nextTick()
|
||||||
|
const canvases = document.querySelectorAll<HTMLCanvasElement>('canvas[data-barcode]')
|
||||||
|
canvases.forEach(canvas => {
|
||||||
|
const format = canvas.dataset.format
|
||||||
|
const value = canvas.dataset.value
|
||||||
|
const includeText = canvas.dataset.includeText === 'true'
|
||||||
|
if (format && value) {
|
||||||
|
renderBarcodeToCanvas(canvas, format, value, includeText)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="layout-renderer" v-if="layout">
|
||||||
|
<template v-for="el in pageElements" :key="el.id">
|
||||||
|
<!-- Container -->
|
||||||
|
<div
|
||||||
|
v-if="el.element_type === 'container'"
|
||||||
|
class="layout-el layout-el--container"
|
||||||
|
:style="{ ...elStyle(el), ...containerStyle(el) }"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Static text / Text / Page number -->
|
||||||
|
<div
|
||||||
|
v-else-if="el.element_type === 'static_text' || el.element_type === 'text' || el.element_type === 'page_number'"
|
||||||
|
class="layout-el layout-el--text"
|
||||||
|
:style="{ ...elStyle(el), ...textStyle(el) }"
|
||||||
|
>
|
||||||
|
{{ el.content?.type === 'text' ? el.content.value : '' }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Line -->
|
||||||
|
<div
|
||||||
|
v-else-if="el.element_type === 'line'"
|
||||||
|
class="layout-el layout-el--line"
|
||||||
|
:style="elStyle(el)"
|
||||||
|
>
|
||||||
|
<div :style="lineStyle(el)" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Image -->
|
||||||
|
<div
|
||||||
|
v-else-if="el.element_type === 'image'"
|
||||||
|
class="layout-el layout-el--image"
|
||||||
|
:style="elStyle(el)"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="el.content?.type === 'image' && el.content.src"
|
||||||
|
:src="el.content.src"
|
||||||
|
:style="{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: 'fill',
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
<div v-else class="layout-el__placeholder">Görsel</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Barcode -->
|
||||||
|
<div
|
||||||
|
v-else-if="el.element_type === 'barcode'"
|
||||||
|
class="layout-el layout-el--barcode"
|
||||||
|
:style="elStyle(el)"
|
||||||
|
>
|
||||||
|
<canvas
|
||||||
|
v-if="el.content?.type === 'barcode' && el.content.value"
|
||||||
|
:ref="(ref) => onBarcodeCanvasMounted(ref as HTMLCanvasElement)"
|
||||||
|
data-barcode
|
||||||
|
:data-format="el.content.format"
|
||||||
|
:data-value="el.content.value"
|
||||||
|
:data-include-text="el.style.barcodeIncludeText ?? (el.content.format === 'ean13' || el.content.format === 'ean8')"
|
||||||
|
:style="{ width: '100%', height: '100%', display: 'block' }"
|
||||||
|
/>
|
||||||
|
<div v-else class="layout-el__placeholder">
|
||||||
|
{{ el.content?.type === 'barcode' ? `[${el.content.format}]` : '[barcode]' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="layout-renderer layout-renderer--empty" v-else>
|
||||||
|
<span>Hesaplanıyor...</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.layout-renderer {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-renderer--empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #999;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-el {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-el--text {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-family: 'Noto Sans', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-el--line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-el__placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 11px;
|
||||||
|
border: 1px dashed #d1d5db;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -397,8 +397,8 @@ function deleteElement() {
|
|||||||
<div v-if="selectedElement.type === 'line'" class="prop-section">
|
<div v-if="selectedElement.type === 'line'" class="prop-section">
|
||||||
<div class="prop-section__title">Çizgi Stili</div>
|
<div class="prop-section__title">Çizgi Stili</div>
|
||||||
<div class="prop-row">
|
<div class="prop-row">
|
||||||
<label class="prop-label">Kalınlık (pt)</label>
|
<label class="prop-label">Kalınlık (mm)</label>
|
||||||
<input class="prop-input" type="number" step="0.25" min="0.25"
|
<input class="prop-input" type="number" step="0.1" min="0.1"
|
||||||
:value="(selectedElement as LineElement).style.strokeWidth ?? 0.5"
|
:value="(selectedElement as LineElement).style.strokeWidth ?? 0.5"
|
||||||
@input="(e) => updateStyle('strokeWidth', parseFloat((e.target as HTMLInputElement).value) || 0.5)" />
|
@input="(e) => updateStyle('strokeWidth', parseFloat((e.target as HTMLInputElement).value) || 0.5)" />
|
||||||
</div>
|
</div>
|
||||||
@@ -509,6 +509,12 @@ function deleteElement() {
|
|||||||
<button v-if="(selectedElement as BarcodeElement).style.color" class="prop-clear" @click="updateStyle('color', undefined)">x</button>
|
<button v-if="(selectedElement as BarcodeElement).style.color" class="prop-clear" @click="updateStyle('color', undefined)">x</button>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div v-if="schemaStore.scalarFields.length > 0" class="prop-row">
|
||||||
<label class="prop-label">Veri Baglama</label>
|
<label class="prop-label">Veri Baglama</label>
|
||||||
<select class="prop-input prop-select"
|
<select class="prop-input prop-select"
|
||||||
@@ -613,8 +619,8 @@ function deleteElement() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="prop-row">
|
<div class="prop-row">
|
||||||
<label class="prop-label">Kenarlık (pt)</label>
|
<label class="prop-label">Kenarlık (mm)</label>
|
||||||
<input class="prop-input" type="number" step="0.5" min="0"
|
<input class="prop-input" type="number" step="0.1" min="0"
|
||||||
:value="(selectedElement as ContainerElement).style.borderWidth ?? 0"
|
:value="(selectedElement as ContainerElement).style.borderWidth ?? 0"
|
||||||
@input="(e) => updateStyle('borderWidth', parseFloat((e.target as HTMLInputElement).value) || 0)" />
|
@input="(e) => updateStyle('borderWidth', parseFloat((e.target as HTMLInputElement).value) || 0)" />
|
||||||
</div>
|
</div>
|
||||||
@@ -638,8 +644,8 @@ function deleteElement() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="prop-row">
|
<div class="prop-row">
|
||||||
<label class="prop-label">Radius (pt)</label>
|
<label class="prop-label">Radius (mm)</label>
|
||||||
<input class="prop-input" type="number" step="1" min="0"
|
<input class="prop-input" type="number" step="0.5" min="0"
|
||||||
:value="(selectedElement as ContainerElement).style.borderRadius ?? 0"
|
:value="(selectedElement as ContainerElement).style.borderRadius ?? 0"
|
||||||
@input="(e) => updateStyle('borderRadius', parseFloat((e.target as HTMLInputElement).value) || 0)" />
|
@input="(e) => updateStyle('borderRadius', parseFloat((e.target as HTMLInputElement).value) || 0)" />
|
||||||
</div>
|
</div>
|
||||||
@@ -791,8 +797,8 @@ function deleteElement() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="prop-row">
|
<div class="prop-row">
|
||||||
<label class="prop-label">Kenarlık (pt)</label>
|
<label class="prop-label">Kenarlık (mm)</label>
|
||||||
<input class="prop-input" type="number" step="0.25" min="0"
|
<input class="prop-input" type="number" step="0.1" min="0"
|
||||||
:value="(selectedElement as RepeatingTableElement).style.borderWidth ?? 0.5"
|
:value="(selectedElement as RepeatingTableElement).style.borderWidth ?? 0.5"
|
||||||
@input="(e) => updateTableStyle('borderWidth', parseFloat((e.target as HTMLInputElement).value) || 0)" />
|
@input="(e) => updateTableStyle('borderWidth', parseFloat((e.target as HTMLInputElement).value) || 0)" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
137
frontend/src/composables/useLayoutEngine.ts
Normal file
137
frontend/src/composables/useLayoutEngine.ts
Normal 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
frontend/src/core/layout-types.ts
Normal file
65
frontend/src/core/layout-types.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -109,6 +109,7 @@ export type BarcodeFormat = 'qr' | 'ean13' | 'ean8' | 'code128' | 'code39'
|
|||||||
|
|
||||||
export interface BarcodeStyle {
|
export interface BarcodeStyle {
|
||||||
color?: string // ön plan rengi (varsayılan: siyah)
|
color?: string // ön plan rengi (varsayılan: siyah)
|
||||||
|
includeText?: boolean // barkod altına değer yazılsın mı (QR hariç)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Element tipleri ---
|
// --- Element tipleri ---
|
||||||
|
|||||||
@@ -110,8 +110,22 @@ function onKeyDown(e: KeyboardEvent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => window.addEventListener('keydown', onKeyDown))
|
// Browser'ın native pinch-zoom'unu editör alanında engelle
|
||||||
onBeforeUnmount(() => window.removeEventListener('keydown', onKeyDown))
|
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 ---
|
// --- Exposed API ---
|
||||||
|
|
||||||
@@ -179,6 +193,7 @@ defineExpose({
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dreport-editor__sidebar {
|
.dreport-editor__sidebar {
|
||||||
|
|||||||
@@ -52,12 +52,36 @@ export const useTemplateStore = defineStore('template', () => {
|
|||||||
|
|
||||||
const mockData = computed(() => overrideData.value ?? generateMockData(template.value))
|
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) {
|
function setOverrideData(data: Record<string, unknown> | null) {
|
||||||
overrideData.value = data
|
overrideData.value = data
|
||||||
|
bumpLayoutVersion()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Undo / Redo
|
// 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 ---
|
// --- Element CRUD ---
|
||||||
|
|
||||||
@@ -78,6 +102,7 @@ export const useTemplateStore = defineStore('template', () => {
|
|||||||
} else {
|
} else {
|
||||||
parent.children.push(element)
|
parent.children.push(element)
|
||||||
}
|
}
|
||||||
|
bumpLayoutVersion()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Element'i ağaçtan kaldır */
|
/** Element'i ağaçtan kaldır */
|
||||||
@@ -85,13 +110,18 @@ export const useTemplateStore = defineStore('template', () => {
|
|||||||
const parent = getParent(elementId)
|
const parent = getParent(elementId)
|
||||||
if (!parent) return
|
if (!parent) return
|
||||||
const idx = parent.children.findIndex(c => c.id === elementId)
|
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şı */
|
/** Element'i başka bir container'a taşı */
|
||||||
function moveElement(elementId: string, targetParentId: string, index?: number) {
|
function moveElement(elementId: string, targetParentId: string, index?: number) {
|
||||||
const el = getElementById(elementId)
|
const el = getElementById(elementId)
|
||||||
if (!el) return
|
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)
|
removeElement(elementId)
|
||||||
addChild(targetParentId, el, index)
|
addChild(targetParentId, el, index)
|
||||||
}
|
}
|
||||||
@@ -99,7 +129,10 @@ export const useTemplateStore = defineStore('template', () => {
|
|||||||
/** Absolute pozisyon güncelle */
|
/** Absolute pozisyon güncelle */
|
||||||
function updateElementPosition(elementId: string, position: PositionMode) {
|
function updateElementPosition(elementId: string, position: PositionMode) {
|
||||||
const el = getElementById(elementId)
|
const el = getElementById(elementId)
|
||||||
if (el) el.position = position
|
if (el) {
|
||||||
|
el.position = position
|
||||||
|
bumpLayoutVersion()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Boyut güncelle */
|
/** Boyut güncelle */
|
||||||
@@ -107,13 +140,17 @@ export const useTemplateStore = defineStore('template', () => {
|
|||||||
const el = getElementById(elementId)
|
const el = getElementById(elementId)
|
||||||
if (el) {
|
if (el) {
|
||||||
el.size = { ...el.size, ...size }
|
el.size = { ...el.size, ...size }
|
||||||
|
bumpLayoutVersion()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Herhangi bir element özelliğini güncelle */
|
/** Herhangi bir element özelliğini güncelle */
|
||||||
function updateElement(elementId: string, updates: Partial<TemplateElement>) {
|
function updateElement(elementId: string, updates: Partial<TemplateElement>) {
|
||||||
const el = getElementById(elementId)
|
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) */
|
/** Ç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
|
if (!parent || !isContainer(parent)) return
|
||||||
const [moved] = parent.children.splice(fromIndex, 1)
|
const [moved] = parent.children.splice(fromIndex, 1)
|
||||||
parent.children.splice(toIndex, 0, moved)
|
parent.children.splice(toIndex, 0, moved)
|
||||||
|
bumpLayoutVersion()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Şablonu JSON olarak dışa aktar */
|
/** Şablonu JSON olarak dışa aktar */
|
||||||
@@ -133,16 +171,20 @@ export const useTemplateStore = defineStore('template', () => {
|
|||||||
function importTemplate(json: string) {
|
function importTemplate(json: string) {
|
||||||
const parsed = JSON.parse(json) as Template
|
const parsed = JSON.parse(json) as Template
|
||||||
template.value = parsed
|
template.value = parsed
|
||||||
|
bumpLayoutVersion()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Yeni boş şablon oluştur */
|
/** Yeni boş şablon oluştur */
|
||||||
function resetTemplate() {
|
function resetTemplate() {
|
||||||
template.value = createDefaultTemplate()
|
template.value = createDefaultTemplate()
|
||||||
|
bumpLayoutVersion()
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
template,
|
template,
|
||||||
mockData,
|
mockData,
|
||||||
|
layoutVersion,
|
||||||
|
bumpLayoutVersion,
|
||||||
getElementById,
|
getElementById,
|
||||||
getParent,
|
getParent,
|
||||||
addChild,
|
addChild,
|
||||||
|
|||||||
@@ -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,
|
*::before,
|
||||||
*::after {
|
*::after {
|
||||||
@@ -12,6 +52,8 @@ html, body {
|
|||||||
color: #1e293b;
|
color: #1e293b;
|
||||||
background: #f1f5f9;
|
background: #f1f5f9;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
|
/* Browser native pinch-zoom'u engelle — editörün kendi zoom'u var */
|
||||||
|
touch-action: pan-x pan-y;
|
||||||
}
|
}
|
||||||
|
|
||||||
#app {
|
#app {
|
||||||
|
|||||||
88
frontend/src/workers/layout.worker.ts
Normal file
88
frontend/src/workers/layout.worker.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
justfile
19
justfile
@@ -8,12 +8,25 @@ front:
|
|||||||
back:
|
back:
|
||||||
cargo run -p dreport-backend
|
cargo run -p dreport-backend
|
||||||
|
|
||||||
# Frontend + Backend birlikte
|
# Frontend + Backend + WASM watch birlikte
|
||||||
dev:
|
dev:
|
||||||
just front & just back & wait
|
just front & just back & just wasm-watch & wait
|
||||||
|
|
||||||
# WASM build (core -> frontend)
|
# Layout engine WASM build
|
||||||
wasm:
|
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
|
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.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
|
cp frontend/src/core/wasm-pkg/dreport_core.d.ts frontend/src/core/wasm/dreport_core.d.ts
|
||||||
|
|||||||
29
layout-engine/Cargo.toml
Normal file
29
layout-engine/Cargo.toml
Normal 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
layout-engine/src/barcode_gen.rs
Normal file
286
layout-engine/src/barcode_gen.rs
Normal 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
layout-engine/src/data_resolve.rs
Normal file
161
layout-engine/src/data_resolve.rs
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
use dreport_core::models::*;
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
/// Her element ID'si için çözümlenmiş text içeriğini tutar.
|
||||||
|
/// Table ve barcode gibi özel tipler de burada çözülür.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ResolvedData {
|
||||||
|
/// element_id → çözümlenmiş text içeriği
|
||||||
|
pub texts: HashMap<String, String>,
|
||||||
|
/// element_id → çözümlenmiş tablo verileri (headers, rows)
|
||||||
|
pub tables: HashMap<String, ResolvedTable>,
|
||||||
|
/// element_id → çözümlenmiş barcode değeri
|
||||||
|
pub barcodes: HashMap<String, String>,
|
||||||
|
/// element_id → çözümlenmiş image src
|
||||||
|
pub images: HashMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ResolvedTable {
|
||||||
|
pub rows: Vec<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JSON path ile veri çek: "firma.unvan" → data["firma"]["unvan"]
|
||||||
|
fn resolve_path<'a>(data: &'a Value, path: &str) -> &'a Value {
|
||||||
|
let mut current = data;
|
||||||
|
for key in path.split('.') {
|
||||||
|
current = match current {
|
||||||
|
Value::Object(map) => map.get(key).unwrap_or(&Value::Null),
|
||||||
|
_ => &Value::Null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
current
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JSON Value → display string
|
||||||
|
fn value_to_string(v: &Value) -> String {
|
||||||
|
match v {
|
||||||
|
Value::String(s) => s.clone(),
|
||||||
|
Value::Number(n) => n.to_string(),
|
||||||
|
Value::Bool(b) => b.to_string(),
|
||||||
|
Value::Null => String::new(),
|
||||||
|
_ => v.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Template'deki tüm binding'leri çözümle.
|
||||||
|
pub fn resolve_template(template: &Template, data: &Value) -> ResolvedData {
|
||||||
|
let mut resolved = ResolvedData {
|
||||||
|
texts: HashMap::new(),
|
||||||
|
tables: HashMap::new(),
|
||||||
|
barcodes: HashMap::new(),
|
||||||
|
images: HashMap::new(),
|
||||||
|
};
|
||||||
|
resolve_element(&TemplateElement::Container(template.root.clone()), data, &mut resolved);
|
||||||
|
resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_element(el: &TemplateElement, data: &Value, resolved: &mut ResolvedData) {
|
||||||
|
match el {
|
||||||
|
TemplateElement::StaticText(e) => {
|
||||||
|
resolved.texts.insert(e.id.clone(), e.content.clone());
|
||||||
|
}
|
||||||
|
TemplateElement::Text(e) => {
|
||||||
|
let bound_value = value_to_string(resolve_path(data, &e.binding.path));
|
||||||
|
let text = match &e.content {
|
||||||
|
Some(prefix) if !prefix.is_empty() => format!("{}{}", prefix, bound_value),
|
||||||
|
_ => bound_value,
|
||||||
|
};
|
||||||
|
resolved.texts.insert(e.id.clone(), text);
|
||||||
|
}
|
||||||
|
TemplateElement::PageNumber(e) => {
|
||||||
|
// Sayfa numarası layout sonrasında çözülecek, placeholder koy
|
||||||
|
let fmt = e.format.as_deref().unwrap_or("{current} / {total}");
|
||||||
|
resolved.texts.insert(e.id.clone(), fmt.replace("{current}", "1").replace("{total}", "1"));
|
||||||
|
}
|
||||||
|
TemplateElement::Barcode(e) => {
|
||||||
|
let value = if let Some(binding) = &e.binding {
|
||||||
|
value_to_string(resolve_path(data, &binding.path))
|
||||||
|
} else {
|
||||||
|
e.value.clone().unwrap_or_default()
|
||||||
|
};
|
||||||
|
resolved.barcodes.insert(e.id.clone(), value);
|
||||||
|
}
|
||||||
|
TemplateElement::Image(e) => {
|
||||||
|
let src = if let Some(binding) = &e.binding {
|
||||||
|
value_to_string(resolve_path(data, &binding.path))
|
||||||
|
} else {
|
||||||
|
e.src.clone().unwrap_or_default()
|
||||||
|
};
|
||||||
|
resolved.images.insert(e.id.clone(), src);
|
||||||
|
}
|
||||||
|
TemplateElement::RepeatingTable(e) => {
|
||||||
|
let array = resolve_path(data, &e.data_source.path);
|
||||||
|
let rows = match array {
|
||||||
|
Value::Array(items) => {
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.map(|item| {
|
||||||
|
e.columns
|
||||||
|
.iter()
|
||||||
|
.map(|col| {
|
||||||
|
let v = resolve_path(item, &col.field);
|
||||||
|
value_to_string(v)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
_ => vec![],
|
||||||
|
};
|
||||||
|
resolved.tables.insert(e.id.clone(), ResolvedTable { rows });
|
||||||
|
}
|
||||||
|
TemplateElement::Container(e) => {
|
||||||
|
for child in &e.children {
|
||||||
|
resolve_element(child, data, resolved);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TemplateElement::Line(_) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_resolve_path() {
|
||||||
|
let data: Value = serde_json::json!({
|
||||||
|
"firma": {
|
||||||
|
"unvan": "Acme A.Ş.",
|
||||||
|
"vergiNo": "123"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
value_to_string(resolve_path(&data, "firma.unvan")),
|
||||||
|
"Acme A.Ş."
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
value_to_string(resolve_path(&data, "firma.vergiNo")),
|
||||||
|
"123"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
value_to_string(resolve_path(&data, "nonexistent.path")),
|
||||||
|
""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_resolve_array() {
|
||||||
|
let data: Value = serde_json::json!({
|
||||||
|
"kalemler": [
|
||||||
|
{ "adi": "Widget", "tutar": 100 },
|
||||||
|
{ "adi": "Gadget", "tutar": 200 }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
let arr = resolve_path(&data, "kalemler");
|
||||||
|
assert!(arr.is_array());
|
||||||
|
assert_eq!(arr.as_array().unwrap().len(), 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
142
layout-engine/src/lib.rs
Normal file
142
layout-engine/src/lib.rs
Normal 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
layout-engine/src/pdf_render.rs
Normal file
680
layout-engine/src/pdf_render.rs
Normal file
@@ -0,0 +1,680 @@
|
|||||||
|
//! LayoutResult → PDF bytes (krilla ile).
|
||||||
|
//! Sadece native (non-WASM) hedeflerde derlenir.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use krilla::color::rgb;
|
||||||
|
use krilla::geom::{PathBuilder, Point, Size, Transform};
|
||||||
|
use krilla::num::NormalizedF32;
|
||||||
|
use krilla::page::PageSettings;
|
||||||
|
use krilla::paint::{Fill, Stroke};
|
||||||
|
use krilla::text::{Font as KrillaFont, TextDirection};
|
||||||
|
use krilla::Document;
|
||||||
|
|
||||||
|
use crate::text_measure::TextMeasurer;
|
||||||
|
use crate::{ElementLayout, FontData, LayoutResult, PageLayout, ResolvedContent, ResolvedStyle};
|
||||||
|
|
||||||
|
/// mm → pt dönüşümü (1mm = 2.83465pt)
|
||||||
|
const MM_TO_PT: f32 = 72.0 / 25.4;
|
||||||
|
|
||||||
|
fn mm(v: f64) -> f32 {
|
||||||
|
v as f32 * MM_TO_PT
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hex renk (#RRGGBB veya #RGB) → rgb::Color
|
||||||
|
fn parse_color(hex: &str) -> rgb::Color {
|
||||||
|
let hex = hex.trim_start_matches('#');
|
||||||
|
let (r, g, b) = match hex.len() {
|
||||||
|
6 => (
|
||||||
|
u8::from_str_radix(&hex[0..2], 16).unwrap_or(0),
|
||||||
|
u8::from_str_radix(&hex[2..4], 16).unwrap_or(0),
|
||||||
|
u8::from_str_radix(&hex[4..6], 16).unwrap_or(0),
|
||||||
|
),
|
||||||
|
3 => {
|
||||||
|
let r = u8::from_str_radix(&hex[0..1], 16).unwrap_or(0);
|
||||||
|
let g = u8::from_str_radix(&hex[1..2], 16).unwrap_or(0);
|
||||||
|
let b = u8::from_str_radix(&hex[2..3], 16).unwrap_or(0);
|
||||||
|
(r * 17, g * 17, b * 17)
|
||||||
|
}
|
||||||
|
_ => (0, 0, 0),
|
||||||
|
};
|
||||||
|
rgb::Color::new(r, g, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fill_from_color(color: rgb::Color) -> Fill {
|
||||||
|
Fill {
|
||||||
|
paint: color.into(),
|
||||||
|
opacity: NormalizedF32::ONE,
|
||||||
|
rule: Default::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Font koleksiyonu — family + weight + italic → KrillaFont mapping
|
||||||
|
struct FontCollection {
|
||||||
|
/// (family_lower, is_bold, is_italic) → KrillaFont
|
||||||
|
fonts: HashMap<(String, bool, bool), KrillaFont>,
|
||||||
|
/// Fallback font (ilk yüklenen regular)
|
||||||
|
default: Option<KrillaFont>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FontCollection {
|
||||||
|
fn new(font_data: &[FontData]) -> Self {
|
||||||
|
let mut fonts = HashMap::new();
|
||||||
|
let mut default = None;
|
||||||
|
|
||||||
|
for fd in font_data {
|
||||||
|
let Some(font) = KrillaFont::new(
|
||||||
|
krilla::Data::from(fd.data.clone()),
|
||||||
|
0,
|
||||||
|
) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let family_lower = fd.family.to_lowercase();
|
||||||
|
let is_bold = is_font_bold(&fd.data);
|
||||||
|
let is_italic = is_font_italic(&fd.data);
|
||||||
|
|
||||||
|
// Default font: ilk regular (non-bold, non-italic)
|
||||||
|
if default.is_none() && !is_bold && !is_italic {
|
||||||
|
default = Some(font.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
fonts.insert((family_lower.clone(), is_bold, is_italic), font);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hiç regular bulamadıysak ilk font'u default yap
|
||||||
|
if default.is_none() {
|
||||||
|
if let Some(fd) = font_data.first() {
|
||||||
|
default = KrillaFont::new(krilla::Data::from(fd.data.clone()), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Self { fonts, default }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get(&self, family: Option<&str>, weight: Option<&str>) -> Option<&KrillaFont> {
|
||||||
|
let is_bold = matches!(weight, Some("bold"));
|
||||||
|
let family_lower = family.unwrap_or("noto sans").to_lowercase();
|
||||||
|
|
||||||
|
// Her zaman non-italic font ara (italic desteği henüz yok)
|
||||||
|
self.fonts
|
||||||
|
.get(&(family_lower.clone(), is_bold, false))
|
||||||
|
.or_else(|| self.fonts.get(&(family_lower, false, false)))
|
||||||
|
.or(self.default.as_ref())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TTF OS/2 tablosunun offset'ini bul
|
||||||
|
fn find_os2_table(data: &[u8]) -> Option<usize> {
|
||||||
|
if data.len() < 12 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let num_tables = u16::from_be_bytes([data[4], data[5]]) as usize;
|
||||||
|
let mut offset = 12;
|
||||||
|
for _ in 0..num_tables {
|
||||||
|
if offset + 16 > data.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let tag = &data[offset..offset + 4];
|
||||||
|
if tag == b"OS/2" {
|
||||||
|
let table_offset =
|
||||||
|
u32::from_be_bytes([data[offset + 8], data[offset + 9], data[offset + 10], data[offset + 11]])
|
||||||
|
as usize;
|
||||||
|
return Some(table_offset);
|
||||||
|
}
|
||||||
|
offset += 16;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TTF dosyasının bold olup olmadığını OS/2 tablosundan kontrol et
|
||||||
|
fn is_font_bold(data: &[u8]) -> bool {
|
||||||
|
let Some(table_offset) = find_os2_table(data) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
// usWeightClass is at offset 4 in OS/2 table
|
||||||
|
if table_offset + 6 <= data.len() {
|
||||||
|
let weight_class = u16::from_be_bytes([data[table_offset + 4], data[table_offset + 5]]);
|
||||||
|
return weight_class >= 700;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TTF dosyasının italic olup olmadığını OS/2 tablosundan kontrol et
|
||||||
|
fn is_font_italic(data: &[u8]) -> bool {
|
||||||
|
let Some(table_offset) = find_os2_table(data) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
// fsSelection is at offset 62 in OS/2 table, bit 0 = ITALIC
|
||||||
|
if table_offset + 64 <= data.len() {
|
||||||
|
let fs_selection = u16::from_be_bytes([data[table_offset + 62], data[table_offset + 63]]);
|
||||||
|
return fs_selection & 0x0001 != 0;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// LayoutResult → PDF bytes
|
||||||
|
pub fn render_pdf(layout: &LayoutResult, font_data: &[FontData]) -> Result<Vec<u8>, String> {
|
||||||
|
let fonts = FontCollection::new(font_data);
|
||||||
|
let mut measurer = TextMeasurer::new(font_data);
|
||||||
|
let mut doc = Document::new();
|
||||||
|
|
||||||
|
for page in &layout.pages {
|
||||||
|
render_page(&mut doc, page, &fonts, font_data, &mut measurer)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.finish().map_err(|e| format!("PDF oluşturma hatası: {e:?}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_page(
|
||||||
|
doc: &mut Document,
|
||||||
|
page: &PageLayout,
|
||||||
|
fonts: &FontCollection,
|
||||||
|
font_data: &[FontData],
|
||||||
|
measurer: &mut TextMeasurer,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let w = mm(page.width_mm);
|
||||||
|
let h = mm(page.height_mm);
|
||||||
|
|
||||||
|
let page_settings =
|
||||||
|
PageSettings::from_wh(w, h).ok_or_else(|| "Geçersiz sayfa boyutu".to_string())?;
|
||||||
|
|
||||||
|
let mut pdf_page = doc.start_page_with(page_settings);
|
||||||
|
let mut surface = pdf_page.surface();
|
||||||
|
|
||||||
|
for el in &page.elements {
|
||||||
|
render_element(&mut surface, el, fonts, font_data, measurer);
|
||||||
|
}
|
||||||
|
|
||||||
|
surface.finish();
|
||||||
|
pdf_page.finish();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_element(
|
||||||
|
surface: &mut krilla::surface::Surface<'_>,
|
||||||
|
el: &ElementLayout,
|
||||||
|
fonts: &FontCollection,
|
||||||
|
font_data: &[FontData],
|
||||||
|
measurer: &mut TextMeasurer,
|
||||||
|
) {
|
||||||
|
let x = mm(el.x_mm);
|
||||||
|
let y = mm(el.y_mm);
|
||||||
|
let w = mm(el.width_mm);
|
||||||
|
let h = mm(el.height_mm);
|
||||||
|
|
||||||
|
// Container background/border
|
||||||
|
if el.element_type == "container" {
|
||||||
|
render_container_bg(surface, x, y, w, h, &el.style);
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(ref content) = el.content else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
match content {
|
||||||
|
ResolvedContent::Text { value } => {
|
||||||
|
render_text(surface, x, y, w, h, value, &el.style, fonts, measurer);
|
||||||
|
}
|
||||||
|
ResolvedContent::Line => {
|
||||||
|
render_line(surface, x, y, w, &el.style);
|
||||||
|
}
|
||||||
|
ResolvedContent::Image { src } => {
|
||||||
|
render_image(surface, x, y, w, h, src);
|
||||||
|
}
|
||||||
|
ResolvedContent::PageNumber { current, total } => {
|
||||||
|
let text = format!("{current} / {total}");
|
||||||
|
render_text(surface, x, y, w, h, &text, &el.style, fonts, measurer);
|
||||||
|
}
|
||||||
|
ResolvedContent::Table { .. } => {
|
||||||
|
// Tablolar expand edilerek container + text olarak render edilir.
|
||||||
|
// Bu branch'e normalde düşmemeli.
|
||||||
|
}
|
||||||
|
ResolvedContent::Barcode { format, value } => {
|
||||||
|
render_barcode(surface, x, y, w, h, format, value, &el.style, font_data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_container_bg(
|
||||||
|
surface: &mut krilla::surface::Surface<'_>,
|
||||||
|
x: f32,
|
||||||
|
y: f32,
|
||||||
|
w: f32,
|
||||||
|
h: f32,
|
||||||
|
style: &ResolvedStyle,
|
||||||
|
) {
|
||||||
|
let has_bg = style.background_color.is_some();
|
||||||
|
let has_border = style.border_color.is_some() && style.border_width.unwrap_or(0.0) > 0.0;
|
||||||
|
|
||||||
|
if !has_bg && !has_border {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill
|
||||||
|
if let Some(ref bg) = style.background_color {
|
||||||
|
surface.set_fill(Some(fill_from_color(parse_color(bg))));
|
||||||
|
} else {
|
||||||
|
surface.set_fill(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stroke
|
||||||
|
if has_border {
|
||||||
|
let border_color = parse_color(style.border_color.as_deref().unwrap_or("#000000"));
|
||||||
|
let border_width = mm(style.border_width.unwrap_or(0.5));
|
||||||
|
surface.set_stroke(Some(Stroke {
|
||||||
|
paint: border_color.into(),
|
||||||
|
width: border_width,
|
||||||
|
opacity: NormalizedF32::ONE,
|
||||||
|
..Default::default()
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
surface.set_stroke(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let rect_path = {
|
||||||
|
let mut pb = PathBuilder::new();
|
||||||
|
if let Some(rect) = krilla::geom::Rect::from_xywh(x, y, w, h) {
|
||||||
|
pb.push_rect(rect);
|
||||||
|
}
|
||||||
|
pb.finish()
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(path) = rect_path {
|
||||||
|
surface.draw_path(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset
|
||||||
|
surface.set_fill(None);
|
||||||
|
surface.set_stroke(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_text(
|
||||||
|
surface: &mut krilla::surface::Surface<'_>,
|
||||||
|
x: f32,
|
||||||
|
y: f32,
|
||||||
|
w: f32,
|
||||||
|
_h: f32,
|
||||||
|
text: &str,
|
||||||
|
style: &ResolvedStyle,
|
||||||
|
fonts: &FontCollection,
|
||||||
|
measurer: &mut TextMeasurer,
|
||||||
|
) {
|
||||||
|
if text.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let font_size = style.font_size.unwrap_or(11.0) as f32;
|
||||||
|
let color = style
|
||||||
|
.color
|
||||||
|
.as_deref()
|
||||||
|
.map(parse_color)
|
||||||
|
.unwrap_or(rgb::Color::new(0, 0, 0));
|
||||||
|
|
||||||
|
let Some(font) = fonts.get(
|
||||||
|
style.font_family.as_deref(),
|
||||||
|
style.font_weight.as_deref(),
|
||||||
|
) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
surface.set_fill(Some(fill_from_color(color)));
|
||||||
|
surface.set_stroke(None);
|
||||||
|
|
||||||
|
// Text baseline: y + ascent (yaklaşık font_size * 0.8)
|
||||||
|
let baseline_y = y + font_size * 0.8;
|
||||||
|
|
||||||
|
// Hizalama — cosmic-text ile text genişliğini ölçerek gerçek pozisyon hesapla
|
||||||
|
let text_x = match style.text_align.as_deref() {
|
||||||
|
Some("center") | Some("right") => {
|
||||||
|
let (text_width_pt, _) = measurer.measure(
|
||||||
|
text,
|
||||||
|
style.font_family.as_deref(),
|
||||||
|
font_size,
|
||||||
|
style.font_weight.as_deref(),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
|
if style.text_align.as_deref() == Some("center") {
|
||||||
|
x + (w - text_width_pt) / 2.0
|
||||||
|
} else {
|
||||||
|
x + w - text_width_pt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => x,
|
||||||
|
};
|
||||||
|
|
||||||
|
surface.draw_text(
|
||||||
|
Point::from_xy(text_x, baseline_y),
|
||||||
|
font.clone(),
|
||||||
|
font_size,
|
||||||
|
text,
|
||||||
|
false,
|
||||||
|
TextDirection::Auto,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_line(
|
||||||
|
surface: &mut krilla::surface::Surface<'_>,
|
||||||
|
x: f32,
|
||||||
|
y: f32,
|
||||||
|
w: f32,
|
||||||
|
style: &ResolvedStyle,
|
||||||
|
) {
|
||||||
|
let stroke_color = style
|
||||||
|
.stroke_color
|
||||||
|
.as_deref()
|
||||||
|
.map(parse_color)
|
||||||
|
.unwrap_or(rgb::Color::new(0, 0, 0));
|
||||||
|
let stroke_width = mm(style.stroke_width.unwrap_or(0.5));
|
||||||
|
|
||||||
|
surface.set_fill(None);
|
||||||
|
surface.set_stroke(Some(Stroke {
|
||||||
|
paint: stroke_color.into(),
|
||||||
|
width: stroke_width,
|
||||||
|
opacity: NormalizedF32::ONE,
|
||||||
|
..Default::default()
|
||||||
|
}));
|
||||||
|
|
||||||
|
let line_y = y + stroke_width / 2.0;
|
||||||
|
let path = {
|
||||||
|
let mut pb = PathBuilder::new();
|
||||||
|
pb.move_to(x, line_y);
|
||||||
|
pb.line_to(x + w, line_y);
|
||||||
|
pb.finish()
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(p) = path {
|
||||||
|
surface.draw_path(&p);
|
||||||
|
}
|
||||||
|
|
||||||
|
surface.set_stroke(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_image(
|
||||||
|
surface: &mut krilla::surface::Surface<'_>,
|
||||||
|
x: f32,
|
||||||
|
y: f32,
|
||||||
|
w: f32,
|
||||||
|
h: f32,
|
||||||
|
src: &str,
|
||||||
|
) {
|
||||||
|
if src.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// data:image/png;base64,... veya data:image/jpeg;base64,...
|
||||||
|
let Some(base64_part) = src.split(',').nth(1) else {
|
||||||
|
eprintln!("[dreport] Image src data URI değil, atlanıyor: {}...", &src[..src.len().min(60)]);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
use base64::Engine;
|
||||||
|
let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(base64_part) else {
|
||||||
|
eprintln!("[dreport] Image base64 decode hatası");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tüm formatları image crate ile decode edip PNG'ye çevir (krilla JPEG desteği sınırlı)
|
||||||
|
let png_data = match decode_to_png(&decoded) {
|
||||||
|
Some(data) => data,
|
||||||
|
None => {
|
||||||
|
eprintln!("[dreport] Image decode/re-encode hatası, ham veri deneniyor");
|
||||||
|
decoded
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
embed_png(surface, x, y, w, h, &png_data);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_barcode(
|
||||||
|
surface: &mut krilla::surface::Surface<'_>,
|
||||||
|
x: f32,
|
||||||
|
y: f32,
|
||||||
|
w: f32,
|
||||||
|
h: f32,
|
||||||
|
format: &str,
|
||||||
|
value: &str,
|
||||||
|
style: &ResolvedStyle,
|
||||||
|
font_data: &[FontData],
|
||||||
|
) {
|
||||||
|
if value.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hedef piksel boyutları (yüksek çözünürlük için 4x, minimum 1px)
|
||||||
|
let w_px = ((w * 4.0) as u32).max(1);
|
||||||
|
let h_px = ((h * 4.0) as u32).max(1);
|
||||||
|
let include_text = style.barcode_include_text.unwrap_or(false);
|
||||||
|
|
||||||
|
let png_result = crate::barcode_gen::generate_barcode_png(format, value, w_px, h_px, include_text, Some(font_data));
|
||||||
|
|
||||||
|
match png_result {
|
||||||
|
Ok(png_bytes) => {
|
||||||
|
embed_png(surface, x, y, w, h, &png_bytes);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[dreport] Barcode üretim hatası ({format}): {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// image crate ile herhangi bir formattan decode edip PNG bytes'a çevir
|
||||||
|
fn decode_to_png(raw: &[u8]) -> Option<Vec<u8>> {
|
||||||
|
let img = image::load_from_memory(raw).ok()?;
|
||||||
|
let rgba = img.to_rgba8();
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
let encoder = image::codecs::png::PngEncoder::new(&mut buf);
|
||||||
|
image::ImageEncoder::write_image(
|
||||||
|
encoder,
|
||||||
|
rgba.as_raw(),
|
||||||
|
rgba.width(),
|
||||||
|
rgba.height(),
|
||||||
|
image::ExtendedColorType::Rgba8,
|
||||||
|
)
|
||||||
|
.ok()?;
|
||||||
|
Some(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PNG bytes'ı PDF'e göm
|
||||||
|
fn embed_png(
|
||||||
|
surface: &mut krilla::surface::Surface<'_>,
|
||||||
|
x: f32,
|
||||||
|
y: f32,
|
||||||
|
w: f32,
|
||||||
|
h: f32,
|
||||||
|
data: &[u8],
|
||||||
|
) {
|
||||||
|
let data_vec: Vec<u8> = data.to_vec();
|
||||||
|
let Ok(img) = krilla::image::Image::from_png(data_vec.into(), true) else {
|
||||||
|
eprintln!("[dreport] PNG krilla embed hatası");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(size) = Size::from_wh(w, h) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
surface.push_transform(&Transform::from_translate(x, y));
|
||||||
|
surface.draw_image(img, size);
|
||||||
|
surface.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::{ElementLayout, PageLayout, ResolvedContent, ResolvedStyle};
|
||||||
|
|
||||||
|
fn test_fonts() -> Vec<FontData> {
|
||||||
|
crate::text_measure::load_test_fonts()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_simple_pdf() {
|
||||||
|
let layout = LayoutResult {
|
||||||
|
pages: vec![PageLayout {
|
||||||
|
page_index: 0,
|
||||||
|
width_mm: 210.0,
|
||||||
|
height_mm: 297.0,
|
||||||
|
elements: vec![
|
||||||
|
ElementLayout {
|
||||||
|
id: "title".to_string(),
|
||||||
|
x_mm: 15.0,
|
||||||
|
y_mm: 15.0,
|
||||||
|
width_mm: 180.0,
|
||||||
|
height_mm: 20.0,
|
||||||
|
element_type: "static_text".to_string(),
|
||||||
|
content: Some(ResolvedContent::Text {
|
||||||
|
value: "FATURA".to_string(),
|
||||||
|
}),
|
||||||
|
style: ResolvedStyle {
|
||||||
|
font_size: Some(18.0),
|
||||||
|
font_weight: Some("bold".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
children: vec![],
|
||||||
|
},
|
||||||
|
ElementLayout {
|
||||||
|
id: "line1".to_string(),
|
||||||
|
x_mm: 15.0,
|
||||||
|
y_mm: 38.0,
|
||||||
|
width_mm: 180.0,
|
||||||
|
height_mm: 0.5,
|
||||||
|
element_type: "line".to_string(),
|
||||||
|
content: Some(ResolvedContent::Line),
|
||||||
|
style: ResolvedStyle {
|
||||||
|
stroke_color: Some("#000000".to_string()),
|
||||||
|
stroke_width: Some(0.5),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
children: vec![],
|
||||||
|
},
|
||||||
|
ElementLayout {
|
||||||
|
id: "body".to_string(),
|
||||||
|
x_mm: 15.0,
|
||||||
|
y_mm: 42.0,
|
||||||
|
width_mm: 180.0,
|
||||||
|
height_mm: 14.0,
|
||||||
|
element_type: "static_text".to_string(),
|
||||||
|
content: Some(ResolvedContent::Text {
|
||||||
|
value: "Bu bir test belgesidir.".to_string(),
|
||||||
|
}),
|
||||||
|
style: ResolvedStyle {
|
||||||
|
font_size: Some(11.0),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
children: vec![],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
let fonts = test_fonts();
|
||||||
|
let pdf = render_pdf(&layout, &fonts).expect("PDF oluşturulabilmeli");
|
||||||
|
|
||||||
|
// PDF magic bytes kontrolü
|
||||||
|
assert!(pdf.starts_with(b"%PDF"), "Geçerli PDF çıktısı olmalı");
|
||||||
|
assert!(pdf.len() > 100, "PDF boyutu çok küçük: {} bytes", pdf.len());
|
||||||
|
|
||||||
|
// Debug: dosyaya yaz
|
||||||
|
let out_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.parent()
|
||||||
|
.unwrap()
|
||||||
|
.join("test_output.pdf");
|
||||||
|
std::fs::write(&out_path, &pdf).unwrap();
|
||||||
|
println!("Test PDF yazıldı: {}", out_path.display());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_full_pipeline() {
|
||||||
|
use dreport_core::models::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
let template = Template {
|
||||||
|
id: "test".to_string(),
|
||||||
|
name: "Test".to_string(),
|
||||||
|
page: PageSettings { width: 210.0, height: 297.0 },
|
||||||
|
fonts: vec!["Noto Sans".to_string()],
|
||||||
|
root: ContainerElement {
|
||||||
|
id: "root".to_string(),
|
||||||
|
position: PositionMode::Flow,
|
||||||
|
size: SizeConstraint {
|
||||||
|
width: SizeValue::Auto,
|
||||||
|
height: SizeValue::Auto,
|
||||||
|
min_width: None, min_height: None, max_width: None, max_height: None,
|
||||||
|
},
|
||||||
|
direction: "column".to_string(),
|
||||||
|
gap: 5.0,
|
||||||
|
padding: Padding { top: 15.0, right: 15.0, bottom: 15.0, left: 15.0 },
|
||||||
|
align: "stretch".to_string(),
|
||||||
|
justify: "start".to_string(),
|
||||||
|
style: ContainerStyle::default(),
|
||||||
|
children: vec![
|
||||||
|
TemplateElement::StaticText(StaticTextElement {
|
||||||
|
id: "title".to_string(),
|
||||||
|
position: PositionMode::Flow,
|
||||||
|
size: SizeConstraint {
|
||||||
|
width: SizeValue::Fr { value: 1.0 },
|
||||||
|
height: SizeValue::Auto,
|
||||||
|
min_width: None, min_height: None, max_width: None, max_height: None,
|
||||||
|
},
|
||||||
|
style: TextStyle {
|
||||||
|
font_size: Some(18.0),
|
||||||
|
font_weight: Some("bold".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
content: "FATURA".to_string(),
|
||||||
|
}),
|
||||||
|
TemplateElement::Line(LineElement {
|
||||||
|
id: "line1".to_string(),
|
||||||
|
position: PositionMode::Flow,
|
||||||
|
size: SizeConstraint {
|
||||||
|
width: SizeValue::Fr { value: 1.0 },
|
||||||
|
height: SizeValue::Auto,
|
||||||
|
min_width: None, min_height: None, max_width: None, max_height: None,
|
||||||
|
},
|
||||||
|
style: LineStyle {
|
||||||
|
stroke_color: Some("#000000".to_string()),
|
||||||
|
stroke_width: Some(0.5),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
TemplateElement::Text(TextElement {
|
||||||
|
id: "firma".to_string(),
|
||||||
|
position: PositionMode::Flow,
|
||||||
|
size: SizeConstraint {
|
||||||
|
width: SizeValue::Fr { value: 1.0 },
|
||||||
|
height: SizeValue::Auto,
|
||||||
|
min_width: None, min_height: None, max_width: None, max_height: None,
|
||||||
|
},
|
||||||
|
style: TextStyle {
|
||||||
|
font_size: Some(11.0),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
content: None,
|
||||||
|
binding: dreport_core::models::ScalarBinding {
|
||||||
|
path: "firma.unvan".to_string(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let data = json!({
|
||||||
|
"firma": { "unvan": "Acme Teknoloji A.Ş." }
|
||||||
|
});
|
||||||
|
|
||||||
|
let fonts = test_fonts();
|
||||||
|
let layout = crate::compute_layout(&template, &data, &fonts);
|
||||||
|
let pdf = render_pdf(&layout, &fonts).expect("Full pipeline PDF");
|
||||||
|
|
||||||
|
assert!(pdf.starts_with(b"%PDF"));
|
||||||
|
assert!(pdf.len() > 200);
|
||||||
|
|
||||||
|
let out_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.parent()
|
||||||
|
.unwrap()
|
||||||
|
.join("test_output_full.pdf");
|
||||||
|
std::fs::write(&out_path, &pdf).unwrap();
|
||||||
|
println!("Full pipeline PDF: {}", out_path.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
222
layout-engine/src/sizing.rs
Normal file
222
layout-engine/src/sizing.rs
Normal 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
layout-engine/src/table_layout.rs
Normal file
191
layout-engine/src/table_layout.rs
Normal 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
layout-engine/src/text_measure.rs
Normal file
277
layout-engine/src/text_measure.rs
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::hash::Hash;
|
||||||
|
|
||||||
|
use crate::FontData;
|
||||||
|
use cosmic_text::{Attrs, Buffer, Family, FontSystem, Metrics, Shaping, Weight};
|
||||||
|
|
||||||
|
/// Opak text ölçüm cache'i. `TextMeasurer` call'ları arasında taşınarak
|
||||||
|
/// aynı parametrelerle yapılan ölçümlerin yeniden hesaplanmasını önler.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct TextMeasureCache {
|
||||||
|
entries: HashMap<MeasureCacheKey, (f32, f32)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TextMeasureCache {
|
||||||
|
/// Cache içeriğini al ve yerine boş cache bırak.
|
||||||
|
pub fn take(&mut self) -> Self {
|
||||||
|
Self {
|
||||||
|
entries: std::mem::take(&mut self.entries),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cache key — text ölçüm parametrelerinin hash'lenebilir temsili.
|
||||||
|
/// f32 değerler bit-exact karşılaştırma için u32'ye çevrilir.
|
||||||
|
#[derive(Clone, Eq, PartialEq, Hash)]
|
||||||
|
struct MeasureCacheKey {
|
||||||
|
text: String,
|
||||||
|
font_family: Option<String>,
|
||||||
|
font_size_bits: u32,
|
||||||
|
font_weight: Option<String>,
|
||||||
|
available_width_bits: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MeasureCacheKey {
|
||||||
|
fn new(
|
||||||
|
text: &str,
|
||||||
|
font_family: Option<&str>,
|
||||||
|
font_size_pt: f32,
|
||||||
|
font_weight: Option<&str>,
|
||||||
|
available_width_pt: Option<f32>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
text: text.to_string(),
|
||||||
|
font_family: font_family.map(|s| s.to_string()),
|
||||||
|
font_size_bits: font_size_pt.to_bits(),
|
||||||
|
font_weight: font_weight.map(|s| s.to_string()),
|
||||||
|
available_width_bits: available_width_pt.map(|w| w.to_bits()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Text ölçüm motoru. cosmic-text kullanarak verilen font, boyut ve
|
||||||
|
/// mevcut genişlik kısıtı ile text'in kaplayacağı alanı hesaplar.
|
||||||
|
/// Ölçüm sonuçları cache'lenir — aynı parametrelerle tekrar çağrılırsa
|
||||||
|
/// cosmic-text'e gitmeden cache'ten döner.
|
||||||
|
pub struct TextMeasurer {
|
||||||
|
font_system: FontSystem,
|
||||||
|
cache: HashMap<MeasureCacheKey, (f32, f32)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// pt → px dönüşümü (cosmic-text px cinsinden çalışır, 1pt = 1.333px @96dpi)
|
||||||
|
const PT_TO_PX: f32 = 96.0 / 72.0;
|
||||||
|
|
||||||
|
impl TextMeasurer {
|
||||||
|
pub fn new(fonts: &[FontData]) -> Self {
|
||||||
|
let mut font_system = FontSystem::new_with_locale_and_db(
|
||||||
|
"tr-TR".to_string(),
|
||||||
|
cosmic_text::fontdb::Database::new(),
|
||||||
|
);
|
||||||
|
for font in fonts {
|
||||||
|
font_system.db_mut().load_font_data(font.data.clone());
|
||||||
|
}
|
||||||
|
Self {
|
||||||
|
font_system,
|
||||||
|
cache: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mevcut cache'i koruyarak yeni bir TextMeasurer oluştur.
|
||||||
|
/// Font seti değişmediyse eski cache geçerliliğini korur.
|
||||||
|
pub fn new_with_cache(fonts: &[FontData], cache: TextMeasureCache) -> Self {
|
||||||
|
let mut m = Self::new(fonts);
|
||||||
|
m.cache = cache.entries;
|
||||||
|
m
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cache'i dışarı taşı (persist etmek için).
|
||||||
|
pub fn take_cache(self) -> TextMeasureCache {
|
||||||
|
TextMeasureCache { entries: self.cache }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Text'i ölç. Dönen değerler pt cinsinden (width, height).
|
||||||
|
/// `available_width_pt`: Mevcut genişlik kısıtı (pt). None ise sınırsız.
|
||||||
|
/// Sonuç cache'lenir — aynı parametrelerle tekrar çağrılırsa cache'ten döner.
|
||||||
|
pub fn measure(
|
||||||
|
&mut self,
|
||||||
|
text: &str,
|
||||||
|
font_family: Option<&str>,
|
||||||
|
font_size_pt: f32,
|
||||||
|
font_weight: Option<&str>,
|
||||||
|
available_width_pt: Option<f32>,
|
||||||
|
) -> (f32, f32) {
|
||||||
|
if text.is_empty() {
|
||||||
|
return (0.0, font_size_pt * 1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
let key = MeasureCacheKey::new(text, font_family, font_size_pt, font_weight, available_width_pt);
|
||||||
|
|
||||||
|
if let Some(&cached) = self.cache.get(&key) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = self.measure_uncached(text, font_family, font_size_pt, font_weight, available_width_pt);
|
||||||
|
self.cache.insert(key, result);
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cache'siz ölçüm — cosmic-text ile gerçek hesaplama.
|
||||||
|
fn measure_uncached(
|
||||||
|
&mut self,
|
||||||
|
text: &str,
|
||||||
|
font_family: Option<&str>,
|
||||||
|
font_size_pt: f32,
|
||||||
|
font_weight: Option<&str>,
|
||||||
|
available_width_pt: Option<f32>,
|
||||||
|
) -> (f32, f32) {
|
||||||
|
let font_size_px = font_size_pt * PT_TO_PX;
|
||||||
|
let line_height_px = font_size_px * 1.2;
|
||||||
|
let metrics = Metrics::new(font_size_px, line_height_px);
|
||||||
|
|
||||||
|
let mut buffer = Buffer::new(&mut self.font_system, metrics);
|
||||||
|
|
||||||
|
let width_px = available_width_pt.map(|w| w * PT_TO_PX);
|
||||||
|
buffer.set_size(&mut self.font_system, width_px, None);
|
||||||
|
|
||||||
|
let weight = match font_weight {
|
||||||
|
Some("bold") => Weight::BOLD,
|
||||||
|
_ => Weight::NORMAL,
|
||||||
|
};
|
||||||
|
|
||||||
|
let family_name = font_family.unwrap_or("Noto Sans");
|
||||||
|
let attrs = Attrs::new()
|
||||||
|
.family(Family::Name(family_name))
|
||||||
|
.weight(weight);
|
||||||
|
|
||||||
|
buffer.set_text(&mut self.font_system, text, attrs, Shaping::Advanced);
|
||||||
|
buffer.shape_until_scroll(&mut self.font_system, false);
|
||||||
|
|
||||||
|
let mut max_width: f32 = 0.0;
|
||||||
|
let mut total_height: f32 = 0.0;
|
||||||
|
|
||||||
|
for run in buffer.layout_runs() {
|
||||||
|
let run_width = run.line_w;
|
||||||
|
if run_width > max_width {
|
||||||
|
max_width = run_width;
|
||||||
|
}
|
||||||
|
total_height = run.line_top + line_height_px;
|
||||||
|
}
|
||||||
|
|
||||||
|
if total_height == 0.0 {
|
||||||
|
total_height = line_height_px;
|
||||||
|
}
|
||||||
|
|
||||||
|
let width_pt = max_width / PT_TO_PX;
|
||||||
|
let height_pt = total_height / PT_TO_PX;
|
||||||
|
|
||||||
|
// Text genişliğine küçük bir tolerans ekle (0.5pt ≈ 0.18mm).
|
||||||
|
// cosmic-text ile browser font engine'i farklı subpixel sonuçlar üretir;
|
||||||
|
// bu fark zoom değişimlerinde text wrap sınırında flickering'e yol açar.
|
||||||
|
// 0.5pt baskıda görünmez ama wrapping dengesizliğini önler.
|
||||||
|
let width_pt = width_pt + 0.5;
|
||||||
|
|
||||||
|
(width_pt, height_pt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn load_test_fonts() -> Vec<crate::FontData> {
|
||||||
|
let font_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.parent()
|
||||||
|
.unwrap()
|
||||||
|
.join("backend/fonts");
|
||||||
|
|
||||||
|
let mut fonts = Vec::new();
|
||||||
|
for entry in std::fs::read_dir(&font_dir).expect("backend/fonts dizini bulunamadı") {
|
||||||
|
let entry = entry.unwrap();
|
||||||
|
let path = entry.path();
|
||||||
|
if path.extension().is_some_and(|e| e == "ttf") {
|
||||||
|
let data = std::fs::read(&path).unwrap();
|
||||||
|
let family = if path
|
||||||
|
.file_name()
|
||||||
|
.unwrap()
|
||||||
|
.to_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("Mono")
|
||||||
|
{
|
||||||
|
"Noto Sans Mono".to_string()
|
||||||
|
} else {
|
||||||
|
"Noto Sans".to_string()
|
||||||
|
};
|
||||||
|
fonts.push(crate::FontData { family, data });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fonts
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn make_measurer() -> TextMeasurer {
|
||||||
|
TextMeasurer::new(&load_test_fonts())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_empty_text() {
|
||||||
|
let mut m = make_measurer();
|
||||||
|
let (w, h) = m.measure("", None, 12.0, None, None);
|
||||||
|
assert_eq!(w, 0.0);
|
||||||
|
assert!(h > 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_basic_measurement() {
|
||||||
|
let mut m = make_measurer();
|
||||||
|
let (w, h) = m.measure("Hello", None, 12.0, None, None);
|
||||||
|
assert!(w > 0.0, "Width should be positive, got {w}");
|
||||||
|
assert!(h > 0.0, "Height should be positive, got {h}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cache_returns_same_result() {
|
||||||
|
let mut m = make_measurer();
|
||||||
|
let (w1, h1) = m.measure("Cache test", None, 14.0, Some("bold"), Some(100.0));
|
||||||
|
let (w2, h2) = m.measure("Cache test", None, 14.0, Some("bold"), Some(100.0));
|
||||||
|
assert_eq!(w1, w2);
|
||||||
|
assert_eq!(h1, h2);
|
||||||
|
// Cache'te 1 entry olmalı (aynı key iki kere çağrıldı)
|
||||||
|
assert_eq!(m.cache.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cache_persists_across_measurers() {
|
||||||
|
let fonts = load_test_fonts();
|
||||||
|
let mut m1 = TextMeasurer::new(&fonts);
|
||||||
|
let (w1, h1) = m1.measure("Persist test", None, 12.0, None, None);
|
||||||
|
let cache = m1.take_cache();
|
||||||
|
|
||||||
|
let mut m2 = TextMeasurer::new_with_cache(&fonts, cache);
|
||||||
|
assert_eq!(m2.cache.len(), 1);
|
||||||
|
let (w2, h2) = m2.measure("Persist test", None, 12.0, None, None);
|
||||||
|
assert_eq!(w1, w2);
|
||||||
|
assert_eq!(h1, h2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_wrapping_reduces_width() {
|
||||||
|
let mut m = make_measurer();
|
||||||
|
// Sınırsız genişlikte ölç
|
||||||
|
let (w_unlimited, h_unlimited) =
|
||||||
|
m.measure("This is a longer text that should wrap", None, 12.0, None, None);
|
||||||
|
// Dar genişlikte ölç
|
||||||
|
let (w_narrow, h_narrow) =
|
||||||
|
m.measure("This is a longer text that should wrap", None, 12.0, None, Some(50.0));
|
||||||
|
|
||||||
|
// Dar genişlikte yükseklik artmalı (wrapping oldu)
|
||||||
|
assert!(
|
||||||
|
h_narrow >= h_unlimited,
|
||||||
|
"Wrapped height ({h_narrow}) should be >= unlimited height ({h_unlimited})"
|
||||||
|
);
|
||||||
|
// Dar genişlikte genişlik kısıtlanmış olmalı
|
||||||
|
assert!(
|
||||||
|
w_narrow <= w_unlimited + 1.0,
|
||||||
|
"Wrapped width ({w_narrow}) should be <= unlimited width ({w_unlimited})"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
1027
layout-engine/src/tree.rs
Normal file
1027
layout-engine/src/tree.rs
Normal file
File diff suppressed because it is too large
Load Diff
138
layout-engine/src/wasm_api.rs
Normal file
138
layout-engine/src/wasm_api.rs
Normal 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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user