This commit is contained in:
2026-03-14 19:45:08 -03:00
parent 9ac83ad823
commit 74adc35b53
23 changed files with 1082 additions and 150 deletions
+77
View File
@@ -348,6 +348,17 @@ dependencies = [
"generic-array",
]
[[package]]
name = "bstr"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab"
dependencies = [
"memchr",
"regex-automata",
"serde",
]
[[package]]
name = "built"
version = "0.8.0"
@@ -1555,6 +1566,12 @@ dependencies = [
"vcpkg",
]
[[package]]
name = "linked-hash-map"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
[[package]]
name = "litemap"
version = "0.8.1"
@@ -1585,6 +1602,23 @@ dependencies = [
"imgref",
]
[[package]]
name = "lopdf"
version = "0.31.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07c8e1b6184b1b32ea5f72f572ebdc40e5da1d2921fa469947ff7c480ad1f85a"
dependencies = [
"encoding_rs",
"flate2",
"itoa",
"linked-hash-map",
"log",
"md5",
"pom",
"time",
"weezl",
]
[[package]]
name = "lru-slab"
version = "0.1.2"
@@ -1626,6 +1660,12 @@ dependencies = [
"digest",
]
[[package]]
name = "md5"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771"
[[package]]
name = "memchr"
version = "2.8.0"
@@ -1646,6 +1686,7 @@ dependencies = [
"image",
"jsonwebtoken",
"oauth2",
"printpdf",
"qrcode",
"reqwest 0.12.28",
"serde",
@@ -1849,6 +1890,15 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "owned_ttf_parser"
version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "706de7e2214113d63a8238d1910463cfce781129a6f263d13fdb09ff64355ba4"
dependencies = [
"ttf-parser",
]
[[package]]
name = "parking"
version = "2.2.1"
@@ -1984,6 +2034,15 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "pom"
version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c972d8f86e943ad532d0b04e8965a749ad1d18bb981a9c7b3ae72fe7fd7744b"
dependencies = [
"bstr",
]
[[package]]
name = "potential_utf"
version = "0.1.4"
@@ -2028,6 +2087,18 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "printpdf"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c30a4cc87c3ca9a98f4970db158a7153f8d1ec8076e005751173c57836380b1d"
dependencies = [
"js-sys",
"lopdf",
"owned_ttf_parser",
"time",
]
[[package]]
name = "proc-macro-error"
version = "1.0.4"
@@ -3444,6 +3515,12 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "ttf-parser"
version = "0.19.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49d64318d8311fc2668e48b63969f4343e0a85c4a109aa8460d6672e364b8bd1"
[[package]]
name = "typenum"
version = "1.19.0"
+3
View File
@@ -62,6 +62,9 @@ qrcode = "0.14"
image = "0.25"
base64 = "0.22"
# PDF generation
printpdf = "0.7"
# Async trait
async-trait = "0.1"
+134
View File
@@ -0,0 +1,134 @@
# MeowSpool Backend — TODO
> Última atualização: 14/03/2026
---
## Crítico — Funcionalidades Quebradas
### Auth: Email e Reset de Senha
Todos os três handlers abaixo retornam `200 OK` sem executar nenhuma lógica real.
Dependem de infraestrutura compartilhada que ainda não existe (ver "Bloqueadores").
- [ ] **`forgot_password_handler`** (`src/adapters/inbound/auth_handler.rs:131`)
- Gerar token de reset seguro (UUID v4 ou CSPRNG)
- Salvar na tabela `password_reset_tokens` (ver migration abaixo)
- Enviar email com link de reset via serviço de email
- Token deve ter TTL de 1h e ser invalidado após uso
- [ ] **`verify_email_handler`** (`src/adapters/inbound/auth_handler.rs:138`)
- Validar token recebido contra tabela `email_verification_tokens`
- Chamar `user_repo.verify_email(user_id)` (método já implementado no repo)
- Marcar token como usado / deletar após validação
- Retornar `400 Bad Request` para token inválido ou expirado
- [ ] **`reset_password_handler`** (`src/adapters/inbound/auth_handler.rs:143`)
- Validar token recebido contra tabela `password_reset_tokens`
- Verificar TTL e se já foi usado
- Hash da nova senha com Argon2id
- Atualizar `password_hash` via `user_repo.update()`
- Invalidar token após uso
### Bloqueadores das features de Auth acima
- [ ] **Migration: `password_reset_tokens`**
```sql
CREATE TABLE password_reset_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
used_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX ON password_reset_tokens (token);
```
- [ ] **Migration: `email_verification_tokens`**
```sql
CREATE TABLE email_verification_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
used_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX ON email_verification_tokens (token);
```
- [ ] **Serviço de email** (`src/infrastructure/email_service.rs` — novo arquivo)
- Adicionar dependência: `lettre` (SMTP) ou integração com API (Resend, SendGrid, Mailgun)
- Adicionar vars de ambiente no `config.rs`: `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `EMAIL_FROM`
- Implementar `send_password_reset_email(to, reset_link)`
- Implementar `send_verification_email(to, code)`
### Perfil de Usuário
- [ ] **`update_me_handler`** (`src/adapters/inbound/user_handler.rs:64`)
- Handler valida formato de email mas não persiste nada
- Implementar chamada a `user_repo.update()` com os campos recebidos
- Considerar campos permitidos: `name` (se adicionado ao domínio), `email`
- Revalidar unicidade de email antes de atualizar
---
## Médio — Segurança
- [ ] **Google OAuth: validar `aud` claim** (`src/adapters/inbound/auth_handler.rs:159`)
- O parâmetro `_client_id` é recebido mas ignorado
- Após obter o payload do tokeninfo do Google, verificar que `aud == GOOGLE_CLIENT_ID`
- Rejeitar com `401 Unauthorized` se o `aud` não corresponder
- Sem isso, qualquer Google token válido de qualquer app é aceito
- [ ] **Logout: invalidar refresh token** (`src/adapters/inbound/auth_handler.rs:121`)
- Atualmente retorna `204 No Content` sem nenhuma ação
- Opção A (simples): tabela `revoked_tokens` no PostgreSQL com TTL via `expires_at`
- Opção B (recomendada): Redis com `SET revoked:{jti} 1 EX <ttl>`
- Middleware de auth deve checar blocklist ao validar tokens
- Adicionar campo `jti` (JWT ID) aos tokens para identificação única
---
## Baixo — Qualidade e Completude
- [ ] **Sort de filamentos não aplicado** (`src/adapters/outbound/postgres_filament_repo.rs`)
- `SortOrder` enum está definido em `ports/filament_repository.rs`
- SQL em `list()` sempre usa `ORDER BY created_at DESC`, ignorando o campo `sort`
- `filament_handler.rs:203` tem `// TODO: parse sort string para SortOrder` — completar o parse
- Aplicar o `SortOrder` no SQL com match: `net_weight_g ASC`, `net_weight_g DESC`, `created_at DESC`
- [ ] **Paginação: retornar total de itens**
- `FilamentRepository::count()` está implementado no repo mas nunca é chamado
- `list_filaments_handler` não retorna metadados de paginação
- Adicionar ao response: `{ data: [...], total: N, page: N, per_page: N }`
- Chamar `repo.count(user_id, filter)` em paralelo com `repo.list()`
- [ ] **`GET /spool-presets/:id` — handler ausente**
- Rota está registrada no `router.rs`
- Nenhum handler correspondente existe em `spool_preset_handler.rs`
- Implementar `get_preset_handler` ou remover a rota do router
- [ ] **Testes**
- Sem nenhum teste unitário ou de integração no projeto
- Prioridade: `AuthService` (hash, JWT), `FilamentService` (cálculos de peso, conflito offline), handlers de auth
- Usar `sqlx::test` para testes de repositório com banco real em transação
---
## Referência Rápida — Arquivos por Área
| Área | Arquivo |
| --------------------- | ------------------------------------------------- |
| Handlers de auth | `src/adapters/inbound/auth_handler.rs` |
| Handler de usuário | `src/adapters/inbound/user_handler.rs` |
| Handler de filamentos | `src/adapters/inbound/filament_handler.rs` |
| Handler de presets | `src/adapters/inbound/spool_preset_handler.rs` |
| Repo de usuário | `src/adapters/outbound/postgres_user_repo.rs` |
| Repo de filamentos | `src/adapters/outbound/postgres_filament_repo.rs` |
| Serviço de auth | `src/application/auth_service.rs` |
| Config / env vars | `src/config.rs` |
| Migrations | `backend/migrations/` |
+49 -2
View File
@@ -24,6 +24,9 @@ Backend da aplicação MeowSpool escrito em **Rust**, utilizando **Axum** como f
| Validação | `validator` | 0.18 |
| HTTP Client | `reqwest` (json, rustls-tls) | 0.12 |
| Geração QR Code | `qrcode` | 0.14 |
| Geração de imagem| `image` | 0.25 |
| Encode Base64 | `base64` | 0.22 |
| Geração PDF | `printpdf` | 0.7 |
---
@@ -138,6 +141,7 @@ Todas as rotas são prefixadas com `/api/v1`.
| DELETE | `/:id` | `delete_filament_handler` | Autenticado |
| GET | `/:id/qrcode` | `get_qrcode_handler` | Autenticado |
| GET | `/:id/label.svg` | `export_label_handler` | Autenticado |
| GET | `/:id/label.pdf` | `export_label_pdf_handler`| Autenticado |
**Query params de listagem (`GET /filaments`):**
@@ -370,11 +374,36 @@ 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)
- Retorna SVG com: cor visual, modelo, material, marca, peso líquido e QR Code embutido
- Query params: `width_mm` (padrão: 50), `height_mm` (padrão: 30), `fields` (opcional)
- Retorna SVG com os campos selecionados embutidos
- `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
- `Content-Type: application/pdf`
- `Content-Disposition: attachment; filename="meowspool-label-{id}.pdf"`
### Parâmetro `fields` (SVG e PDF)
Controla quais elementos aparecem na etiqueta. Valor: string com itens separados por vírgula.
| Valor | Elemento |
| --------------- | ------------------------------- |
| `color` | Barra de cor lateral |
| `name` | Nome do modelo |
| `material_brand`| Texto "Marca · Material" |
| `net_weight` | Peso líquido disponível |
| `print_temp` | Temperatura de impressão |
| `qrcode` | QR Code com deep link |
Se `fields` for omitido, todos os elementos são incluídos.
Implementado em `LabelFields` (`filament_service.rs`) — `LabelFields::from_str(Option<&str>)`.
---
## Segurança
@@ -390,6 +419,24 @@ O backend implementa **last-write-wins com timestamp**:
## Mudanças Recentes (14/03/2026)
### ✅ Exportação de Etiqueta PDF (`GET /filaments/:id/label.pdf`)
- **Dependência**: `printpdf = "0.7"` adicionada ao `Cargo.toml`
- **Service**: `FilamentService::generate_label_pdf` em `filament_service.rs`
- Layout: fundo `#1E1B18`, barra de cor (filament.color_hex), texto Helvetica builtin, QR Code PNG luma embutido
- Página com dimensões exatas em mm via `printpdf::Mm`
- Usa `Polygon` + `PaintMode::Fill` para retângulos (API do printpdf 0.7)
- QR Code embutido via `ImageXObject` com pixels luma brutos — sem depender do feature `image` do printpdf
- **Handler**: `export_label_pdf_handler` em `filament_handler.rs`
- **Rota**: `GET /api/v1/filaments/:id/label.pdf`
### ✅ Controle de Campos na Etiqueta (`fields` query param)
- **Struct**: `LabelFields` em `filament_service.rs` — parseia string CSV de campos ativos
- **Aplicado em**: `generate_label_svg` e `generate_label_pdf`
- **LabelQuery**: adicionado campo `fields: Option<String>` em `filament_handler.rs`
- Permite que o cliente selecione quais elementos aparecem na etiqueta (cor, nome, material, peso, temp, QR)
### ✅ Correção: PUT Spool Preset retornava FORBIDDEN
**Local:** `src/adapters/outbound/postgres_spool_preset_repo.rs` — método `update()`
@@ -5,7 +5,7 @@ info:
http:
method: POST
url: http://0.0.0.0:8080/api/v1/auth/login
url: https://meowspool.felipecncloud.com/api/v1/auth/login
body:
type: json
data: |-
@@ -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");
+210 -19
View File
@@ -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 {
+1
View File
@@ -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))