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
+2 -1
View File
@@ -15,7 +15,8 @@
"mcp__paper__get_screenshot",
"Bash(find /var/home/felipecn/DEV/MeowSpool/backend/bruno-routes -name \"*.yml\" ! -name \"route.yml\" -exec sed -i 's|http://0\\\\.0\\\\.0\\\\.0:8080|{{base_url}}|g; s|https://meowspool\\\\.felipecncloud\\\\.com|{{base_url}}|g' {} +)",
"Bash(find /var/home/felipecn/DEV/MeowSpool/backend/bruno-routes -name \"*.yml\" ! -name \"route.yml\" ! -name \"Dashboard.yml\" -exec sed -i 's| url: \\\\\\({{.*}}\\\\\\)| url: \"\"\\\\1\"\"|g' {} +)",
"Bash(find /var/home/felipecn/DEV/MeowSpool/backend/bruno-routes -name \"*.yml\" ! -name \"route.yml\" -exec sed -i 's| url: \"\"\\\\?\\\\\\(.*\\\\\\)\"\"\\\\?$| url: \"\"\\\\1\"\"|g; s| url: \"\"\\\\\\(.*\\\\\\)\"\"\"\"\\\\?$| url: \"\"\\\\1\"\"|g' {} +)"
"Bash(find /var/home/felipecn/DEV/MeowSpool/backend/bruno-routes -name \"*.yml\" ! -name \"route.yml\" -exec sed -i 's| url: \"\"\\\\?\\\\\\(.*\\\\\\)\"\"\\\\?$| url: \"\"\\\\1\"\"|g; s| url: \"\"\\\\\\(.*\\\\\\)\"\"\"\"\\\\?$| url: \"\"\\\\1\"\"|g' {} +)",
"Bash(cargo check:*)"
]
}
}
+68 -5
View File
@@ -374,16 +374,17 @@ O backend implementa **last-write-wins com timestamp**:
### Etiqueta SVG (`GET /filaments/:id/label.svg`)
- Query params: `width_mm` (padrão: 50), `height_mm` (padrão: 30), `fields` (opcional)
- Retorna SVG com os campos selecionados embutidos
- Query params: `width_mm` (padrão: 50), `height_mm` (padrão: 30), `fields` (opcional), `dpi` (opcional, padrão 96)
- Layout adaptativo: modo **mini** para `width_mm < 30` ou `height_mm < 20`
- `dpi` afeta `px_per_mm` e portanto as dimensões em pixels do SVG gerado
- `Content-Type: image/svg+xml`
- `Content-Disposition: attachment; filename="meowspool-label-{id}.svg"`
### Etiqueta PDF (`GET /filaments/:id/label.pdf`)
- Query params: `width_mm` (padrão: 50), `height_mm` (padrão: 30), `fields` (opcional)
- Retorna PDF de página única com dimensões exatas em mm — ideal para impressoras de etiqueta (Brother, Dymo)
- Gerado com `printpdf`: fundo escuro `#1E1B18`, barra de cor do filamento, texto com Helvetica builtin, QR Code embutido como PNG luma
- Query params: `width_mm` (padrão: 50), `height_mm` (padrão: 30), `fields` (opcional), `dpi` (opcional, reservado)
- Layout adaptativo: modo **mini** para `width_mm < 30` ou `height_mm < 20` — fontes e posições proporcionais, garante que nenhum elemento saia da página
- Gerado com `printpdf`: fundo escuro `#1E1B18`, barra de cor do filamento, texto com Helvetica builtin, QR Code PNG luma embutido com `interpolate: false` (bordas nítidas em impressoras térmicas)
- `Content-Type: application/pdf`
- `Content-Disposition: attachment; filename="meowspool-label-{id}.pdf"`
@@ -404,6 +405,33 @@ Se `fields` for omitido, todos os elementos são incluídos.
Implementado em `LabelFields` (`filament_service.rs`) — `LabelFields::from_str(Option<&str>)`.
### Parâmetro `dpi` (SVG e PDF)
Controla o DPI alvo da impressora. Afeta o cálculo de pixels do SVG e garante QR nítido em impressoras térmicas.
| Impressora | `dpi` recomendado |
| --------------------- | ----------------- |
| Tela / padrão | 96 (omitir param) |
| Niimbot D11/D110 | 203 |
| Brother QL / Dymo | 300 |
### Layout adaptativo ("mini" vs padrão)
Etiquetas com `width_mm < 30` **ou** `height_mm < 20` ativam o modo **mini**, que:
- Usa fontes menores (proporcionais à altura física)
- Posiciona QR Code com margem de 1mm (vs 2mm no padrão)
- Estreita a barra de cor (~11% da largura vs ~5% + 6px no padrão)
- Encurta o rótulo de peso: `"320g"` em vez de `"320g disponível"`
- Separador de marca/material: espaço simples em vez de ` · ` (economiza espaço)
- No SVG, o separador de meta usa espaço em vez de ` · `
- Garante que nenhum texto extrapole a borda inferior da etiqueta
Exemplo para Niimbot 22×14mm:
```
GET /api/v1/filaments/:id/label.pdf?width_mm=22&height_mm=14&fields=color,name,qrcode&dpi=203
GET /api/v1/filaments/:id/label.svg?width_mm=22&height_mm=14&fields=color,name,qrcode&dpi=203
```
---
## Segurança
@@ -419,6 +447,41 @@ Implementado em `LabelFields` (`filament_service.rs`) — `LabelFields::from_str
## Mudanças Recentes (14/03/2026)
### ✅ Suporte a Impressoras Pequenas — Niimbot e layout adaptativo
**Arquivos alterados**: `filament_handler.rs`, `filament_service.rs`
#### Novo query param `dpi`
Adicionado campo `dpi: Option<u32>` ao `LabelQuery` (handler). Aceito em `/label.svg` e `/label.pdf`. Valores recomendados:
| Impressora | `dpi` |
| ---------------- | ----- |
| Tela / padrão | omitir (96) |
| Niimbot D11/D110 | 203 |
| Brother / Dymo | 300 |
No SVG, `dpi` define `px_per_mm = dpi / 25.4` — o tamanho em pixels do arquivo corresponde à resolução física da impressora. No PDF, o `dpi` é ignorado (PDF já trabalha em mm), mas reservado para uso futuro.
#### Layout adaptativo — modo "mini"
Ativado quando `width_mm < 30` **ou** `height_mm < 20` (ex: Niimbot 22×14mm). Diferenças em relação ao modo padrão:
| Aspecto | Padrão | Mini |
| --- | --- | --- |
| Barra de cor | 3.5mm fixo | 11% da largura |
| QR size | 75% da altura | 80% da altura |
| Margem QR | 2mm | 1mm |
| Y do texto | `h - 8/14/20` (fixo, quebrava para h<20) | `h * 0.72/0.50/0.28` (proporcional) |
| Fontes | 79pt fixo | proporcionais à altura física |
| Rótulo peso | `"320g disponível"` | `"320g"` |
| Separador meta | ` · ` | ` ` (espaço) |
| `interpolate` no QR (PDF) | `true` | `false` (bordas nítidas em térmica) |
**Correção crítica**: no modo anterior, `Mm(h - 20.0)` gerava coordenada negativa para `h < 20mm` (Niimbot), colocando o texto fora da página.
---
### ✅ Exportação de Etiqueta PDF (`GET /filaments/:id/label.pdf`)
- **Dependência**: `printpdf = "0.7"` adicionada ao `Cargo.toml`
@@ -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();
+52 -2
View File
@@ -320,8 +320,8 @@ Base: `EXPO_PUBLIC_API_URL` (padrão: `http://localhost:3000/api/v1`)
| PATCH | `/filaments/:id` | Atualizar |
| DELETE | `/filaments/:id` | Excluir |
| GET | `/filaments/:id/qrcode` | QR Code PNG |
| GET | `/filaments/:id/label.svg` | Etiqueta SVG (`width_mm`, `height_mm`, `fields`) |
| GET | `/filaments/:id/label.pdf` | Etiqueta PDF (`width_mm`, `height_mm`, `fields`) |
| GET | `/filaments/:id/label.svg` | Etiqueta SVG (`width_mm`, `height_mm`, `fields`, `dpi`) |
| GET | `/filaments/:id/label.pdf` | Etiqueta PDF (`width_mm`, `height_mm`, `fields`, `dpi`) |
### Spool Presets
@@ -349,6 +349,7 @@ Base: `EXPO_PUBLIC_API_URL` (padrão: `http://localhost:3000/api/v1`)
- [x] Exportar etiqueta SVG via `GET /filaments/:id/label.svg`
- [x] Exportar etiqueta PDF via `GET /filaments/:id/label.pdf`
- [x] Seletor de formato (PDF/SVG) e controle de campos na tela de etiqueta
- [x] Suporte a Niimbot D11/D110 (22×14mm) com seleção automática de conteúdo mínimo
- [x] NFC leitura NTAG215 → `nfc-reader.tsx` (bottom sheet modal)
- [x] NFC gravação NTAG215 → `filaments/[id]/write-nfc.tsx`
- [ ] NFC suporte iOS (requer entitlement `com.apple.developer.nfc.readwrite`)
@@ -360,6 +361,55 @@ Base: `EXPO_PUBLIC_API_URL` (padrão: `http://localhost:3000/api/v1`)
## Mudanças Recentes (14/03/2026)
### ✅ Suporte a Impressoras Pequenas — Niimbot D11/D110 (`label.tsx`)
**Arquivo alterado**: `app/(app)/filaments/[id]/label.tsx`
#### Preset Niimbot 22×14mm
Adicionado como primeira opção em `LABEL_SIZES`:
```ts
{ id: '22x14', label: '22 × 14', sub: 'Niimbot D11/D110' }
```
O tipo `LabelSize` foi atualizado para incluir `'22x14'`.
#### Seleção automática de conteúdo mínimo
A função `handleSizeChange` substitui o `setSelectedSize` direto nos chips. Ao selecionar `22x14`, o conteúdo ativo é automaticamente reduzido para `{ color, name, qrcode }` — os únicos campos que cabem fisicamente em 22×14mm. O usuário pode ajustar manualmente após.
```ts
const MINI_LABEL_CONTENT: Set<ContentOption> = new Set(['color', 'name', 'qrcode']);
function handleSizeChange(size: LabelSize): void {
setSelectedSize(size);
if (size === '22x14') setEnabledContent(new Set(MINI_LABEL_CONTENT));
}
```
#### `dpi=203` enviado para o backend
O parâmetro `dpi` é incluído automaticamente nos requests de `/label.pdf` e `/label.svg` quando Niimbot está selecionado. Instrui o backend a calcular pixels do SVG na resolução correta da impressora térmica.
#### Fix: btoa chunking para PDFs grandes
O código original usava `String.fromCharCode(...new Uint8Array(buffer))` com spread, que causa **stack overflow** em buffers > ~64KB em React Native. Substituído por loop em chunks de 8192 bytes:
```ts
const bytes = new Uint8Array(response.data);
let binary = '';
const CHUNK = 8192;
for (let i = 0; i < bytes.length; i += CHUNK) {
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
}
const base64 = btoa(binary);
```
Essa correção se aplica a todos os tamanhos de etiqueta, não apenas Niimbot.
---
### ✅ NFC Read/Write — NTAG215
Implementação completa de leitura e gravação NFC para tags NTAG215. O conteúdo gravado é idêntico ao QR Code: `meowspool://filament/{id}` como NDEF URI record.
+26 -7
View File
@@ -15,14 +15,18 @@ import { formatWeight } from '@shared/utils/filament';
import { Button } from '@presentation/components/ui/Button';
import { httpClient } from '@adapters/remote/httpClient';
type LabelSize = '50x30' | '62x29' | '38x25';
type LabelSize = '22x14' | '50x30' | '62x29' | '38x25';
const LABEL_SIZES: { id: LabelSize; label: string; sub: string }[] = [
{ id: '22x14', label: '22 × 14', sub: 'Niimbot D11/D110' },
{ id: '50x30', label: '50 × 30', sub: 'Padrão' },
{ id: '62x29', label: '62 × 29', sub: 'Brother DK' },
{ id: '38x25', label: '38 × 25', sub: 'Dymo 11354' },
];
// Conteúdo mínimo para etiquetas muito pequenas (Niimbot)
const MINI_LABEL_CONTENT: Set<ContentOption> = new Set(['color', 'name', 'qrcode']);
type ContentOption = 'color' | 'name' | 'material_brand' | 'net_weight' | 'print_temp' | 'qrcode';
const CONTENT_OPTIONS: { id: ContentOption; label: string }[] = [
@@ -64,6 +68,13 @@ export default function LabelScreen(): React.ReactElement {
const pct = calcFilamentPercentage(filament);
function handleSizeChange(size: LabelSize): void {
setSelectedSize(size);
if (size === '22x14') {
setEnabledContent(new Set(MINI_LABEL_CONTENT));
}
}
function toggleContent(opt: ContentOption): void {
setEnabledContent((prev) => {
const next = new Set(prev);
@@ -78,16 +89,24 @@ export default function LabelScreen(): React.ReactElement {
const height_mm = Number(heightStr);
const isPdf = selectedFormat === 'pdf';
const fields = Array.from(enabledContent).join(',');
// DPI alvo: 203 para Niimbot (impressora térmica de baixo custo), 96 para tela
const dpi = selectedSize === '22x14' ? 203 : undefined;
try {
if (isPdf) {
const response = await httpClient.get<ArrayBuffer>(
`/filaments/${id}/label.pdf`,
{ params: { width_mm, height_mm, fields }, responseType: 'arraybuffer' },
);
const base64 = btoa(
String.fromCharCode(...new Uint8Array(response.data)),
{ params: { width_mm, height_mm, fields, dpi }, responseType: 'arraybuffer' },
);
// Converte ArrayBuffer para Base64 em chunks para evitar stack overflow
// em buffers grandes (String.fromCharCode com spread falha acima de ~64KB)
const bytes = new Uint8Array(response.data);
let binary = '';
const CHUNK = 8192;
for (let i = 0; i < bytes.length; i += CHUNK) {
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
}
const base64 = btoa(binary);
const fileUri = `${FileSystem.cacheDirectory}meowspool-label-${id}.pdf`;
await FileSystem.writeAsStringAsync(fileUri, base64, {
encoding: FileSystem.EncodingType.Base64,
@@ -100,7 +119,7 @@ export default function LabelScreen(): React.ReactElement {
} else {
const response = await httpClient.get<string>(
`/filaments/${id}/label.svg`,
{ params: { width_mm, height_mm, fields }, responseType: 'text' },
{ params: { width_mm, height_mm, fields, dpi }, responseType: 'text' },
);
const fileUri = `${FileSystem.cacheDirectory}meowspool-label-${id}.svg`;
await FileSystem.writeAsStringAsync(fileUri, response.data, { encoding: 'utf8' });
@@ -180,7 +199,7 @@ export default function LabelScreen(): React.ReactElement {
{LABEL_SIZES.map((s) => (
<TouchableOpacity
key={s.id}
onPress={() => setSelectedSize(s.id)}
onPress={() => handleSizeChange(s.id)}
style={[styles.sizeChip, selectedSize === s.id && styles.sizeChipActive]}
>
<Text style={[styles.sizeChipMain, selectedSize === s.id && styles.sizeChipMainActive]}>