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:
commit
fcd8f7d836
4 files changed
+263
-2
No files matched your search
@@ -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.
|
||||
@@ -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=
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user