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]
|
||||
members = ["core", "backend"]
|
||||
members = ["core", "backend", "layout-engine"]
|
||||
resolver = "2"
|
||||
|
||||
@@ -5,6 +5,7 @@ edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
dreport-core = { path = "../core" }
|
||||
dreport-layout = { path = "../layout-engine" }
|
||||
axum = "0.8"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
@@ -12,7 +13,3 @@ serde_json = "1"
|
||||
tower-http = { version = "0.6", features = ["cors"] }
|
||||
thiserror = "2"
|
||||
anyhow = "1"
|
||||
typst = "0.14"
|
||||
typst-pdf = "0.14"
|
||||
typst-kit = { version = "0.14", features = ["fonts"] }
|
||||
chrono = "0.4"
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
use axum::{Router, serve};
|
||||
use dreport_layout::FontData;
|
||||
use std::sync::Arc;
|
||||
use tokio::net::TcpListener;
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
|
||||
mod models;
|
||||
mod routes;
|
||||
mod typst_engine;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
// Fontları bir kez yükle — tüm request'lerde paylaşılacak
|
||||
println!("Fontlar yukleniyor...");
|
||||
let fonts = Arc::new(typst_engine::fonts::load_fonts());
|
||||
println!("Fontlar yuklendi ({} font)", fonts.fonts.len());
|
||||
let fonts = Arc::new(load_fonts());
|
||||
println!("Fontlar yuklendi ({} font dosyasi)", fonts.len());
|
||||
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
@@ -30,3 +29,31 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Proje fontlarını yükler (backend/fonts/ dizininden).
|
||||
fn load_fonts() -> Vec<FontData> {
|
||||
let font_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("fonts");
|
||||
let mut fonts = Vec::new();
|
||||
|
||||
let entries = std::fs::read_dir(&font_dir).expect("backend/fonts dizini bulunamadi");
|
||||
for entry in entries {
|
||||
let entry = entry.unwrap();
|
||||
let path = entry.path();
|
||||
if path.extension().is_some_and(|e| e == "ttf" || e == "otf") {
|
||||
let data = std::fs::read(&path).unwrap();
|
||||
let family = if path
|
||||
.file_name()
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.contains("Mono")
|
||||
{
|
||||
"Noto Sans Mono".to_string()
|
||||
} else {
|
||||
"Noto Sans".to_string()
|
||||
};
|
||||
fonts.push(FontData { family, data });
|
||||
}
|
||||
}
|
||||
fonts
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use axum::{Router, routing::get, Json};
|
||||
use dreport_layout::FontData;
|
||||
use serde::Serialize;
|
||||
use std::sync::Arc;
|
||||
use typst_kit::fonts::Fonts;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct HealthResponse {
|
||||
@@ -16,6 +16,6 @@ async fn health() -> Json<HealthResponse> {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn router() -> Router<Arc<Fonts>> {
|
||||
pub fn router() -> Router<Arc<Vec<FontData>>> {
|
||||
Router::new().route("/api/health", get(health))
|
||||
}
|
||||
|
||||
@@ -2,10 +2,10 @@ mod health;
|
||||
mod render;
|
||||
|
||||
use axum::Router;
|
||||
use dreport_layout::FontData;
|
||||
use std::sync::Arc;
|
||||
use typst_kit::fonts::Fonts;
|
||||
|
||||
pub fn router() -> Router<Arc<Fonts>> {
|
||||
pub fn router() -> Router<Arc<Vec<FontData>>> {
|
||||
Router::new()
|
||||
.merge(health::router())
|
||||
.merge(render::router())
|
||||
|
||||
@@ -6,13 +6,11 @@ use axum::{
|
||||
routing::post,
|
||||
Json,
|
||||
};
|
||||
use dreport_layout::FontData;
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::models::Template;
|
||||
use crate::typst_engine::compiler::compile_pdf;
|
||||
use crate::typst_engine::template_to_typst::{self, RenderMode};
|
||||
use typst_kit::fonts::Fonts;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RenderRequest {
|
||||
@@ -22,17 +20,14 @@ pub struct RenderRequest {
|
||||
|
||||
/// POST /api/render — Template + Data → PDF
|
||||
pub async fn render(
|
||||
State(fonts): State<Arc<Fonts>>,
|
||||
State(fonts): State<Arc<Vec<FontData>>>,
|
||||
Json(payload): Json<RenderRequest>,
|
||||
) -> impl IntoResponse {
|
||||
// 1. Template JSON → Typst markup
|
||||
let typst_markup = template_to_typst::template_to_typst(&payload.template, &payload.data, RenderMode::Pdf);
|
||||
// 1. Layout hesapla
|
||||
let layout = dreport_layout::compute_layout(&payload.template, &payload.data, &fonts);
|
||||
|
||||
// 2. Base64 image'ları çıkar
|
||||
let files = template_to_typst::extract_image_files(&payload.template);
|
||||
|
||||
// 3. Typst markup → PDF
|
||||
match compile_pdf(typst_markup, &fonts, files) {
|
||||
// 2. PDF render
|
||||
match dreport_layout::pdf_render::render_pdf(&layout, &fonts) {
|
||||
Ok(pdf_bytes) => (
|
||||
StatusCode::OK,
|
||||
[(header::CONTENT_TYPE, "application/pdf")],
|
||||
@@ -41,12 +36,12 @@ pub async fn render(
|
||||
.into_response(),
|
||||
Err(err) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("PDF derleme hatasi: {}", err),
|
||||
format!("PDF render hatasi: {}", err),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn router() -> Router<Arc<Fonts>> {
|
||||
pub fn router() -> Router<Arc<Vec<FontData>>> {
|
||||
Router::new().route("/api/render", post(render))
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct SizeConstraint {
|
||||
pub width: SizeValue,
|
||||
pub height: SizeValue,
|
||||
@@ -24,25 +24,43 @@ pub struct SizeConstraint {
|
||||
pub max_height: Option<f64>,
|
||||
}
|
||||
|
||||
impl Default for SizeConstraint {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
width: SizeValue::Auto,
|
||||
height: SizeValue::Auto,
|
||||
min_width: None,
|
||||
min_height: None,
|
||||
max_width: None,
|
||||
max_height: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PageSettings {
|
||||
pub width: f64,
|
||||
pub height: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct Padding {
|
||||
#[serde(default)]
|
||||
pub top: f64,
|
||||
#[serde(default)]
|
||||
pub right: f64,
|
||||
#[serde(default)]
|
||||
pub bottom: f64,
|
||||
#[serde(default)]
|
||||
pub left: f64,
|
||||
}
|
||||
|
||||
// --- Positioning ---
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum PositionMode {
|
||||
#[default]
|
||||
#[serde(rename = "flow")]
|
||||
Flow,
|
||||
#[serde(rename = "absolute")]
|
||||
@@ -124,6 +142,7 @@ pub struct TableStyle {
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct BarcodeStyle {
|
||||
pub color: Option<String>,
|
||||
pub include_text: Option<bool>,
|
||||
}
|
||||
|
||||
// --- Element tipleri ---
|
||||
@@ -200,17 +219,30 @@ impl TemplateElement {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ContainerElement {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub position: PositionMode,
|
||||
#[serde(default)]
|
||||
pub size: SizeConstraint,
|
||||
#[serde(default = "default_column")]
|
||||
pub direction: String,
|
||||
#[serde(default)]
|
||||
pub gap: f64,
|
||||
#[serde(default)]
|
||||
pub padding: Padding,
|
||||
#[serde(default = "default_stretch")]
|
||||
pub align: String,
|
||||
#[serde(default = "default_start")]
|
||||
pub justify: String,
|
||||
#[serde(default)]
|
||||
pub style: ContainerStyle,
|
||||
#[serde(default)]
|
||||
pub children: Vec<TemplateElement>,
|
||||
}
|
||||
|
||||
fn default_column() -> String { "column".to_string() }
|
||||
fn default_stretch() -> String { "stretch".to_string() }
|
||||
fn default_start() -> String { "start".to_string() }
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StaticTextElement {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { computed, ref, watch, provide, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useTemplateStore } from '../../stores/template'
|
||||
import { useEditorStore } from '../../stores/editor'
|
||||
import { useTypstCompiler } from '../../composables/useTypstCompiler'
|
||||
import TypstSvgLayer from './TypstSvgLayer.vue'
|
||||
import { useLayoutEngine } from '../../composables/useLayoutEngine'
|
||||
import LayoutRenderer from './LayoutRenderer.vue'
|
||||
import InteractionOverlay from './InteractionOverlay.vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
@@ -15,7 +15,7 @@ const props = withDefaults(defineProps<{
|
||||
|
||||
const templateStore = useTemplateStore()
|
||||
const editorStore = useEditorStore()
|
||||
const { template, mockData } = storeToRefs(templateStore)
|
||||
const { template, mockData, layoutVersion } = storeToRefs(templateStore)
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const containerWidth = ref(800)
|
||||
@@ -24,8 +24,11 @@ const emit = defineEmits<{
|
||||
'compile-error': [error: string | null]
|
||||
}>()
|
||||
|
||||
// Typst compiler — template + data'yı worker'a gönderir, WASM ile derlenir
|
||||
const { svg, error, compiling, layout, dispose } = useTypstCompiler(template, mockData)
|
||||
// Layout engine — template + data'yı worker'a gönderir, WASM ile layout hesaplar
|
||||
const { layout, layoutMap, error, computing: compiling, generateBarcode, dispose } = useLayoutEngine(template, mockData, layoutVersion)
|
||||
|
||||
// LayoutRenderer'ın barcode üretmek için kullanabileceği fonksiyon
|
||||
provide('generateBarcode', generateBarcode)
|
||||
|
||||
watch(error, (val) => emit('compile-error', val))
|
||||
|
||||
@@ -89,15 +92,76 @@ onBeforeUnmount(() => {
|
||||
window.removeEventListener('keyup', onKeyUp)
|
||||
})
|
||||
|
||||
// Zoom
|
||||
// Zoom & Pan via wheel/trackpad
|
||||
const pageRef = ref<HTMLElement | null>(null)
|
||||
|
||||
let zoomRAF: number | null = null
|
||||
let zoomDeltaAccum = 0
|
||||
let zoomClientX = 0
|
||||
let zoomClientY = 0
|
||||
|
||||
function onWheel(e: WheelEvent) {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault()
|
||||
const delta = e.deltaY > 0 ? -0.1 : 0.1
|
||||
editorStore.setZoom(editorStore.zoom + delta)
|
||||
|
||||
zoomDeltaAccum += e.deltaY
|
||||
zoomClientX = e.clientX
|
||||
zoomClientY = e.clientY
|
||||
|
||||
if (zoomRAF === null) {
|
||||
zoomRAF = requestAnimationFrame(() => {
|
||||
const delta = Math.max(-4, Math.min(4, zoomDeltaAccum))
|
||||
if (Math.abs(delta) > 0.01) {
|
||||
applyZoom(delta, zoomClientX, zoomClientY)
|
||||
}
|
||||
zoomDeltaAccum = 0
|
||||
zoomRAF = null
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// İki parmak pan (touchpad) veya normal scroll
|
||||
e.preventDefault()
|
||||
editorStore.setPan(
|
||||
editorStore.panX - e.deltaX,
|
||||
editorStore.panY - e.deltaY,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function applyZoom(delta: number, clientX: number, clientY: number) {
|
||||
const pageEl = pageRef.value
|
||||
if (!pageEl) return
|
||||
|
||||
const oldZoom = editorStore.zoom
|
||||
const zoomFactor = Math.pow(0.99, delta)
|
||||
const newZoom = Math.max(0.25, Math.min(4, oldZoom * zoomFactor))
|
||||
if (newZoom === oldZoom) return
|
||||
|
||||
// Sayfa elemanının şu anki ekran pozisyonunu al (centering + pan dahil)
|
||||
const pageRect = pageEl.getBoundingClientRect()
|
||||
|
||||
// Mouse'un sayfa üzerindeki pozisyonu (mm cinsinden)
|
||||
const baseScale = containerWidth.value / templateStore.template.page.width
|
||||
const oldScale = baseScale * oldZoom
|
||||
const newScale = baseScale * newZoom
|
||||
const mousePageMmX = (clientX - pageRect.left) / oldScale
|
||||
const mousePageMmY = (clientY - pageRect.top) / oldScale
|
||||
|
||||
// Flex centering kayması: sayfa genişliği değişince ortalama kayar
|
||||
// X ekseni: justify-content: center → kayma = (eskiBoyut - yeniBoyut) / 2
|
||||
const pageW = templateStore.template.page.width
|
||||
const centerShiftX = pageW * (oldScale - newScale) / 2
|
||||
// Y ekseni: align-items: flex-start → kayma yok
|
||||
const centerShiftY = 0
|
||||
|
||||
// Yeni pan: mouse'un gösterdiği mm noktası aynı ekran pozisyonunda kalmalı
|
||||
const newPanX = editorStore.panX + (mousePageMmX - pageW / 2) * (oldScale - newScale)
|
||||
const newPanY = editorStore.panY + mousePageMmY * (oldScale - newScale)
|
||||
|
||||
editorStore.setZoom(newZoom)
|
||||
editorStore.setPan(newPanX, newPanY)
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.code === 'Space' && !e.repeat && !(e.target instanceof HTMLInputElement || e.target instanceof HTMLSelectElement || e.target instanceof HTMLTextAreaElement)) {
|
||||
e.preventDefault()
|
||||
@@ -146,9 +210,9 @@ function onPointerUp(e: PointerEvent) {
|
||||
@pointerup="onPointerUp"
|
||||
>
|
||||
<!-- Sayfa -->
|
||||
<div class="editor-canvas__page" :style="[pageStyle, panTransform ? { transform: panTransform } : {}]">
|
||||
<TypstSvgLayer :svg="svg" />
|
||||
<InteractionOverlay :scale="scale" :layout="layout" :page-width-pt="templateStore.template.page.width * 2.8346" />
|
||||
<div ref="pageRef" class="editor-canvas__page" :style="[pageStyle, panTransform ? { transform: panTransform } : {}]">
|
||||
<LayoutRenderer :layout="layout" :scale="scale" />
|
||||
<InteractionOverlay :scale="scale" :layout-map="layoutMap" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -170,12 +234,14 @@ function onPointerUp(e: PointerEvent) {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor-canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
overflow: hidden;
|
||||
background: #e5e7eb;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
|
||||
@@ -2,25 +2,18 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { useTemplateStore } from '../../stores/template'
|
||||
import { useEditorStore } from '../../stores/editor'
|
||||
import type { ElementLayout } from '../../core/template-to-typst'
|
||||
import type { ElementLayout } from '../../core/layout-types'
|
||||
import type { TemplateElement, SizeValue, ContainerElement } from '../../core/types'
|
||||
import { isContainer, sz } from '../../core/types'
|
||||
|
||||
const props = defineProps<{
|
||||
scale: number
|
||||
layout: Record<string, ElementLayout>
|
||||
pageWidthPt: number
|
||||
layoutMap: Record<string, ElementLayout>
|
||||
}>()
|
||||
|
||||
const templateStore = useTemplateStore()
|
||||
const editorStore = useEditorStore()
|
||||
|
||||
// pt→px dönüşüm katsayısı
|
||||
const ptToPx = computed(() => {
|
||||
const pageWidthPx = templateStore.template.page.width * props.scale
|
||||
return props.pageWidthPt > 0 ? pageWidthPx / props.pageWidthPt : 1
|
||||
})
|
||||
|
||||
// Tüm elemanları flat olarak topla (root hariç)
|
||||
const flatElements = computed(() => {
|
||||
const result: TemplateElement[] = []
|
||||
@@ -50,20 +43,20 @@ const allContainers = computed(() => {
|
||||
})
|
||||
|
||||
function getElementStyle(el: TemplateElement) {
|
||||
const l = props.layout[el.id]
|
||||
const l = props.layoutMap[el.id]
|
||||
if (!l) return { display: 'none' }
|
||||
|
||||
const s = ptToPx.value
|
||||
const h = l.height * s
|
||||
const s = props.scale
|
||||
const h = l.height_mm * s
|
||||
const minH = 8
|
||||
const actualH = Math.max(h, minH)
|
||||
const yOffset = h < minH ? (minH - h) / 2 : 0
|
||||
|
||||
return {
|
||||
position: 'absolute' as const,
|
||||
left: `${l.x * s}px`,
|
||||
top: `${l.y * s - yOffset}px`,
|
||||
width: `${l.width * s}px`,
|
||||
left: `${l.x_mm * s}px`,
|
||||
top: `${l.y_mm * s - yOffset}px`,
|
||||
width: `${l.width_mm * s}px`,
|
||||
height: `${actualH}px`,
|
||||
}
|
||||
}
|
||||
@@ -90,23 +83,23 @@ const dropLogicalIndex = ref<number | null>(null)
|
||||
|
||||
/** Mouse pozisyonuna göre en derin container'ı bul */
|
||||
function findDeepestContainer(mouseX: number, mouseY: number, excludeId?: string): ContainerElement {
|
||||
const s = ptToPx.value
|
||||
const s = props.scale
|
||||
let best: ContainerElement = templateStore.template.root
|
||||
|
||||
for (const c of allContainers.value) {
|
||||
if (c.id === excludeId) continue
|
||||
const l = props.layout[c.id]
|
||||
const l = props.layoutMap[c.id]
|
||||
if (!l) continue
|
||||
|
||||
const cx = l.x * s
|
||||
const cy = l.y * s
|
||||
const cw = l.width * s
|
||||
const ch = l.height * s
|
||||
const cx = l.x_mm * s
|
||||
const cy = l.y_mm * s
|
||||
const cw = l.width_mm * s
|
||||
const ch = l.height_mm * s
|
||||
|
||||
if (mouseX >= cx && mouseX <= cx + cw && mouseY >= cy && mouseY <= cy + ch) {
|
||||
// Daha küçük (daha derin) container'ı tercih et
|
||||
const bestL = props.layout[best.id]
|
||||
if (!bestL || (cw * ch < bestL.width * s * bestL.height * s)) {
|
||||
const bestL = props.layoutMap[best.id]
|
||||
if (!bestL || (cw * ch < bestL.width_mm * s * bestL.height_mm * s)) {
|
||||
best = c
|
||||
}
|
||||
}
|
||||
@@ -116,20 +109,20 @@ function findDeepestContainer(mouseX: number, mouseY: number, excludeId?: string
|
||||
|
||||
/** Container içinde drop index hesapla */
|
||||
function computeDropIndex(container: ContainerElement, mouseX: number, mouseY: number, excludeId?: string) {
|
||||
const s = ptToPx.value
|
||||
const s = props.scale
|
||||
const flowChildren = container.children.filter(c => c.position.type !== 'absolute' && c.id !== excludeId)
|
||||
const isRow = container.direction === 'row'
|
||||
|
||||
let visualIdx = flowChildren.length
|
||||
|
||||
for (let i = 0; i < flowChildren.length; i++) {
|
||||
const l = props.layout[flowChildren[i].id]
|
||||
const l = props.layoutMap[flowChildren[i].id]
|
||||
if (!l) continue
|
||||
if (isRow) {
|
||||
const centerX = l.x * s + (l.width * s) / 2
|
||||
const centerX = l.x_mm * s + (l.width_mm * s) / 2
|
||||
if (mouseX < centerX) { visualIdx = i; break }
|
||||
} else {
|
||||
const centerY = l.y * s + (l.height * s) / 2
|
||||
const centerY = l.y_mm * s + (l.height_mm * s) / 2
|
||||
if (mouseY < centerY) { visualIdx = i; break }
|
||||
}
|
||||
}
|
||||
@@ -184,7 +177,7 @@ const dropIndicatorStyle = computed(() => {
|
||||
const container = templateStore.getElementById(dropTargetContainerId.value)
|
||||
if (!container || !isContainer(container)) return { display: 'none' }
|
||||
|
||||
const s = ptToPx.value
|
||||
const s = props.scale
|
||||
const idx = dropVisualIndex.value
|
||||
const isRow = container.direction === 'row'
|
||||
|
||||
@@ -192,37 +185,37 @@ const dropIndicatorStyle = computed(() => {
|
||||
const dragId = dragElementId.value
|
||||
const flowChildren = container.children.filter(c => c.position.type !== 'absolute' && c.id !== dragId)
|
||||
|
||||
const cl = props.layout[container.id]
|
||||
const cl = props.layoutMap[container.id]
|
||||
if (!cl) return { display: 'none' }
|
||||
|
||||
if (isRow) {
|
||||
// Row container: dikey gösterge çizgisi
|
||||
let x = 0
|
||||
if (idx === 0 && flowChildren.length > 0) {
|
||||
const l = props.layout[flowChildren[0].id]
|
||||
if (l) x = (cl.x * s + l.x * s) / 2
|
||||
else x = cl.x * s
|
||||
const l = props.layoutMap[flowChildren[0].id]
|
||||
if (l) x = (cl.x_mm * s + l.x_mm * s) / 2
|
||||
else x = cl.x_mm * s
|
||||
} else if (idx < flowChildren.length && idx > 0) {
|
||||
const left = props.layout[flowChildren[idx - 1].id]
|
||||
const right = props.layout[flowChildren[idx].id]
|
||||
const left = props.layoutMap[flowChildren[idx - 1].id]
|
||||
const right = props.layoutMap[flowChildren[idx].id]
|
||||
if (left && right) {
|
||||
const leftEnd = (left.x + left.width) * s
|
||||
const rightStart = right.x * s
|
||||
const leftEnd = (left.x_mm + left.width_mm) * s
|
||||
const rightStart = right.x_mm * s
|
||||
x = (leftEnd + rightStart) / 2
|
||||
}
|
||||
} else if (idx === 0 && flowChildren.length === 0) {
|
||||
x = cl.x * s + 8
|
||||
x = cl.x_mm * s + 8
|
||||
} else if (flowChildren.length > 0) {
|
||||
const last = flowChildren[flowChildren.length - 1]
|
||||
const l = props.layout[last.id]
|
||||
const l = props.layoutMap[last.id]
|
||||
if (l) {
|
||||
const gapPx = container.gap * props.scale
|
||||
x = (l.x + l.width) * s + gapPx / 2
|
||||
x = (l.x_mm + l.width_mm) * s + gapPx / 2
|
||||
}
|
||||
}
|
||||
|
||||
const top = cl.y * s
|
||||
const height = cl.height * s
|
||||
const top = cl.y_mm * s
|
||||
const height = cl.height_mm * s
|
||||
|
||||
return {
|
||||
position: 'absolute' as const,
|
||||
@@ -240,33 +233,33 @@ const dropIndicatorStyle = computed(() => {
|
||||
// Column container: yatay gösterge çizgisi
|
||||
let y = 0
|
||||
if (idx === 0 && flowChildren.length > 0) {
|
||||
const l = props.layout[flowChildren[0].id]
|
||||
const l = props.layoutMap[flowChildren[0].id]
|
||||
if (l) {
|
||||
y = (cl.y * s + l.y * s) / 2
|
||||
y = (cl.y_mm * s + l.y_mm * s) / 2
|
||||
} else {
|
||||
y = cl.y * s - 4
|
||||
y = cl.y_mm * s - 4
|
||||
}
|
||||
} else if (idx < flowChildren.length && idx > 0) {
|
||||
const above = props.layout[flowChildren[idx - 1].id]
|
||||
const below = props.layout[flowChildren[idx].id]
|
||||
const above = props.layoutMap[flowChildren[idx - 1].id]
|
||||
const below = props.layoutMap[flowChildren[idx].id]
|
||||
if (above && below) {
|
||||
const aboveBottom = (above.y + above.height) * s
|
||||
const belowTop = below.y * s
|
||||
const aboveBottom = (above.y_mm + above.height_mm) * s
|
||||
const belowTop = below.y_mm * s
|
||||
y = (aboveBottom + belowTop) / 2
|
||||
}
|
||||
} else if (idx === 0 && flowChildren.length === 0) {
|
||||
y = cl.y * s + 8
|
||||
y = cl.y_mm * s + 8
|
||||
} else if (flowChildren.length > 0) {
|
||||
const last = flowChildren[flowChildren.length - 1]
|
||||
const l = props.layout[last.id]
|
||||
const l = props.layoutMap[last.id]
|
||||
if (l) {
|
||||
const gapPx = container.gap * props.scale
|
||||
y = (l.y + l.height) * s + gapPx / 2
|
||||
y = (l.y_mm + l.height_mm) * s + gapPx / 2
|
||||
}
|
||||
}
|
||||
|
||||
const x = cl.x * s
|
||||
const width = cl.width * s
|
||||
const x = cl.x_mm * s
|
||||
const width = cl.width_mm * s
|
||||
|
||||
return {
|
||||
position: 'absolute' as const,
|
||||
@@ -297,20 +290,20 @@ function onDragStart(e: PointerEvent, el: TemplateElement) {
|
||||
return
|
||||
}
|
||||
|
||||
const l = props.layout[el.id]
|
||||
const l = props.layoutMap[el.id]
|
||||
if (!l) return
|
||||
|
||||
const s = ptToPx.value
|
||||
const s = props.scale
|
||||
dragElementId.value = el.id
|
||||
didDrag.value = false
|
||||
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
||||
dragOffset.value = { x: e.clientX - rect.left, y: e.clientY - rect.top }
|
||||
dragGhost.value = {
|
||||
x: l.x * s,
|
||||
y: l.y * s,
|
||||
width: l.width * s,
|
||||
height: l.height * s,
|
||||
x: l.x_mm * s,
|
||||
y: l.y_mm * s,
|
||||
width: l.width_mm * s,
|
||||
height: l.height_mm * s,
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', onDragMove)
|
||||
@@ -440,27 +433,26 @@ function onResizeStart(e: PointerEvent, elId: string, handle: string) {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
|
||||
const l = props.layout[elId]
|
||||
const l = props.layoutMap[elId]
|
||||
if (!l) return
|
||||
|
||||
resizeElementId.value = elId
|
||||
resizeHandle.value = handle
|
||||
isResizing.value = true
|
||||
|
||||
const s = ptToPx.value
|
||||
const ptToMm = 1 / 2.8346
|
||||
const s = props.scale
|
||||
|
||||
// Barkod elemanları için aspect ratio'yu kaydet
|
||||
// Barkod ve görsel elemanları için aspect ratio'yu kaydet
|
||||
const el = flatElements.value.find(e => e.id === elId)
|
||||
resizeAspectRatio.value = (el?.type === 'barcode' && l.height > 0) ? l.width / l.height : 0
|
||||
resizeAspectRatio.value = ((el?.type === 'barcode' || el?.type === 'image') && l.height_mm > 0) ? l.width_mm / l.height_mm : 0
|
||||
|
||||
resizeStart.value = {
|
||||
mouseX: e.clientX, mouseY: e.clientY,
|
||||
x: l.x * s, y: l.y * s,
|
||||
width: l.width * s, height: l.height * s,
|
||||
x: l.x_mm * s, y: l.y_mm * s,
|
||||
width: l.width_mm * s, height: l.height_mm * s,
|
||||
}
|
||||
resizeGhost.value = { x: l.x * s, y: l.y * s, width: l.width * s, height: l.height * s }
|
||||
resizeFinalMm.value = { width: l.width * ptToMm, height: l.height * ptToMm }
|
||||
resizeGhost.value = { x: l.x_mm * s, y: l.y_mm * s, width: l.width_mm * s, height: l.height_mm * s }
|
||||
resizeFinalMm.value = { width: l.width_mm, height: l.height_mm }
|
||||
|
||||
window.addEventListener('pointermove', onResizeMove)
|
||||
window.addEventListener('pointerup', onResizeEnd)
|
||||
@@ -511,9 +503,15 @@ function onResizeEnd() {
|
||||
|
||||
if (resizeElementId.value) {
|
||||
const handle = resizeHandle.value
|
||||
const ar = resizeAspectRatio.value
|
||||
const sizeUpdate: { width?: SizeValue; height?: SizeValue } = {}
|
||||
if (handle.includes('e') || handle.includes('w')) sizeUpdate.width = sz.fixed(resizeFinalMm.value.width)
|
||||
if (handle.includes('s') || handle.includes('n')) sizeUpdate.height = sz.fixed(resizeFinalMm.value.height)
|
||||
// Aspect ratio aktifken her zaman hem width hem height güncelle
|
||||
if (ar > 0) {
|
||||
sizeUpdate.width = sz.fixed(resizeFinalMm.value.width)
|
||||
sizeUpdate.height = sz.fixed(resizeFinalMm.value.height)
|
||||
}
|
||||
templateStore.updateElementSize(resizeElementId.value, sizeUpdate)
|
||||
}
|
||||
|
||||
@@ -589,8 +587,8 @@ const isAnyDragActive = computed(() =>
|
||||
|
||||
<!-- Resize handles -->
|
||||
<template v-if="editorStore.selectedElementId === el.id && !isResizing">
|
||||
<template v-if="el.type === 'barcode'">
|
||||
<!-- Barkod: sadece yatay resize (aspect ratio korunur) -->
|
||||
<template v-if="el.type === 'barcode' || el.type === 'image'">
|
||||
<!-- Barkod/Görsel: sadece yatay resize (aspect ratio korunur) -->
|
||||
<div class="resize-handle resize-handle--e" @pointerdown="(e: PointerEvent) => onResizeStart(e, el.id, 'e')" />
|
||||
<div class="resize-handle resize-handle--w" @pointerdown="(e: PointerEvent) => onResizeStart(e, el.id, 'w')" />
|
||||
</template>
|
||||
|
||||
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 class="prop-section__title">Çizgi Stili</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Kalınlık (pt)</label>
|
||||
<input class="prop-input" type="number" step="0.25" min="0.25"
|
||||
<label class="prop-label">Kalınlık (mm)</label>
|
||||
<input class="prop-input" type="number" step="0.1" min="0.1"
|
||||
:value="(selectedElement as LineElement).style.strokeWidth ?? 0.5"
|
||||
@input="(e) => updateStyle('strokeWidth', parseFloat((e.target as HTMLInputElement).value) || 0.5)" />
|
||||
</div>
|
||||
@@ -509,6 +509,12 @@ function deleteElement() {
|
||||
<button v-if="(selectedElement as BarcodeElement).style.color" class="prop-clear" @click="updateStyle('color', undefined)">x</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="(selectedElement as BarcodeElement).format !== 'qr'" class="prop-row">
|
||||
<label class="prop-label">Metin Goster</label>
|
||||
<input type="checkbox"
|
||||
:checked="(selectedElement as BarcodeElement).style.includeText ?? ((selectedElement as BarcodeElement).format === 'ean13' || (selectedElement as BarcodeElement).format === 'ean8')"
|
||||
@change="(e) => updateStyle('includeText', (e.target as HTMLInputElement).checked)" />
|
||||
</div>
|
||||
<div v-if="schemaStore.scalarFields.length > 0" class="prop-row">
|
||||
<label class="prop-label">Veri Baglama</label>
|
||||
<select class="prop-input prop-select"
|
||||
@@ -613,8 +619,8 @@ function deleteElement() {
|
||||
</div>
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Kenarlık (pt)</label>
|
||||
<input class="prop-input" type="number" step="0.5" min="0"
|
||||
<label class="prop-label">Kenarlık (mm)</label>
|
||||
<input class="prop-input" type="number" step="0.1" min="0"
|
||||
:value="(selectedElement as ContainerElement).style.borderWidth ?? 0"
|
||||
@input="(e) => updateStyle('borderWidth', parseFloat((e.target as HTMLInputElement).value) || 0)" />
|
||||
</div>
|
||||
@@ -638,8 +644,8 @@ function deleteElement() {
|
||||
</select>
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Radius (pt)</label>
|
||||
<input class="prop-input" type="number" step="1" min="0"
|
||||
<label class="prop-label">Radius (mm)</label>
|
||||
<input class="prop-input" type="number" step="0.5" min="0"
|
||||
:value="(selectedElement as ContainerElement).style.borderRadius ?? 0"
|
||||
@input="(e) => updateStyle('borderRadius', parseFloat((e.target as HTMLInputElement).value) || 0)" />
|
||||
</div>
|
||||
@@ -791,8 +797,8 @@ function deleteElement() {
|
||||
</div>
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label class="prop-label">Kenarlık (pt)</label>
|
||||
<input class="prop-input" type="number" step="0.25" min="0"
|
||||
<label class="prop-label">Kenarlık (mm)</label>
|
||||
<input class="prop-input" type="number" step="0.1" min="0"
|
||||
:value="(selectedElement as RepeatingTableElement).style.borderWidth ?? 0.5"
|
||||
@input="(e) => updateTableStyle('borderWidth', parseFloat((e.target as HTMLInputElement).value) || 0)" />
|
||||
</div>
|
||||
|
||||
137
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 {
|
||||
color?: string // ön plan rengi (varsayılan: siyah)
|
||||
includeText?: boolean // barkod altına değer yazılsın mı (QR hariç)
|
||||
}
|
||||
|
||||
// --- Element tipleri ---
|
||||
|
||||
@@ -110,8 +110,22 @@ function onKeyDown(e: KeyboardEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => window.addEventListener('keydown', onKeyDown))
|
||||
onBeforeUnmount(() => window.removeEventListener('keydown', onKeyDown))
|
||||
// Browser'ın native pinch-zoom'unu editör alanında engelle
|
||||
function onGlobalWheel(e: WheelEvent) {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
// passive: false olmadan preventDefault çalışmaz
|
||||
document.addEventListener('wheel', onGlobalWheel, { passive: false })
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
document.removeEventListener('wheel', onGlobalWheel)
|
||||
})
|
||||
|
||||
// --- Exposed API ---
|
||||
|
||||
@@ -179,6 +193,7 @@ defineExpose({
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dreport-editor__sidebar {
|
||||
|
||||
@@ -52,12 +52,36 @@ export const useTemplateStore = defineStore('template', () => {
|
||||
|
||||
const mockData = computed(() => overrideData.value ?? generateMockData(template.value))
|
||||
|
||||
/**
|
||||
* Layout version counter — her template/data mutasyonunda artar.
|
||||
* useLayoutEngine bu counter'ı izler (deep watch yerine).
|
||||
* Vue'nun tüm template ağacını recursive karşılaştırması yerine
|
||||
* tek bir sayı karşılaştırması yapılır.
|
||||
*/
|
||||
const layoutVersion = ref(0)
|
||||
|
||||
/** Layout yeniden hesaplamasını tetikle */
|
||||
function bumpLayoutVersion() {
|
||||
layoutVersion.value++
|
||||
}
|
||||
|
||||
function setOverrideData(data: Record<string, unknown> | null) {
|
||||
overrideData.value = data
|
||||
bumpLayoutVersion()
|
||||
}
|
||||
|
||||
// Undo / Redo
|
||||
const { undo, redo, canUndo, canRedo } = useUndoRedo(template)
|
||||
const { undo: _undo, redo: _redo, canUndo, canRedo } = useUndoRedo(template)
|
||||
|
||||
function undo() {
|
||||
_undo()
|
||||
bumpLayoutVersion()
|
||||
}
|
||||
|
||||
function redo() {
|
||||
_redo()
|
||||
bumpLayoutVersion()
|
||||
}
|
||||
|
||||
// --- Element CRUD ---
|
||||
|
||||
@@ -78,6 +102,7 @@ export const useTemplateStore = defineStore('template', () => {
|
||||
} else {
|
||||
parent.children.push(element)
|
||||
}
|
||||
bumpLayoutVersion()
|
||||
}
|
||||
|
||||
/** Element'i ağaçtan kaldır */
|
||||
@@ -85,13 +110,18 @@ export const useTemplateStore = defineStore('template', () => {
|
||||
const parent = getParent(elementId)
|
||||
if (!parent) return
|
||||
const idx = parent.children.findIndex(c => c.id === elementId)
|
||||
if (idx !== -1) parent.children.splice(idx, 1)
|
||||
if (idx !== -1) {
|
||||
parent.children.splice(idx, 1)
|
||||
bumpLayoutVersion()
|
||||
}
|
||||
}
|
||||
|
||||
/** Element'i başka bir container'a taşı */
|
||||
function moveElement(elementId: string, targetParentId: string, index?: number) {
|
||||
const el = getElementById(elementId)
|
||||
if (!el) return
|
||||
// removeElement bump'lar, addChild de bump'lar — ama tek mantıksal operasyon.
|
||||
// Fazladan 1 bump sorun değil (debounce var), ama istersek optimize edebiliriz.
|
||||
removeElement(elementId)
|
||||
addChild(targetParentId, el, index)
|
||||
}
|
||||
@@ -99,7 +129,10 @@ export const useTemplateStore = defineStore('template', () => {
|
||||
/** Absolute pozisyon güncelle */
|
||||
function updateElementPosition(elementId: string, position: PositionMode) {
|
||||
const el = getElementById(elementId)
|
||||
if (el) el.position = position
|
||||
if (el) {
|
||||
el.position = position
|
||||
bumpLayoutVersion()
|
||||
}
|
||||
}
|
||||
|
||||
/** Boyut güncelle */
|
||||
@@ -107,13 +140,17 @@ export const useTemplateStore = defineStore('template', () => {
|
||||
const el = getElementById(elementId)
|
||||
if (el) {
|
||||
el.size = { ...el.size, ...size }
|
||||
bumpLayoutVersion()
|
||||
}
|
||||
}
|
||||
|
||||
/** Herhangi bir element özelliğini güncelle */
|
||||
function updateElement(elementId: string, updates: Partial<TemplateElement>) {
|
||||
const el = getElementById(elementId)
|
||||
if (el) Object.assign(el, updates)
|
||||
if (el) {
|
||||
Object.assign(el, updates)
|
||||
bumpLayoutVersion()
|
||||
}
|
||||
}
|
||||
|
||||
/** Çocuk sırasını değiştir (aynı parent içinde) */
|
||||
@@ -122,6 +159,7 @@ export const useTemplateStore = defineStore('template', () => {
|
||||
if (!parent || !isContainer(parent)) return
|
||||
const [moved] = parent.children.splice(fromIndex, 1)
|
||||
parent.children.splice(toIndex, 0, moved)
|
||||
bumpLayoutVersion()
|
||||
}
|
||||
|
||||
/** Şablonu JSON olarak dışa aktar */
|
||||
@@ -133,16 +171,20 @@ export const useTemplateStore = defineStore('template', () => {
|
||||
function importTemplate(json: string) {
|
||||
const parsed = JSON.parse(json) as Template
|
||||
template.value = parsed
|
||||
bumpLayoutVersion()
|
||||
}
|
||||
|
||||
/** Yeni boş şablon oluştur */
|
||||
function resetTemplate() {
|
||||
template.value = createDefaultTemplate()
|
||||
bumpLayoutVersion()
|
||||
}
|
||||
|
||||
return {
|
||||
template,
|
||||
mockData,
|
||||
layoutVersion,
|
||||
bumpLayoutVersion,
|
||||
getElementById,
|
||||
getParent,
|
||||
addChild,
|
||||
|
||||
@@ -1,3 +1,43 @@
|
||||
@font-face {
|
||||
font-family: 'Noto Sans';
|
||||
src: url('/fonts/NotoSans-Regular.ttf') format('truetype');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Noto Sans';
|
||||
src: url('/fonts/NotoSans-Bold.ttf') format('truetype');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Noto Sans';
|
||||
src: url('/fonts/NotoSans-Italic.ttf') format('truetype');
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Noto Sans';
|
||||
src: url('/fonts/NotoSans-BoldItalic.ttf') format('truetype');
|
||||
font-weight: 700;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Noto Sans Mono';
|
||||
src: url('/fonts/NotoSansMono-Regular.ttf') format('truetype');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
@@ -12,6 +52,8 @@ html, body {
|
||||
color: #1e293b;
|
||||
background: #f1f5f9;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
/* Browser native pinch-zoom'u engelle — editörün kendi zoom'u var */
|
||||
touch-action: pan-x pan-y;
|
||||
}
|
||||
|
||||
#app {
|
||||
|
||||
88
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:
|
||||
cargo run -p dreport-backend
|
||||
|
||||
# Frontend + Backend birlikte
|
||||
# Frontend + Backend + WASM watch birlikte
|
||||
dev:
|
||||
just front & just back & wait
|
||||
just front & just back & just wasm-watch & wait
|
||||
|
||||
# WASM build (core -> frontend)
|
||||
# Layout engine WASM build
|
||||
wasm:
|
||||
wasm-pack build layout-engine --target web --release --out-dir ../frontend/src/core/wasm-pkg-layout
|
||||
mkdir -p frontend/src/core/wasm-layout frontend/public/wasm
|
||||
cp frontend/src/core/wasm-pkg-layout/dreport_layout.js frontend/src/core/wasm-layout/dreport_layout.js
|
||||
cp frontend/src/core/wasm-pkg-layout/dreport_layout.d.ts frontend/src/core/wasm-layout/dreport_layout.d.ts
|
||||
cp frontend/src/core/wasm-pkg-layout/dreport_layout_bg.wasm frontend/public/wasm/dreport_layout_bg.wasm
|
||||
cp frontend/src/core/wasm-pkg-layout/dreport_layout_bg.wasm.d.ts frontend/src/core/wasm-layout/dreport_layout_bg.wasm.d.ts
|
||||
|
||||
# Layout engine WASM watch (rebuild on change)
|
||||
wasm-watch:
|
||||
watchexec -w layout-engine/src -w core/src -e rs -- just wasm
|
||||
|
||||
# Eski core WASM build (typst-based, deprecated)
|
||||
wasm-legacy:
|
||||
wasm-pack build core --target web --release --out-dir ../frontend/src/core/wasm-pkg -- --features wasm
|
||||
cp frontend/src/core/wasm-pkg/dreport_core.js frontend/src/core/wasm/dreport_core.js
|
||||
cp frontend/src/core/wasm-pkg/dreport_core.d.ts frontend/src/core/wasm/dreport_core.d.ts
|
||||
|
||||
29
layout-engine/Cargo.toml
Normal file
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