feat: adicionar suporte a impressoras pequenas com parâmetro dpi e layout adaptativo para etiquetas

This commit is contained in:
2026-03-14 21:21:15 -03:00
parent b6e4aa7622
commit 5c8d6a368e
6 changed files with 261 additions and 64 deletions
@@ -49,6 +49,9 @@ pub struct LabelQuery {
/// Campos a incluir, separados por vírgula: color,name,material_brand,net_weight,print_temp,qrcode
/// Se ausente, inclui todos.
pub fields: Option<String>,
/// DPI alvo da impressora. Padrão: 96 (tela). Use 203 para Niimbot/térmicas de baixo custo,
/// 300 para Brother/Dymo premium. Afeta resolução do QR Code no SVG e dimensionamento físico.
pub dpi: Option<u32>,
}
fn default_width_mm() -> u32 { 50 }
@@ -297,7 +300,7 @@ pub async fn export_label_pdf_handler(
) -> Result<impl IntoResponse, AppError> {
let pdf = state
.filament_service
.generate_label_pdf(id, user.id, query.width_mm, query.height_mm, query.fields.as_deref())
.generate_label_pdf(id, user.id, query.width_mm, query.height_mm, query.fields.as_deref(), query.dpi)
.await?;
let filename = format!("meowspool-label-{id}.pdf");
@@ -321,7 +324,7 @@ pub async fn export_label_handler(
) -> Result<impl IntoResponse, AppError> {
let svg = state
.filament_service
.generate_label_svg(id, user.id, query.width_mm, query.height_mm, query.fields.as_deref())
.generate_label_svg(id, user.id, query.width_mm, query.height_mm, query.fields.as_deref(), query.dpi)
.await?;
let filename = format!("meowspool-label-{id}.svg");
+108 -47
View File
@@ -291,19 +291,18 @@ impl FilamentService {
width_mm: u32,
height_mm: u32,
fields: Option<&str>,
dpi: Option<u32>,
) -> Result<String, AppError> {
let f = LabelFields::from_str(fields);
let filament = self.get(id, user_id).await?;
// Gerar QR Code como PNG base64 para embutir no SVG
let qr_png = self.generate_qrcode_png(id, user_id).await?;
let qr_b64 = STANDARD.encode(&qr_png);
// Escala: 1mm = ~3.78px (96dpi)
let px_per_mm = 3.78_f64;
let width_px = (width_mm as f64 * px_per_mm) as u32;
let height_px = (height_mm as f64 * px_per_mm) as u32;
let qr_size = (height_px as f64 * 0.8) as u32;
// px_per_mm baseado no DPI alvo (padrão 96 para tela)
let px_per_mm = dpi.unwrap_or(96) as f64 / 25.4;
let width_px = (width_mm as f64 * px_per_mm).round() as u32;
let height_px = (height_mm as f64 * px_per_mm).round() as u32;
let model_name = filament.model.as_deref().unwrap_or("");
let net_weight = filament.net_weight_g;
@@ -311,47 +310,88 @@ impl FilamentService {
let brand = &filament.brand;
let color = &filament.color_hex;
// Etiqueta "mini": largura < 30mm ou altura < 20mm (ex: Niimbot 22×14mm)
let is_mini = width_mm < 30 || height_mm < 20;
let (bar_w, text_x, qr_size, qr_x, qr_y, fs_name, fs_meta, fs_weight, y_name, y_meta, y_weight) =
if is_mini {
// Barra de cor estreita, QR ocupa 80% da altura, texto na faixa central
let bar_w = ((width_px as f64 * 0.09) as u32).max(3);
let qr_sz = ((height_px as f64 * 0.80) as u32).max(1);
let pad = ((width_px as f64 * 0.04) as u32).max(2);
let qr_x = width_px.saturating_sub(qr_sz + pad);
let qr_y = (height_px.saturating_sub(qr_sz)) / 2;
let text_x = (bar_w + pad * 2) as f64;
let fs_name = (height_px as f64 * 0.20).max(5.0).min(10.0);
let fs_meta = (height_px as f64 * 0.13).max(4.0).min(7.0);
let fs_weight = (height_px as f64 * 0.17).max(4.5).min(9.0);
let y_name = (height_px as f64 * 0.30) as u32;
let y_meta = (height_px as f64 * 0.54) as u32;
let y_weight = (height_px as f64 * 0.78) as u32;
(bar_w, text_x, qr_sz, qr_x, qr_y, fs_name, fs_meta, fs_weight, y_name, y_meta, y_weight)
} else {
// Layout padrão
let bar_w = ((width_px as f64 * 0.05) as u32).max(6);
let qr_sz = (height_px as f64 * 0.80) as u32;
let qr_x = width_px.saturating_sub(qr_sz + 4);
let qr_y = (height_px.saturating_sub(qr_sz)) / 2;
let text_x = (bar_w + 12) as f64;
let fs_name = (height_px as f64 * 0.19).max(8.0).min(14.0);
let fs_meta = (height_px as f64 * 0.14).max(6.0).min(11.0);
let fs_weight = (height_px as f64 * 0.17).max(7.0).min(12.0);
let y_name = (height_px as f64 * 0.33) as u32;
let y_meta = (height_px as f64 * 0.55) as u32;
let y_weight = (height_px as f64 * 0.77) as u32;
(bar_w, text_x, qr_sz, qr_x, qr_y, fs_name, fs_meta, fs_weight, y_name, y_meta, y_weight)
};
let color_swatch = if f.color {
format!(r#" <rect x="6" y="6" width="14" height="{h}" fill="{color}" rx="2"/>"#,
h = height_px - 12, color = color)
let pad_v = (height_px as f64 * 0.08) as u32;
format!(
r#" <rect x="0" y="{pad_v}" width="{bar_w}" height="{h}" fill="{color}" rx="1"/>"#,
h = height_px.saturating_sub(pad_v * 2),
)
} else { String::new() };
let text_name = if f.name {
format!(r##" <text x="26" y="20" font-family="Inter, sans-serif" font-size="10" font-weight="700" fill="#F5EEDC">{model_name}</text>"##,
model_name = escape_xml(model_name))
format!(
r##" <text x="{text_x}" y="{y_name}" font-family="Inter, sans-serif" font-size="{fs_name}" font-weight="700" fill="#F5EEDC">{model_name}</text>"##,
model_name = escape_xml(model_name)
)
} else { String::new() };
let text_meta = if f.material_brand {
format!(r##" <text x="26" y="33" font-family="Inter, sans-serif" font-size="8" fill="#C9C1B0">{brand} · {material}</text>"##,
brand = escape_xml(brand), material = escape_xml(&material))
let sep = if is_mini { " " } else { " · " };
format!(
r##" <text x="{text_x}" y="{y_meta}" font-family="Inter, sans-serif" font-size="{fs_meta}" fill="#C9C1B0">{brand}{sep}{material}</text>"##,
brand = escape_xml(brand), material = escape_xml(&material)
)
} else { String::new() };
let text_weight = if f.net_weight {
format!(r##" <text x="26" y="46" font-family="Inter, sans-serif" font-size="9" font-weight="600" fill="#38BCC2">{net_weight}g</text>"##,
net_weight = net_weight)
format!(
r##" <text x="{text_x}" y="{y_weight}" font-family="Inter, sans-serif" font-size="{fs_weight}" font-weight="600" fill="#38BCC2">{net_weight}g</text>"##,
)
} else { String::new() };
let qr_element = if f.qrcode {
format!(r##" <image x="{qr_x}" y="{qr_y}" width="{qr_size}" height="{qr_size}" xlink:href="data:image/png;base64,{qr_b64}"/>"##,
qr_x = width_px - qr_size - 4,
qr_y = (height_px - qr_size) / 2,
qr_size = qr_size,
qr_b64 = qr_b64)
format!(
r##" <image x="{qr_x}" y="{qr_y}" width="{qr_size}" height="{qr_size}" xlink:href="data:image/png;base64,{qr_b64}"/>"##,
)
} else { String::new() };
let rx = if is_mini { 2 } else { 4 };
let svg = format!(
r##"<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
width="{width_px}px" height="{height_px}px" viewBox="0 0 {width_px} {height_px}">
<rect width="{width_px}" height="{height_px}" fill="#1E1B18" rx="4"/>
<rect width="{width_px}" height="{height_px}" fill="#1E1B18" rx="{rx}"/>
{color_swatch}
{text_name}
{text_meta}
{text_weight}
{qr_element}
</svg>"##,
width_px = width_px,
height_px = height_px,
);
Ok(svg)
@@ -368,6 +408,7 @@ impl FilamentService {
width_mm: u32,
height_mm: u32,
fields: Option<&str>,
_dpi: Option<u32>,
) -> Result<Vec<u8>, AppError> {
let f = LabelFields::from_str(fields);
use printpdf::{
@@ -378,7 +419,6 @@ impl FilamentService {
let filament = self.get(id, user_id).await?;
// QR Code como pixels luma — sem depender do feature image do printpdf
let qr_png = self.generate_qrcode_png(id, user_id).await?;
let qr_img = image::load_from_memory(&qr_png)
.map_err(|e| AppError::Internal(anyhow::anyhow!("QR decode: {e}")))?
@@ -389,6 +429,9 @@ impl FilamentService {
let w = width_mm as f32;
let h = height_mm as f32;
// Etiqueta "mini": largura < 30mm ou altura < 20mm (ex: Niimbot 22×14mm)
let is_mini = w < 30.0 || h < 20.0;
let (doc, page1, layer1) = PdfDocument::new(
format!("MeowSpool — {}", filament.brand),
Mm(w),
@@ -397,7 +440,6 @@ impl FilamentService {
);
let layer = doc.get_page(page1).get_layer(layer1);
// Helper de retângulo preenchido via Polygon
let filled_rect = |x: f32, y: f32, rw: f32, rh: f32| Polygon {
rings: vec![vec![
(Point::new(Mm(x), Mm(y)), false),
@@ -410,32 +452,36 @@ impl FilamentService {
};
// ── Fundo escuro ──────────────────────────────────────────
layer.set_fill_color(Color::Rgb(Rgb::new(0.118, 0.106, 0.094, None))); // #1E1B18
layer.set_fill_color(Color::Rgb(Rgb::new(0.118, 0.106, 0.094, None)));
layer.add_polygon(filled_rect(0.0, 0.0, w, h));
// ── Barra de cor ─────────────────────────────────────────
// ── Barra de cor ─────────────────────────────────────────
let bar_w = if is_mini { (w * 0.11).max(1.5) } else { 3.5_f32 };
if f.color {
let hex = filament.color_hex.trim_start_matches('#');
let cr = u8::from_str_radix(hex.get(0..2).unwrap_or("80"), 16).unwrap_or(128) as f32 / 255.0;
let cg = u8::from_str_radix(hex.get(2..4).unwrap_or("80"), 16).unwrap_or(128) as f32 / 255.0;
let cb = u8::from_str_radix(hex.get(4..6).unwrap_or("80"), 16).unwrap_or(128) as f32 / 255.0;
layer.set_fill_color(Color::Rgb(Rgb::new(cr, cg, cb, None)));
layer.add_polygon(filled_rect(0.0, 0.0, 3.5, h));
layer.add_polygon(filled_rect(0.0, 0.0, bar_w, h));
}
// ── QR Code ───────────────────────────────────────────────
if f.qrcode {
let qr_size_mm = h * 0.75;
let qr_x = w - qr_size_mm - 2.0;
let qr_y = (h - qr_size_mm) / 2.0;
let qr_dpi = qr_px_w as f32 * 25.4 / qr_size_mm;
// Mini: QR ocupa 80% da altura e fica colado à direita.
// Padrão: QR ocupa 75% da altura com margem de 2mm.
let qr_size_mm = if is_mini { h * 0.80 } else { h * 0.75 };
let qr_margin = if is_mini { 1.0_f32 } else { 2.0_f32 };
let qr_x = w - qr_size_mm - qr_margin;
let qr_y = (h - qr_size_mm) / 2.0;
if f.qrcode {
let qr_dpi = qr_px_w as f32 * 25.4 / qr_size_mm;
let qr_xobj = ImageXObject {
width: Px(qr_px_w as usize),
height: Px(qr_px_h as usize),
color_space: ColorSpace::Greyscale,
bits_per_component: ColorBits::Bit8,
interpolate: true,
interpolate: false, // false = bordas nítidas em impressoras térmicas
image_data: qr_raw,
image_filter: None,
clipping_bbox: None,
@@ -455,39 +501,54 @@ impl FilamentService {
}
// ── Texto ─────────────────────────────────────────────────
// text_x: começa após a barra de cor com pequena margem
// text_max_x: limite antes do QR (ou borda direita se sem QR)
let text_x = bar_w + if is_mini { 1.0 } else { 1.5 };
let text_max_x = if f.qrcode { qr_x - 0.5 } else { w - 1.0 };
// Distribuição vertical uniforme dentro da etiqueta (3 linhas)
// PDF: y=0 é a borda inferior, y=h é o topo.
// Dividimos h em 4 partes: 75%, 50%, 25% do topo (→ 25%, 50%, 75% da base)
let y_name = h * 0.72;
let y_meta = h * 0.50;
let y_weight = h * 0.28;
// Tamanhos de fonte adaptativos ao tamanho físico da etiqueta
let fs_name = if is_mini { (h * 0.32).max(4.5).min(7.0) } else { (h * 0.28).max(7.0).min(11.0) };
let fs_meta = if is_mini { (h * 0.22).max(3.5).min(5.5) } else { (h * 0.20).max(5.5).min(8.5) };
let fs_weight = if is_mini { (h * 0.27).max(4.0).min(6.5) } else { (h * 0.24).max(6.5).min(10.0) };
let needs_text = f.name || f.material_brand || f.net_weight;
if needs_text {
if needs_text && text_x < text_max_x {
let font_bold = doc.add_builtin_font(BuiltinFont::HelveticaBold)
.map_err(|e| AppError::Internal(anyhow::anyhow!("Font: {e}")))?;
let font = doc.add_builtin_font(BuiltinFont::Helvetica)
.map_err(|e| AppError::Internal(anyhow::anyhow!("Font: {e}")))?;
let text_x = 5.0_f32;
if f.name {
let model_name = filament.model.as_deref().unwrap_or(&filament.brand);
layer.set_fill_color(Color::Rgb(Rgb::new(0.961, 0.933, 0.863, None)));
layer.use_text(model_name, 9.0, Mm(text_x), Mm(h - 8.0), &font_bold);
layer.use_text(model_name, fs_name, Mm(text_x), Mm(y_name), &font_bold);
}
if f.material_brand {
let meta = format!("{} · {}", filament.brand, filament.material);
let sep = if is_mini { " " } else { " · " };
let meta = format!("{}{}{}", filament.brand, sep, filament.material);
layer.set_fill_color(Color::Rgb(Rgb::new(0.788, 0.757, 0.690, None)));
layer.use_text(&meta, 7.0, Mm(text_x), Mm(h - 14.0), &font);
layer.use_text(&meta, fs_meta, Mm(text_x), Mm(y_meta), &font);
}
if f.net_weight {
let label = if is_mini {
format!("{:.0}g", filament.net_weight_g)
} else {
format!("{:.0}g disponível", filament.net_weight_g)
};
layer.set_fill_color(Color::Rgb(Rgb::new(0.220, 0.737, 0.761, None)));
layer.use_text(
&format!("{:.0}g disponível", filament.net_weight_g),
8.0,
Mm(text_x),
Mm(h - 20.0),
&font_bold,
);
layer.use_text(&label, fs_weight, Mm(text_x), Mm(y_weight), &font_bold);
}
}
let _ = CurTransMat::Identity; // suprime unused import
let _ = CurTransMat::Identity;
// ── Serializar ────────────────────────────────────────────
let mut buf = Vec::new();