# Camada de Serviços de PDF — Diário Oficial

> _Atualizado para o **v6** (`update_diario_v5_to_v6`) em 2026-09-11. Alterações desta versão: `DIARIO_OFICIAL_ALTERACOES.md` e `plan/2026-09-11-diario-upgrade-backlog.md`._

Documento de referência da geração de PDF no **v6**. Substitui o formato de
orquestrador com 5 sub-serviços usado na versão anterior da wiki: no v6 existem
apenas **3 serviços**, e o `FPdfService` é **monolítico**.

## Visão geral

```
DiarioOficialRepository::generatePdfFile($id)
        │
        ▼
FPdfService (extends setasign\Fpdi\Tcpdf\Fpdi)     ← serviço monolítico (TCPDF)
        │  usa
        ├── PdfConverter    (PDF externo → JPEG; Imagick + Ghostscript)
        └── ImageProcessor  (dimensões em mm para <img>)
```

Arquivos:

| Arquivo | Linhas | Papel |
|---------|:------:|-------|
| `app/Services/FPdfService.php` | 374 | Serviço de PDF monolítico (Header/Footer, capítulos, capa, anexos, assinatura) |
| `app/Services/Pdf/PdfConverter.php` | 196 | Conversão PDF → JPEG + contagem de páginas |
| `app/Services/Pdf/ImageProcessor.php` | 181 | Dimensionamento/embutir de imagens HTML |

> `LayoutManager.php`, `HeaderRenderer.php` e `CoverHandler.php` **não existem no v6**.

---

## 1. `FPdfService` (monolítico)

- Herda de `setasign\Fpdi\Tcpdf\Fpdi` (que estende `TCPDF`).
- Instancia `PdfConverter`/`ImageProcessor` **sob demanda** (sem manter referências no construtor).
- Mantém as **propriedades públicas** de configuração preenchidas pelo repositório:
  `certificate`, `published_at`, `prefecture`, `diario`, `city`, `stateOf`, `publishedYear`,
  `assigner`, `address`, `columnsNumber`, `hasLine`, `lineStyle`, `lineColor`, `logoPosition`,
  `headerImg`, `coverImagePath`, `isPublished`, `progressCallback`.
- Principais métodos:
  - `Header()` — página 1 com capa → sem cabeçalho; sem capa → arte conforme `logoPosition`
    (`center`/`left`/`right`) + QR Code + dados da edição. Sempre: linha superior,
    `Publicação: ...` à direita, `Página N` à esquerda e divisor vertical quando
    `columnsNumber > 1 && hasLine`. Aplica `setSignature(...)` **somente se `isPublished`**.
  - `Footer()` — linha + `DOCUMENTO ASSINADO DIGITALMENTE POR ...`, prefeitura e endereço.
  - `PrintChapter($content, $columnsNumber)` — `checkIfIsNeedBreakPage()` + `resetColumns()` +
    `setEqualColumns(2,89)`/`(3,58)` + `ChapterBody()`.
  - `ChapterHeader($title)` — faixa cinza com o nome da **área**.
  - `ChapterTitle($title)` / `ChapterBody($content)`.
  - `checkIfIsNeedBreakPage()` — `y > 240` ou `x > 91` → `lastPage()` + `AddPage()`.
  - `addCoverPage()` — `Image(capa, 0, 0, 210, 297)` em página própria.
  - `processImageContent($content)` — delega ao `ImageProcessor`.
  - `buildAnexoImageTag($imagePath, $columnWidthMM, $maxHeightMM = 200)` — delega ao
    `ImageProcessor::buildTagForColumn()`.
  - `convertPdfToImages(...)` — delega ao `PdfConverter`, repassando `progressCallback`.
  - `hexToRgb`, `contentPageNumber`, `createStream`.

---

## 2. `PdfConverter`

Converte PDFs externos em imagens JPEG para incorporação no diário.

| Constante | Valor |
|-----------|-------|
| `IMAGE_DPI` | 100 |
| `IMAGE_WIDTH_TARGET` | 700 px |
| `JPEG_QUALITY` | 80 |

- `countPages($pdfPath)`: tenta **FPDI** (`setSourceFile`); se falhar (CrossReference
  não suportado pela versão gratuita), faz fallback para **Imagick** (`getNumberImages`).
- `convertPdfToImages($pdfPath, $outputDir, $diarioId, $materiaId, $progressCallback)`:
  - Cria o diretório de saída se necessário.
  - Loop **uma página por vez**, criando um **novo `Imagick`** por página
    (`readImage("{$pdfPath}[{$pageNum}]")`) → isola crashes do Ghostscript.
  - `setResolution(100)`, `pdf:use-cropbox`, resize Lanczos para 700 px, `unsharpMaskImage`,
    fundo branco, remove alpha, JPEG q80, `stripImage`.
  - Dispara `progressCallback($pageNum+1, $pageCount)`.
  - Nome: `diario_{id}_materia_{materiaId}_page_{n}.jpg`.
  - Retorna `['success' => bool, 'images' => [...], 'total_pages' => int]`
    (ou `error` em falha fatal).
  - Em erro de **policy** (`security policy`/`IsCoderAuthorized`), **aborta** a conversão
    (não gera dezenas de erros); se nenhuma página converter, retorna `success = false`.
- `createStream($file)`: `StreamReader::createByString` (estático).

---

## 3. `ImageProcessor`

Usado tanto pelas imagens do texto corrido (`FPdfService::processImageContent`) quanto
pelas páginas de anexo (`FPdfService::buildAnexoImageTag`).

| Constante | Valor |
|-----------|-------|
| `MAX_IMAGE_HEIGHT_MM` | 200 |
| `MAX_IMAGE_HEIGHT_POINTS` | 566 |
| `MAX_CONTENT_WIDTH_MM` | 190 |

- `processImageContent($content)`: regex em `<img src="...">`; resolve o caminho, lê dimensões,
  codifica em **base64 data URI** e substitui pela tag otimizada.
- `calculateOptimalDimensions($w, $h)`: converte px→pt (fator 72/96), limita à largura/altura
  máximas, **nunca aumenta** a imagem.
- `buildOptimizedImageTag($src, $dimensions)`: emite `width`/`height` em **mm**.
- `buildTagForColumn($src, $pxW, $pxH, $columnWidthMM, $maxHeightMM)`: dimensiona a página do
  anexo para a largura da coluna, respeitando a altura máxima.
- `getImagePath($src)`: ignora `data:`; resolve `/storage/...` e caminhos via `public_path`.

> **Nota técnica:** o **TCPDF 6.3 não implementa `max-width`/`max-height`**. Por isso as
> dimensões são emitidas como `width`/`height` em **mm** — mantendo o efeito da implementação
> clássica (tamanho fixo, independente da resolução da imagem).

### Dimensionamento resultante (página A4 700×989px)

| Colunas | Largura da coluna | Tamanho do anexo | Resultado |
|:------:|:----------------:|:----------------:|-----------|
| 2 | 89 mm | ~87 × 123 mm | 2 por coluna / 4 por página |
| 3 | 58 mm | ~58 × 82 mm | até 3 por coluna |
| 1 | 190 mm | ~141 × 200 mm | 1 por página |

---

## 4. Fluxo no `DiarioOficialRepository::generatePdfFile`

1. `@set_time_limit(0)` + log; busca `Diario`, `DiarioConfig` e `DiarioCertificado`
   (prioriza o certificado do diário; fallback para o padrão). Sem certificado → `false`.
2. Agrupa as matérias por **área** (`groupBy('id_area')`, ordenado por `position`).
   A **subárea não define seção** (não gera quebra de página/coluna).
3. Monta dados do PDF a partir de `Configuration` e gera o **QR Code** (`SimpleQrCode` →
   `public/img/qrcode.png`) apontando para `rtrim(config('app.url'), '/').'/diario-oficial/{id}'`.
4. Configura o `FPdfService` (certificado, colunas, logo, header, linha, capa) e
   `isPublished = (bool) $data->status`.
5. Adiciona capa (`addCoverPage`) ou página normal (`AddPage` + `ln(56)`).
6. Itera Áreas:
   - Nova área só abre página se a atual já tiver conteúdo (evita página em branco).
   - `ChapterHeader(nome da área)`.
   - Para cada matéria:
     - `type=file` + `file_in=true` → `convertPdfToImages()` (Imagick + Ghostscript) e
       `writeImagesInline()` (`writeHTML` + `selectColumn`, fluxo de colunas contínuo).
       Fallback: `printImportedPagesInline` → `printImportedPagesGrid`.
     - `type=file` + `file_in=false` → `printImportedPagesFullPage` (FPDI `useTemplate`).
       Fallback: `printImagesFullPage` → grade.
     - `type=text` → acumula HTML e ao final `PrintChapter($content, $columnsNumber)`.
7. **Escrita atômica**:
   ```php
   $pdfContent = $pdf->Output($pdf_name, 'S');
   Storage::put($caminho . '.tmp.' . uniqid() . '.pdf', $pdfContent);
   // remove destino antigo e move (rename atômico no mesmo filesystem)
   ```
8. `cleanupMergedPdfs($id)` (sucesso e exceção).

> O `mergePdfs()` de anexos por subárea **deixou de ser usado** no v6 (é código morto);
> `cleanupMergedPdfs()` apenas remove eventuais `_merged_*`.

### Progresso

`progressCallback` é injetado no `FPdfService` e salvo via `saveGenerationProgress` em
`diarios.generation_progress`:

```json
{"step":"processing_attachment","current_attachment":2,"total_attachments":3}
{"step":"processing_pages","current_attachment":2,"total_attachments":3,"current_page":5,"total_pages":27}
```

---

## 5. Certificação e dependências

- Assinatura aplicada no `FPdfService::Header()` **somente quando `isPublished`**:
  `setSignature($cert, $key, '', '', 2, $info)`.
- Extração de nome/CNPJ: `DiarioOficialRepository::certReturn()` (`openssl_x509_read` +
  `openssl_x509_parse`, `explode(':', subject.CN)`).
- Upload do certificado: `DiarioCertificadosController::store()` — valida CRT/CN/PKCS12,
  converte com `openssl pkcs12` e persiste `cert`/`pkey`.

| Pacote | Uso |
|--------|-----|
| `setasign/fpdi` (`^2.3`) + `tecnickcom/tcpdf` (`^6.3`) | Geração e importação de páginas |
| `simplesoftwareio/simple-qrcode` (`^2.0`) | QR Code |
| `imagick` (extensão PHP) + **Ghostscript** | PDF → JPEG e contagem de páginas |
| OpenSSL | Leitura do certificado |
| `barryvdh/laravel-dompdf` / `dompdf/dompdf` | Usados por outros módulos / template legado |

> **Infra:** o `docker/apache/Dockerfile` (não versionado) instala `ghostscript` e libera o
> coder PDF no policy do ImageMagick (`rights="read|write"`). Requer **rebuild da imagem** e
> `docker stop` + `docker start` (o PHP-FPM mantém o policy em cache).
