diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 8b1db4f..3a53ec8 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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:*)" ] } } diff --git a/backend/agent.md b/backend/agent.md index 8130a85..47c7005 100644 --- a/backend/agent.md +++ b/backend/agent.md @@ -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` 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 | 7–9pt 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` diff --git a/backend/src/adapters/inbound/filament_handler.rs b/backend/src/adapters/inbound/filament_handler.rs index cbbc4a1..7aa2bcd 100644 --- a/backend/src/adapters/inbound/filament_handler.rs +++ b/backend/src/adapters/inbound/filament_handler.rs @@ -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, + /// 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, } fn default_width_mm() -> u32 { 50 } @@ -297,7 +300,7 @@ pub async fn export_label_pdf_handler( ) -> Result { 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 { 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"); diff --git a/backend/src/application/filament_service.rs b/backend/src/application/filament_service.rs index 7ffa6b5..6e69561 100644 --- a/backend/src/application/filament_service.rs +++ b/backend/src/application/filament_service.rs @@ -291,19 +291,18 @@ impl FilamentService { width_mm: u32, height_mm: u32, fields: Option<&str>, + dpi: Option, ) -> Result { 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#" "#, - h = height_px - 12, color = color) + let pad_v = (height_px as f64 * 0.08) as u32; + format!( + r#" "#, + h = height_px.saturating_sub(pad_v * 2), + ) } else { String::new() }; let text_name = if f.name { - format!(r##" {model_name}"##, - model_name = escape_xml(model_name)) + format!( + r##" {model_name}"##, + model_name = escape_xml(model_name) + ) } else { String::new() }; let text_meta = if f.material_brand { - format!(r##" {brand} · {material}"##, - brand = escape_xml(brand), material = escape_xml(&material)) + let sep = if is_mini { " " } else { " · " }; + format!( + r##" {brand}{sep}{material}"##, + brand = escape_xml(brand), material = escape_xml(&material) + ) } else { String::new() }; let text_weight = if f.net_weight { - format!(r##" {net_weight}g"##, - net_weight = net_weight) + format!( + r##" {net_weight}g"##, + ) } else { String::new() }; let qr_element = if f.qrcode { - format!(r##" "##, - qr_x = width_px - qr_size - 4, - qr_y = (height_px - qr_size) / 2, - qr_size = qr_size, - qr_b64 = qr_b64) + format!( + r##" "##, + ) } else { String::new() }; + let rx = if is_mini { 2 } else { 4 }; let svg = format!( r##" - + {color_swatch} {text_name} {text_meta} {text_weight} {qr_element} "##, - 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, ) -> Result, 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(); diff --git a/mobile/agent.md b/mobile/agent.md index c0ced72..7da5e66 100644 --- a/mobile/agent.md +++ b/mobile/agent.md @@ -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 = 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. diff --git a/mobile/app/(app)/filaments/[id]/label.tsx b/mobile/app/(app)/filaments/[id]/label.tsx index 5fca37a..a1ab91e 100644 --- a/mobile/app/(app)/filaments/[id]/label.tsx +++ b/mobile/app/(app)/filaments/[id]/label.tsx @@ -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 = 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( `/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( `/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) => ( setSelectedSize(s.id)} + onPress={() => handleSizeChange(s.id)} style={[styles.sizeChip, selectedSize === s.id && styles.sizeChipActive]} >