diff --git a/dms/docs/DOC-19-PRUEFPROTOKOLL.md b/dms/docs/DOC-19-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..538eb665 --- /dev/null +++ b/dms/docs/DOC-19-PRUEFPROTOKOLL.md @@ -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. diff --git a/dms/go.sum b/dms/go.sum index eaa7314a..5e7f7343 100644 --- a/dms/go.sum +++ b/dms/go.sum @@ -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= diff --git a/dms/internal/thumbnail/generate.go b/dms/internal/thumbnail/generate.go index b1441e7b..127a73ec 100644 --- a/dms/internal/thumbnail/generate.go +++ b/dms/internal/thumbnail/generate.go @@ -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). diff --git a/dms/internal/thumbnail/thumbnail_test.go b/dms/internal/thumbnail/thumbnail_test.go index 1ddfc693..0928a337 100644 --- a/dms/internal/thumbnail/thumbnail_test.go +++ b/dms/internal/thumbnail/thumbnail_test.go @@ -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.