update
This commit is contained in:
@@ -46,6 +46,9 @@ pub struct LabelQuery {
|
||||
pub width_mm: u32,
|
||||
#[serde(default = "default_height_mm")]
|
||||
pub height_mm: u32,
|
||||
/// Campos a incluir, separados por vírgula: color,name,material_brand,net_weight,print_temp,qrcode
|
||||
/// Se ausente, inclui todos.
|
||||
pub fields: Option<String>,
|
||||
}
|
||||
|
||||
fn default_width_mm() -> u32 { 50 }
|
||||
@@ -286,6 +289,30 @@ pub async fn get_qrcode_handler(
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn export_label_pdf_handler(
|
||||
State(state): State<AppState>,
|
||||
Extension(user): Extension<CurrentUser>,
|
||||
Path(id): Path<Uuid>,
|
||||
Query(query): Query<LabelQuery>,
|
||||
) -> 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())
|
||||
.await?;
|
||||
|
||||
let filename = format!("meowspool-label-{id}.pdf");
|
||||
Ok((
|
||||
[
|
||||
(header::CONTENT_TYPE, "application/pdf".to_string()),
|
||||
(
|
||||
header::CONTENT_DISPOSITION,
|
||||
format!("attachment; filename=\"{filename}\""),
|
||||
),
|
||||
],
|
||||
pdf,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn export_label_handler(
|
||||
State(state): State<AppState>,
|
||||
Extension(user): Extension<CurrentUser>,
|
||||
@@ -294,7 +321,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)
|
||||
.generate_label_svg(id, user.id, query.width_mm, query.height_mm, query.fields.as_deref())
|
||||
.await?;
|
||||
|
||||
let filename = format!("meowspool-label-{id}.svg");
|
||||
|
||||
@@ -65,6 +65,41 @@ pub struct FilamentService {
|
||||
preset_repo: Arc<dyn SpoolPresetRepository>,
|
||||
}
|
||||
|
||||
/// Campos de conteúdo a incluir na etiqueta.
|
||||
pub struct LabelFields {
|
||||
pub color: bool,
|
||||
pub name: bool,
|
||||
pub material_brand: bool,
|
||||
pub net_weight: bool,
|
||||
pub print_temp: bool,
|
||||
pub qrcode: bool,
|
||||
}
|
||||
|
||||
impl LabelFields {
|
||||
/// Parseia uma string de campos separados por vírgula.
|
||||
/// Se `None`, habilita todos.
|
||||
pub fn from_str(s: Option<&str>) -> Self {
|
||||
match s {
|
||||
None => Self::all(),
|
||||
Some(s) => {
|
||||
let parts: Vec<&str> = s.split(',').map(str::trim).collect();
|
||||
Self {
|
||||
color: parts.contains(&"color"),
|
||||
name: parts.contains(&"name"),
|
||||
material_brand: parts.contains(&"material_brand"),
|
||||
net_weight: parts.contains(&"net_weight"),
|
||||
print_temp: parts.contains(&"print_temp"),
|
||||
qrcode: parts.contains(&"qrcode"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn all() -> Self {
|
||||
Self { color: true, name: true, material_brand: true, net_weight: true, print_temp: true, qrcode: true }
|
||||
}
|
||||
}
|
||||
|
||||
impl FilamentService {
|
||||
pub fn new(repo: Arc<dyn FilamentRepository>, preset_repo: Arc<dyn SpoolPresetRepository>) -> Self {
|
||||
Self { repo, preset_repo }
|
||||
@@ -255,7 +290,9 @@ impl FilamentService {
|
||||
user_id: Uuid,
|
||||
width_mm: u32,
|
||||
height_mm: u32,
|
||||
fields: Option<&str>,
|
||||
) -> 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
|
||||
@@ -274,37 +311,191 @@ impl FilamentService {
|
||||
let brand = &filament.brand;
|
||||
let color = &filament.color_hex;
|
||||
|
||||
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)
|
||||
} 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))
|
||||
} 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))
|
||||
} 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)
|
||||
} 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)
|
||||
} else { String::new() };
|
||||
|
||||
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"/>
|
||||
<!-- Color swatch -->
|
||||
<rect x="6" y="6" width="14" height="{h}" fill="{color}" rx="2"/>
|
||||
<!-- Text block -->
|
||||
<text x="26" y="20" font-family="Inter, sans-serif" font-size="10" font-weight="700" fill="#F5EEDC">{model_name}</text>
|
||||
<text x="26" y="33" font-family="Inter, sans-serif" font-size="8" fill="#C9C1B0">{brand} · {material}</text>
|
||||
<text x="26" y="46" font-family="Inter, sans-serif" font-size="9" font-weight="600" fill="#38BCC2">{net_weight}g</text>
|
||||
<!-- QR Code -->
|
||||
<image x="{qr_x}" y="{qr_y}" width="{qr_size}" height="{qr_size}"
|
||||
xlink:href="data:image/png;base64,{qr_b64}"/>
|
||||
{color_swatch}
|
||||
{text_name}
|
||||
{text_meta}
|
||||
{text_weight}
|
||||
{qr_element}
|
||||
</svg>"##,
|
||||
width_px = width_px,
|
||||
height_px = height_px,
|
||||
h = height_px - 12,
|
||||
color = color,
|
||||
model_name = escape_xml(model_name),
|
||||
brand = escape_xml(brand),
|
||||
material = escape_xml(&material),
|
||||
net_weight = net_weight,
|
||||
qr_size = qr_size,
|
||||
qr_x = width_px - qr_size - 4,
|
||||
qr_y = (height_px - qr_size) / 2,
|
||||
qr_b64 = qr_b64,
|
||||
);
|
||||
|
||||
Ok(svg)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// PDF Label
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
pub async fn generate_label_pdf(
|
||||
&self,
|
||||
id: Uuid,
|
||||
user_id: Uuid,
|
||||
width_mm: u32,
|
||||
height_mm: u32,
|
||||
fields: Option<&str>,
|
||||
) -> Result<Vec<u8>, AppError> {
|
||||
let f = LabelFields::from_str(fields);
|
||||
use printpdf::{
|
||||
path::{PaintMode, WindingOrder},
|
||||
ColorBits, ColorSpace, CurTransMat, Image, ImageTransform, ImageXObject, Mm, Point,
|
||||
Polygon, Px, PdfDocument, BuiltinFont, Color, Rgb,
|
||||
};
|
||||
|
||||
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}")))?
|
||||
.to_luma8();
|
||||
let (qr_px_w, qr_px_h) = qr_img.dimensions();
|
||||
let qr_raw = qr_img.into_raw();
|
||||
|
||||
let w = width_mm as f32;
|
||||
let h = height_mm as f32;
|
||||
|
||||
let (doc, page1, layer1) = PdfDocument::new(
|
||||
format!("MeowSpool — {}", filament.brand),
|
||||
Mm(w),
|
||||
Mm(h),
|
||||
"Content",
|
||||
);
|
||||
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),
|
||||
(Point::new(Mm(x + rw), Mm(y)), false),
|
||||
(Point::new(Mm(x + rw), Mm(y + rh)), false),
|
||||
(Point::new(Mm(x), Mm(y + rh)), false),
|
||||
]],
|
||||
mode: PaintMode::Fill,
|
||||
winding_order: WindingOrder::NonZero,
|
||||
};
|
||||
|
||||
// ── Fundo escuro ──────────────────────────────────────────
|
||||
layer.set_fill_color(Color::Rgb(Rgb::new(0.118, 0.106, 0.094, None))); // #1E1B18
|
||||
layer.add_polygon(filled_rect(0.0, 0.0, w, h));
|
||||
|
||||
// ── Barra de cor ─────────────────────────────────────────
|
||||
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));
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
|
||||
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,
|
||||
image_data: qr_raw,
|
||||
image_filter: None,
|
||||
clipping_bbox: None,
|
||||
smask: None,
|
||||
};
|
||||
Image::from(qr_xobj).add_to_layer(
|
||||
layer.clone(),
|
||||
ImageTransform {
|
||||
translate_x: Some(Mm(qr_x)),
|
||||
translate_y: Some(Mm(qr_y)),
|
||||
rotate: None,
|
||||
scale_x: None,
|
||||
scale_y: None,
|
||||
dpi: Some(qr_dpi),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ── Texto ─────────────────────────────────────────────────
|
||||
let needs_text = f.name || f.material_brand || f.net_weight;
|
||||
if needs_text {
|
||||
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);
|
||||
}
|
||||
|
||||
if f.material_brand {
|
||||
let meta = format!("{} · {}", filament.brand, 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);
|
||||
}
|
||||
|
||||
if f.net_weight {
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
let _ = CurTransMat::Identity; // suprime unused import
|
||||
|
||||
// ── Serializar ────────────────────────────────────────────
|
||||
let mut buf = Vec::new();
|
||||
doc.save(&mut std::io::BufWriter::new(std::io::Cursor::new(&mut buf)))
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("PDF save: {e}")))?;
|
||||
|
||||
Ok(buf)
|
||||
}
|
||||
}
|
||||
|
||||
fn escape_xml(s: &str) -> String {
|
||||
|
||||
@@ -85,6 +85,7 @@ pub fn build(db: PgPool, config: Config) -> Router {
|
||||
.route("/filaments/:id", delete(filament_handler::delete_filament_handler))
|
||||
.route("/filaments/:id/qrcode", get(filament_handler::get_qrcode_handler))
|
||||
.route("/filaments/:id/label.svg", get(filament_handler::export_label_handler))
|
||||
.route("/filaments/:id/label.pdf", get(filament_handler::export_label_pdf_handler))
|
||||
// Spool Presets
|
||||
.route("/spool-presets", get(spool_preset_handler::list_presets_handler))
|
||||
.route("/spool-presets", post(spool_preset_handler::create_preset_handler))
|
||||
|
||||
Reference in New Issue
Block a user