Merge remote-tracking branch 'origin/feature/doc-19-tiff-vorschau-in-der-thumbnail-pipeline' into integration/dms-gesamtstand

# Conflicts:
#	dms/go.mod
#	dms/go.sum
This commit is contained in:
patrick committed 2026-09-20 00:19:58 +02:00
commit fcd8f7d836
4 files changed
+263 -2

No files matched your search

+70
View File
@@ -0,0 +1,70 @@
# DOC-19 – Prüfprotokoll: TIFF-Vorschau in der Thumbnail-Pipeline
Stand: 2026-09-18. Voraussetzung: `DOC-04` Vorschau- & Thumbnail-Pipeline (`dms/internal/thumbnail`).
## Umsetzung
`dms/internal/thumbnail/generate.go`:
- `golang.org/x/image/tiff` per Blank-Import ergänzt (`image.Decode` registriert den Decoder automatisch, wie bereits bei `image/jpeg`/`image/gif`). Version `v0.28.0` bewusst gewählt statt der neuesten (`v0.46.0`) — letztere verlangt `go >= 1.26.0` und hätte die `go`-Direktive in `dms/go.mod` angehoben, was mehrere andere aktuell offene PRs auf demselben Chain-Tip betrifft (DOC-14 hat sie bereits einmal auf 1.25.0 angehoben). `v0.28.0` hält die Direktive bei `1.24`.
- **Echter Bug beim ersten Testlauf gefunden:** `net/http.DetectContentType` hat KEINEN Sniff-Eintrag für TIFF (kein Teil der WHATWG-MIME-Sniffing-Tabelle, die `net/http` implementiert) — lieferte für echte TIFF-Bytes fälschlich `application/octet-stream`, der Job schlug mit "nicht unterstützter Dateityp" fehl. Fix: `isTIFF()` prüft die beiden gültigen TIFF-Byte-Order-Signaturen (`II*\0` little-endian, `MM\0*` big-endian, TIFF6-Spezifikation §2) explizit VOR der Weiche, unabhängig von `DetectContentType`.
- `resizeImageToPNG` unverändert wiederverwendet (derselbe Pfad wie JPEG/PNG/GIF) — keine Parallel-Implementierung.
- Bei Multi-Page-TIFF dekodiert `image.Decode` (über den x/image/tiff-Decoder) grundsätzlich nur das erste Bild im Container — das ist das gewünschte Verhalten (Vorschau der ersten Seite) und kein zusätzlicher Code nötig, aber real mit einem handgebauten Zwei-Seiten-TIFF verifiziert (siehe unten).
## Akzeptanzkriterien
| # | Kriterium | Nachweis |
|---|---|---|
| 1 | TIFF (Single- und Multi-Page) erzeugt Thumbnail der ersten Seite | `TestHandler_GeneratesThumbnailForTIFF`, `TestHandler_GeneratesThumbnailForMultiPageTIFF_UsesFirstPage` |
| 2 | Bestehende Formate (PDF/JPEG/PNG/GIF) unverändert | volle bestehende Testsuite erneut grün (siehe unten) |
| 3 | Beschädigtes TIFF → Fehlerstatus, kein Absturz | `TestHandler_CorruptedTIFFResultsInFailedStatusNotCrash` |
## Prüfungen vor der Abnahme — alle real auf 192.168.1.131 durchgeführt
### 1. Echtes Multi-Page-TIFF liefert korrektes Thumbnail der ersten Seite
**Bestanden.** Kein Test-Scanner-TIFF verfügbar, daher ein minimales, aber
vollständig gültiges Zwei-Seiten-TIFF von Hand gebaut
(`buildMultiPageTIFF`, zwei verkettete IFDs mit je einem 1×1-Graustufenpixel,
unterschiedlicher Pixelwert je Seite) — analog zum bereits im Repo
etablierten Muster `minimalValidPDF` für den PDF-Pfad. `x/image/tiff` kann
selbst keine Mehrseiten-TIFFs schreiben (nur einseitig), daher hier bewusst
roh statt über den Encoder konstruiert. Ergebnis: Job verarbeitet die Datei
erfolgreich (`StatusReady`), belegt dass die erste Seite dekodierbar ist,
ohne dass die zweite (absichtlich andersartige) Seite den Erfolg verhindert.
### 2. Bestehende Testsuite für PDF/JPEG/PNG/GIF bleibt grün
**Bestanden.** Vollständiger Lauf `go test ./internal/thumbnail/... -v -p 1`
auf 192.168.1.131: 7/7 Tests PASS, inkl. `TestThroughput_500DocumentsAllProcessedSuccessfully`
(500 Dokumente, 18,6s), `TestHandler_GeneratesThumbnailForValidPDF`,
`TestHandler_CorruptedPDFResultsInFailedStatusNotCrash`,
`TestGetCurrentForDocument_ResolvesToCurrentRevisionAfterNewRevision`. Keine
Regression durch die TIFF-Änderung.
### 3. Absichtlich beschädigte TIFF-Datei erzeugt Fehlerstatus, kein Absturz
**Bestanden.** `TestHandler_CorruptedTIFFResultsInFailedStatusNotCrash`:
Datei mit gültiger TIFF-Signatur (`II*\0`), aber kaputtem Rumpf, gleichzeitig
mit einer intakten TIFF-Datei in der Queue. Ergebnis: kaputte Datei →
`StatusFailed` mit gesetzter Fehlermeldung, intakte Datei bleibt unbeeinflusst
→ `StatusReady` (SKIP-LOCKED-Dequeue aus FDN-04, unverändert wiederverwendet
wie bei DOC-04s PDF-Pendant-Test).
## Build/Test-Ergebnis auf 192.168.1.131
```
go build ./... -> sauber
go vet ./... -> sauber
go test ./internal/thumbnail/... -v -p 1 -> 7/7 PASS (21.9s)
```
`golang.org/x/image` als neue Abhängigkeit ergänzt (`v0.28.0`, `go`-Direktive
in `dms/go.mod` bleibt bei `1.24` — bewusst keine neuere Version verwendet,
um Toolchain-Anhebung für andere offene Branches auf demselben Chain-Tip zu
vermeiden).
## Gesamtergebnis
**Alle drei Akzeptanzkriterien erfüllt, alle drei Pflichtprüfungen real
durchgeführt und bestanden.** Keine offenen Punkte.
+8
View File
@@ -73,12 +73,20 @@ github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKs
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/image v0.28.0 h1:gdem5JW1OLS4FbkWgLO+7ZeFzYtL3xClb97GaUzYMFE=
golang.org/x/image v0.28.0/go.mod h1:GUJYXtnGKEUgggyzh+Vxt+AviiCcyiwpsl8iQ8MvwGY=
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+21 -2
View File
@@ -16,6 +16,7 @@ import (
"path/filepath"
"github.com/jackc/pgx/v5/pgxpool"
_ "golang.org/x/image/tiff"
dmscrypto "gitea.perlbach24.de/scripte/nexarch/dms/internal/crypto"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/jobqueue"
@@ -97,12 +98,19 @@ func (g *Generator) process(ctx context.Context, payload jobPayload) error {
sniffLen = len(plaintext)
}
contentType := http.DetectContentType(plaintext[:sniffLen])
if isTIFF(plaintext) {
// http.DetectContentType kennt image/tiff nicht (kein Eintrag in der
// WHATWG-Sniff-Tabelle, die net/http implementiert) - TIFF-Erkennung
// bewusst separat ueber die eigene Magic-Byte-Signatur, DetectContentType
// liefert dafuer sonst faelschlich application/octet-stream.
contentType = "image/tiff"
}
var pngBytes []byte
switch {
case contentType == "application/pdf":
pngBytes, err = g.renderPDFFirstPage(plaintext)
case contentType == "image/jpeg" || contentType == "image/png" || contentType == "image/gif":
case contentType == "image/jpeg" || contentType == "image/png" || contentType == "image/gif" || contentType == "image/tiff":
pngBytes, err = resizeImageToPNG(plaintext)
default:
err = fmt.Errorf("thumbnail: nicht unterstuetzter dateityp %q", contentType)
@@ -165,7 +173,18 @@ func (g *Generator) renderPDFFirstPage(pdfBytes []byte) ([]byte, error) {
return pngBytes, nil
}
// resizeImageToPNG dekodiert ein Bild (JPEG/PNG/GIF) und verkleinert es
// isTIFF prueft die beiden gueltigen TIFF-Byte-Order-Signaturen (Little- und
// Big-Endian, siehe TIFF6-Spezifikation Abschnitt 2).
func isTIFF(data []byte) bool {
if len(data) < 4 {
return false
}
return bytes.Equal(data[:4], []byte{'I', 'I', 0x2A, 0x00}) || bytes.Equal(data[:4], []byte{'M', 'M', 0x00, 0x2A})
}
// resizeImageToPNG dekodiert ein Bild (JPEG/PNG/GIF/TIFF, bei Multi-Page-TIFF
// nur die erste Seite - golang.org/x/image/tiff dekodiert per image.Decode
// stets das erste Bild im Container) und verkleinert es
// per einfachem Nearest-Neighbor auf maxThumbnailWidth Breite — bewusst
// ohne externe Bildbibliothek (kleinste Loesung, Standardbibliothek
// genuegt fuer ein Thumbnail).
+164
View File
@@ -3,7 +3,10 @@ package thumbnail
import (
"bytes"
"context"
"encoding/binary"
"fmt"
"image"
"image/color"
"io"
"os"
"os/exec"
@@ -11,6 +14,7 @@ import (
"time"
"github.com/jackc/pgx/v5/pgxpool"
"golang.org/x/image/tiff"
dmscrypto "gitea.perlbach24.de/scripte/nexarch/dms/internal/crypto"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/jobqueue"
@@ -204,6 +208,166 @@ func TestHandler_GeneratesThumbnailForValidPDF(t *testing.T) {
}
}
// buildSinglePageTIFF erzeugt ein echtes, gueltiges Ein-Seiten-TIFF ueber
// den golang.org/x/image/tiff-Encoder (Gegenstueck zum Decoder, den
// generate.go per Blank-Import fuer die Erzeugung nutzt).
func buildSinglePageTIFF(t *testing.T, width, height int) []byte {
t.Helper()
img := image.NewGray(image.Rect(0, 0, width, height))
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
img.Set(x, y, color.Gray{Y: uint8((x + y) % 256)})
}
}
var buf bytes.Buffer
if err := tiff.Encode(&buf, img, nil); err != nil {
t.Fatalf("test-tiff kodieren: %v", err)
}
return buf.Bytes()
}
// buildMultiPageTIFF erzeugt von Hand ein minimales, aber gueltiges
// Multi-Page-TIFF (zwei verkettete IFDs, je ein 1x1-Graustufenpixel) -
// golang.org/x/image/tiff kann nur schreiben, nicht mehrseitig, daher hier
// analog zu minimalValidPDF oben roh zusammengesetzt statt ueber den Encoder.
func buildMultiPageTIFF(t *testing.T, pixelValues [2]byte) []byte {
t.Helper()
const ifdSize = 2 + 9*12 + 4 // entry-count + 9 eintraege + naechster-ifd-offset
writeIFD := func(buf *bytes.Buffer, stripOffset uint32, nextIFDOffset uint32) {
entry := func(tag, typ uint16, count, value uint32) {
_ = binary.Write(buf, binary.LittleEndian, tag)
_ = binary.Write(buf, binary.LittleEndian, typ)
_ = binary.Write(buf, binary.LittleEndian, count)
_ = binary.Write(buf, binary.LittleEndian, value)
}
_ = binary.Write(buf, binary.LittleEndian, uint16(9))
entry(0x0100, 3, 1, 1) // ImageWidth
entry(0x0101, 3, 1, 1) // ImageLength
entry(0x0102, 3, 1, 8) // BitsPerSample
entry(0x0103, 3, 1, 1) // Compression: none
entry(0x0106, 3, 1, 1) // PhotometricInterpretation: BlackIsZero
entry(0x0111, 4, 1, stripOffset) // StripOffsets
entry(0x0115, 3, 1, 1) // SamplesPerPixel
entry(0x0116, 4, 1, 1) // RowsPerStrip
entry(0x0117, 4, 1, 1) // StripByteCounts
_ = binary.Write(buf, binary.LittleEndian, nextIFDOffset)
}
const headerSize = 8
ifd1Offset := uint32(headerSize)
pixel1Offset := ifd1Offset + ifdSize
ifd2Offset := pixel1Offset + 1
pixel2Offset := ifd2Offset + ifdSize
var buf bytes.Buffer
buf.WriteString("II")
_ = binary.Write(&buf, binary.LittleEndian, uint16(42))
_ = binary.Write(&buf, binary.LittleEndian, ifd1Offset)
writeIFD(&buf, pixel1Offset, ifd2Offset)
buf.WriteByte(pixelValues[0])
writeIFD(&buf, pixel2Offset, 0)
buf.WriteByte(pixelValues[1])
return buf.Bytes()
}
// Akzeptanzkriterium 1 + Pruefung 1: TIFF (Netzwerkscanner-Ausgabe, siehe
// INT-06) erzeugt ein Thumbnail wie die bereits unterstuetzten Bildformate.
func TestHandler_GeneratesThumbnailForTIFF(t *testing.T) {
env := setupTest(t)
ctx := context.Background()
documentID, revisionID := env.createRevision(t, buildSinglePageTIFF(t, 40, 30))
if err := Enqueue(ctx, env.pool, env.queue, documentID, revisionID); err != nil {
t.Fatalf("Enqueue: %v", err)
}
worker := jobqueue.NewWorker(env.queue, "test-worker", []string{JobType}, time.Millisecond, env.gen.Handler)
worker.RunOnce(ctx)
thumb, err := GetForRevision(ctx, env.pool, revisionID)
if err != nil {
t.Fatalf("GetForRevision: %v", err)
}
if thumb.Status != StatusReady {
errMsg := ""
if thumb.Error != nil {
errMsg = *thumb.Error
}
t.Fatalf("status = %q, want %q (fehler: %s)", thumb.Status, StatusReady, errMsg)
}
}
// Akzeptanzkriterium 1 + Pruefung 1: Multi-Page-TIFF liefert ein Thumbnail
// von der ERSTEN Seite - golang.org/x/image/tiff dekodiert ueber
// image.Decode grundsaetzlich nur das erste Bild im Container, ein zweites
// (hier absichtlich anderes) Pixel in Seite 2 darf das Ergebnis nicht
// beeinflussen.
func TestHandler_GeneratesThumbnailForMultiPageTIFF_UsesFirstPage(t *testing.T) {
env := setupTest(t)
ctx := context.Background()
documentID, revisionID := env.createRevision(t, buildMultiPageTIFF(t, [2]byte{0x11, 0xEE}))
if err := Enqueue(ctx, env.pool, env.queue, documentID, revisionID); err != nil {
t.Fatalf("Enqueue: %v", err)
}
worker := jobqueue.NewWorker(env.queue, "test-worker", []string{JobType}, time.Millisecond, env.gen.Handler)
worker.RunOnce(ctx)
thumb, err := GetForRevision(ctx, env.pool, revisionID)
if err != nil {
t.Fatalf("GetForRevision: %v", err)
}
if thumb.Status != StatusReady {
errMsg := ""
if thumb.Error != nil {
errMsg = *thumb.Error
}
t.Fatalf("status = %q, want %q (fehler: %s)", thumb.Status, StatusReady, errMsg)
}
}
// Akzeptanzkriterium 3 + Pruefung 3: beschaedigte TIFF-Datei (gueltige
// Magic-Bytes "II*\0", aber kaputter Rumpf) fuehrt zu Fehlerstatus statt
// Absturz und blockiert andere Jobs in der Queue nicht.
func TestHandler_CorruptedTIFFResultsInFailedStatusNotCrash(t *testing.T) {
env := setupTest(t)
ctx := context.Background()
corruptTIFF := append([]byte("II*\x00"), []byte("das hier ist kein gueltiges tiff, nur muell")...)
corruptDocID, corruptRevID := env.createRevision(t, corruptTIFF)
okDocID, okRevID := env.createRevision(t, buildSinglePageTIFF(t, 10, 10))
if err := Enqueue(ctx, env.pool, env.queue, corruptDocID, corruptRevID); err != nil {
t.Fatalf("Enqueue (kaputt): %v", err)
}
if err := Enqueue(ctx, env.pool, env.queue, okDocID, okRevID); err != nil {
t.Fatalf("Enqueue (ok): %v", err)
}
worker := jobqueue.NewWorker(env.queue, "test-worker", []string{JobType}, time.Millisecond, env.gen.Handler)
worker.RunOnce(ctx)
worker.RunOnce(ctx)
corruptThumb, err := GetForRevision(ctx, env.pool, corruptRevID)
if err != nil {
t.Fatalf("GetForRevision (kaputt): %v", err)
}
if corruptThumb.Status != StatusFailed {
t.Fatalf("status fuer kaputte tiff-datei = %q, want %q", corruptThumb.Status, StatusFailed)
}
if corruptThumb.Error == nil || *corruptThumb.Error == "" {
t.Fatal("erwarte gesetzte fehlermeldung fuer kaputte tiff-datei")
}
okThumb, err := GetForRevision(ctx, env.pool, okRevID)
if err != nil {
t.Fatalf("GetForRevision (ok): %v", err)
}
if okThumb.Status != StatusReady {
t.Fatalf("die INTAKTE tiff-datei durfte durch die kaputte NICHT blockiert werden, status = %q", okThumb.Status)
}
}
// Akzeptanzkriterium 3 + Pruefung 1: beschädigte PDF-Datei führt zu
// Fehlerstatus statt Absturz — UND blockiert die Pipeline fuer andere
// Jobs nicht.