visual testing & renderer improvement

This commit is contained in:
2026-04-06 09:29:09 +03:00
parent f04c39cb69
commit ad0d2fda0a
3 changed files with 255 additions and 98 deletions

View File

@@ -84,9 +84,9 @@ function shapeStyle(el: ElementLayout): Record<string, string> {
function lineStyle(el: ElementLayout): Record<string, string> {
const st = el.style
return {
borderTop: `${(st.strokeWidth ?? 0.5) * props.scale}px solid ${st.strokeColor ?? '#000'}`,
backgroundColor: st.strokeColor ?? '#000',
width: '100%',
height: '0',
height: '100%',
}
}
@@ -96,12 +96,17 @@ async function renderBarcodeToCanvas(canvas: HTMLCanvasElement, format: string,
if (!value || !generateBarcode) return
try {
// WASM'dan ksek çözünürlüklü pixel verisi al
// QR her zaman kare
// Eleman boyutlarından piksel boyutlarını hesapla (PDF ile aynı mantık: pt * 4)
// data-el-w ve data-el-h mm cinsinden, MM_TO_PT = 72/25.4 = 2.83465
const elWmm = parseFloat(canvas.dataset.elW || '30')
const elHmm = parseFloat(canvas.dataset.elH || '15')
const MM_TO_PT = 72 / 25.4
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)
const wPt = elWmm * MM_TO_PT
const hPt = elHmm * MM_TO_PT
const size = Math.max(1, Math.round(wPt * 4))
const barcodeHeight = Math.max(1, Math.round(hPt * 4))
const result = await generateBarcode(format, value, size, barcodeHeight, isQr ? false : includeText)
if (!result) return
// Canvas boyutlarını WASM çıktısına ayarla (crisp rendering)
@@ -248,6 +253,8 @@ watch(
:data-format="el.content.format"
:data-value="el.content.value"
:data-include-text="el.style.barcodeIncludeText ?? (el.content.format === 'ean13' || el.content.format === 'ean8')"
:data-el-w="el.width_mm"
:data-el-h="el.height_mm"
:style="{ width: '100%', height: '100%', display: 'block' }"
/>
<div v-else class="layout-el__placeholder">

View File

@@ -46,6 +46,52 @@ fn parse_color(hex: &str) -> rgb::Color {
rgb::Color::new(r, g, b)
}
/// Rounded rectangle path oluştur. radius 0 ise düz dikdörtgen.
fn build_rect_path(x: f32, y: f32, w: f32, h: f32, radius: f32) -> Option<krilla::geom::Path> {
let mut pb = PathBuilder::new();
if radius <= 0.0 {
if let Some(rect) = krilla::geom::Rect::from_xywh(x, y, w, h) {
pb.push_rect(rect);
}
} else {
// Radius'u yarım kenar uzunluğuyla sınırla
let r = radius.min(w / 2.0).min(h / 2.0);
// Cubic bezier kappa faktörü (daire yaklaşımı)
let k = r * 0.5522848;
// Sağ üst köşeden başla, saat yönünde
pb.move_to(x + r, y);
pb.line_to(x + w - r, y);
pb.cubic_to(x + w - r + k, y, x + w, y + r - k, x + w, y + r);
pb.line_to(x + w, y + h - r);
pb.cubic_to(x + w, y + h - r + k, x + w - r + k, y + h, x + w - r, y + h);
pb.line_to(x + r, y + h);
pb.cubic_to(x + r - k, y + h, x, y + h - r + k, x, y + h - r);
pb.line_to(x, y + r);
pb.cubic_to(x, y + r - k, x + r - k, y, x + r, y);
pb.close();
}
pb.finish()
}
/// Ellipse path oluştur (4 cubic bezier ile)
fn build_ellipse_path(x: f32, y: f32, w: f32, h: f32) -> Option<krilla::geom::Path> {
let mut pb = PathBuilder::new();
let cx = x + w / 2.0;
let cy = y + h / 2.0;
let rx = w / 2.0;
let ry = h / 2.0;
let kx = rx * 0.5522848;
let ky = ry * 0.5522848;
pb.move_to(cx, cy - ry);
pb.cubic_to(cx + kx, cy - ry, cx + rx, cy - ky, cx + rx, cy);
pb.cubic_to(cx + rx, cy + ky, cx + kx, cy + ry, cx, cy + ry);
pb.cubic_to(cx - kx, cy + ry, cx - rx, cy + ky, cx - rx, cy);
pb.cubic_to(cx - rx, cy - ky, cx - kx, cy - ry, cx, cy - ry);
pb.close();
pb.finish()
}
fn fill_from_color(color: rgb::Color) -> Fill {
Fill {
paint: color.into(),
@@ -54,18 +100,30 @@ fn fill_from_color(color: rgb::Color) -> Fill {
}
}
/// Font metrikleri — ascender ve descender oranları (unitsPerEm'e bölünmüş)
#[derive(Clone, Copy)]
struct FontMetrics {
/// sTypoAscender / unitsPerEm (pozitif, genelde 0.71.1)
ascender: f32,
/// |sTypoDescender| / unitsPerEm (pozitif, genelde 0.20.4)
descender: f32,
}
/// 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>,
/// Font metrikleri: (family_lower, is_bold) → FontMetrics
metrics: HashMap<(String, bool), FontMetrics>,
}
impl FontCollection {
fn new(font_data: &[FontData]) -> Self {
let mut fonts = HashMap::new();
let mut default = None;
let mut metrics = HashMap::new();
for fd in font_data {
let Some(font) = KrillaFont::new(
@@ -84,6 +142,11 @@ impl FontCollection {
default = Some(font.clone());
}
// Font metriklerini OS/2 tablosundan oku
if let Some(m) = read_font_metrics(&fd.data) {
metrics.insert((family_lower.clone(), is_bold), m);
}
fonts.insert((family_lower.clone(), is_bold, is_italic), font);
}
@@ -94,7 +157,7 @@ impl FontCollection {
}
}
Self { fonts, default }
Self { fonts, default, metrics }
}
fn get(&self, family: Option<&str>, weight: Option<&str>) -> Option<&KrillaFont> {
@@ -107,6 +170,80 @@ impl FontCollection {
.or_else(|| self.fonts.get(&(family_lower, false, false)))
.or(self.default.as_ref())
}
/// CSS line-height: 1.2 modeline uygun baseline offset hesapla (pt cinsinden).
///
/// CSS modeli:
/// content_height = (ascender + |descender|) * font_size
/// half_leading = (line_height - content_height) / 2
/// baseline_from_top = half_leading + ascender * font_size
fn baseline_offset(&self, family: Option<&str>, weight: Option<&str>, font_size: f32) -> f32 {
let is_bold = matches!(weight, Some("bold"));
let family_lower = family.unwrap_or("noto sans").to_lowercase();
let m = self.metrics
.get(&(family_lower.clone(), is_bold))
.or_else(|| self.metrics.get(&(family_lower, false)))
.copied();
match m {
Some(m) => {
let content_height = (m.ascender + m.descender) * font_size;
let line_height = font_size * 1.2;
let half_leading = (line_height - content_height) / 2.0;
half_leading + m.ascender * font_size
}
None => font_size * 0.8, // Fallback
}
}
}
/// TTF OS/2 tablosundan font metriklerini oku
fn read_font_metrics(data: &[u8]) -> Option<FontMetrics> {
let units_per_em = read_units_per_em(data)?;
if units_per_em == 0 {
return None;
}
let table_offset = find_os2_table(data)?;
// sTypoAscender: offset 68 (int16), sTypoDescender: offset 70 (int16, negatif)
if table_offset + 72 > data.len() {
return None;
}
let ascender = i16::from_be_bytes([data[table_offset + 68], data[table_offset + 69]]);
let descender = i16::from_be_bytes([data[table_offset + 70], data[table_offset + 71]]);
Some(FontMetrics {
ascender: ascender as f32 / units_per_em as f32,
descender: descender.unsigned_abs() as f32 / units_per_em as f32,
})
}
/// TTF head tablosundan unitsPerEm oku
fn read_units_per_em(data: &[u8]) -> Option<u16> {
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"head" {
let table_offset =
u32::from_be_bytes([data[offset + 8], data[offset + 9], data[offset + 10], data[offset + 11]])
as usize;
// unitsPerEm: head tablosunda offset 18 (uint16)
if table_offset + 20 <= data.len() {
return Some(u16::from_be_bytes([data[table_offset + 18], data[table_offset + 19]]));
}
return None;
}
offset += 16;
}
None
}
/// TTF OS/2 tablosunun offset'ini bul
@@ -228,7 +365,7 @@ fn render_element(
render_text(surface, x, y, w, h, value, &el.style, fonts, measurer);
}
ResolvedContent::Line => {
render_line(surface, x, y, w, &el.style);
render_line(surface, x, y, w, h, &el.style);
}
ResolvedContent::Image { src } => {
render_image(surface, x, y, w, h, src);
@@ -275,60 +412,68 @@ fn render_shape(
return;
}
if let Some(ref bg) = style.background_color {
surface.set_fill(Some(fill_from_color(parse_color(bg))));
} else {
surface.set_fill(None);
}
let shape_type = match content {
Some(ResolvedContent::Shape { shape_type }) => shape_type.as_str(),
_ => "rectangle",
};
let rect_radius = |s: &ResolvedStyle| -> f32 {
if shape_type == "rounded_rectangle" {
s.border_radius.map(|r| mm(r)).unwrap_or(mm(3.0))
} else {
s.border_radius.map(|r| mm(r)).unwrap_or(0.0)
}
};
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));
let border_color = parse_color(style.border_color.as_deref().unwrap_or("#000000"));
let inset = border_width / 2.0;
// Fill + stroke tek path ile — anti-aliasing uyumu
if let Some(ref bg) = style.background_color {
surface.set_fill(Some(fill_from_color(parse_color(bg))));
} else {
surface.set_fill(None);
}
surface.set_stroke(Some(Stroke {
paint: border_color.into(),
width: border_width,
opacity: NormalizedF32::ONE,
..Default::default()
}));
} else {
surface.set_stroke(None);
}
let shape_type = match content {
Some(ResolvedContent::Shape { shape_type }) => shape_type.as_str(),
_ => "rectangle",
};
let path = match shape_type {
"ellipse" => {
let mut pb = PathBuilder::new();
let cx = x + w / 2.0;
let cy = y + h / 2.0;
let rx = w / 2.0;
let ry = h / 2.0;
// Approximate ellipse with 4 cubic bezier curves
let kx = rx * 0.5522848;
let ky = ry * 0.5522848;
pb.move_to(cx, cy - ry);
pb.cubic_to(cx + kx, cy - ry, cx + rx, cy - ky, cx + rx, cy);
pb.cubic_to(cx + rx, cy + ky, cx + kx, cy + ry, cx, cy + ry);
pb.cubic_to(cx - kx, cy + ry, cx - rx, cy + ky, cx - rx, cy);
pb.cubic_to(cx - rx, cy - ky, cx - kx, cy - ry, cx, cy - ry);
pb.close();
pb.finish()
}
_ => {
// rectangle / rounded_rectangle
let mut pb = PathBuilder::new();
if let Some(rect) = krilla::geom::Rect::from_xywh(x, y, w, h) {
pb.push_rect(rect);
let path = match shape_type {
"ellipse" => build_ellipse_path(
x + inset, y + inset,
w - border_width, h - border_width,
),
_ => {
let radius = rect_radius(style);
build_rect_path(
x + inset, y + inset,
w - border_width, h - border_width,
(radius - inset).max(0.0),
)
}
pb.finish()
};
if let Some(p) = path {
surface.draw_path(&p);
}
};
} else {
// Sadece fill, border yok
surface.set_fill(Some(fill_from_color(parse_color(
style.background_color.as_deref().unwrap_or("#ffffff"),
))));
surface.set_stroke(None);
if let Some(p) = path {
surface.draw_path(&p);
let path = match shape_type {
"ellipse" => build_ellipse_path(x, y, w, h),
_ => build_rect_path(x, y, w, h, rect_radius(style)),
};
if let Some(p) = path {
surface.draw_path(&p);
}
}
surface.set_fill(None);
@@ -346,8 +491,9 @@ fn render_checkbox(
) {
let border_color = parse_color(style.border_color.as_deref().unwrap_or("#333333"));
let border_width = mm(style.border_width.unwrap_or(0.3));
let inset = border_width / 2.0;
// Draw box outline
// Draw box outline (inset for CSS border-box match)
surface.set_fill(None);
surface.set_stroke(Some(Stroke {
paint: border_color.into(),
@@ -356,14 +502,11 @@ fn render_checkbox(
..Default::default()
}));
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(p) = rect_path {
if let Some(p) = build_rect_path(
x + inset, y + inset,
w - border_width, h - border_width,
0.0,
) {
surface.draw_path(&p);
}
@@ -413,40 +556,44 @@ fn render_container_bg(
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);
}
let radius = style.border_radius.map(|r| mm(r)).unwrap_or(0.0);
// 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));
let border_color = parse_color(style.border_color.as_deref().unwrap_or("#000000"));
let inset = border_width / 2.0;
// CSS border-box: stroke path'i border_width/2 içeri çek.
// Tek draw_path ile hem fill hem stroke çizerek anti-aliasing uyumunu sağla.
if let Some(ref bg) = style.background_color {
surface.set_fill(Some(fill_from_color(parse_color(bg))));
} else {
surface.set_fill(None);
}
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);
if let Some(path) = build_rect_path(
x + inset, y + inset,
w - border_width, h - border_width,
(radius - inset).max(0.0),
) {
surface.draw_path(&path);
}
} else {
// Sadece background, border yok
surface.set_fill(Some(fill_from_color(parse_color(
style.background_color.as_deref().unwrap_or("#ffffff"),
))));
surface.set_stroke(None);
if let Some(path) = build_rect_path(x, y, w, h, radius) {
surface.draw_path(&path);
}
pb.finish()
};
if let Some(path) = rect_path {
surface.draw_path(&path);
}
// Reset
surface.set_fill(None);
surface.set_stroke(None);
}
@@ -483,8 +630,12 @@ fn render_text(
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;
// Text baseline: CSS line-height 1.2 modeline uygun hesapla
let baseline_y = y + fonts.baseline_offset(
style.font_family.as_deref(),
style.font_weight.as_deref(),
font_size,
);
// Hizalama — cosmic-text ile text genişliğini ölçerek gerçek pozisyon hesapla
let text_x = match style.text_align.as_deref() {
@@ -561,7 +712,7 @@ fn render_rich_text(
.iter()
.map(|s| s.font_size.map(|f| f as f32).unwrap_or(default_font_size))
.fold(0.0f32, f32::max);
let baseline_y = y + max_font_size * 0.8;
let baseline_y = y + fonts.baseline_offset(default_family, default_weight, max_font_size);
let mut cursor_x = line_start_x;
@@ -607,6 +758,7 @@ fn render_line(
x: f32,
y: f32,
w: f32,
h: f32,
style: &ResolvedStyle,
) {
let stroke_color = style
@@ -614,29 +766,27 @@ fn render_line(
.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()
}));
// Çizgiyi filled rectangle olarak çiz — CSS borderTop ile aynı davranış.
// Stroke kullanmak sub-pixel anti-aliasing farkları yaratır.
surface.set_fill(Some(fill_from_color(stroke_color)));
surface.set_stroke(None);
let line_y = y + stroke_width / 2.0;
let path = {
let rect_path = {
let mut pb = PathBuilder::new();
pb.move_to(x, line_y);
pb.line_to(x + w, line_y);
// Eleman yüksekliği layout engine tarafından stroke_width olarak hesaplandı.
// Tüm eleman alanını dolduran ince dikdörtgen çiz.
if let Some(rect) = krilla::geom::Rect::from_xywh(x, y, w, h) {
pb.push_rect(rect);
}
pb.finish()
};
if let Some(p) = path {
if let Some(p) = rect_path {
surface.draw_path(&p);
}
surface.set_stroke(None);
surface.set_fill(None);
}
fn render_image(

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB