package preprocessor import ( "archive/zip" "bufio" "bytes" "encoding/base64" "encoding/json" "image" "image/draw" "image/gif" "io" "math" "mime" "strings" "github.com/pkg/errors" "golang.org/x/image/font" "golang.org/x/image/font/opentype" "golang.org/x/image/math/fixed" "github.com/dhowden/tag" thumbnailerErrors "github.com/opencloud-eu/opencloud/services/thumbnails/pkg/errors" ) // FileConverter is the interface for the file converter type FileConverter interface { Convert(r io.Reader) (any, error) } // GifDecoder is a converter for the gif file type GifDecoder struct{ limit decodeLimit } // Convert reads the gif file and returns the thumbnail image func (i GifDecoder) Convert(r io.Reader) (any, error) { r, err := i.limit.guardDimensions(r, gif.DecodeConfig) if err != nil { return nil, err } img, err := gif.DecodeAll(r) if err != nil { return nil, errors.Wrap(err, `could not decode the image`) } return img, nil } // GgsDecoder is a converter for the geogebra slides file type GgsDecoder struct { thumbnailpath string limit decodeLimit } // Convert reads the ggs file and returns the thumbnail image func (g GgsDecoder) Convert(r io.Reader) (any, error) { var buf bytes.Buffer _, err := io.Copy(&buf, r) if err != nil { return nil, err } zipReader, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len())) if err != nil { return nil, err } for _, file := range zipReader.File { if file.Name == g.thumbnailpath { thumbnail, err := file.Open() if err != nil { return nil, err } converter := ForType("image/png", g.limit.opts()) if converter == nil { return nil, thumbnailerErrors.ErrNoConverterForExtractedImageFromGgsFile } img, err := converter.Convert(thumbnail) if err != nil { return nil, errors.Wrap(err, `could not decode the image`) } return img, nil } } return nil, errors.Errorf("%s not found", g.thumbnailpath) } // AudioDecoder is a converter for the audio file type AudioDecoder struct{ limit decodeLimit } // Convert reads the audio file and extracts the thumbnail image from the id3 tag func (i AudioDecoder) Convert(r io.Reader) (any, error) { b, err := io.ReadAll(r) if err != nil { return nil, err } m, err := tag.ReadFrom(bytes.NewReader(b)) if err != nil { return nil, err } picture := m.Picture() if picture == nil { return nil, thumbnailerErrors.ErrNoImageFromAudioFile } converter := ForType(picture.MIMEType, i.limit.opts()) if converter == nil { return nil, thumbnailerErrors.ErrNoConverterForExtractedImageFromAudioFile } return converter.Convert(bytes.NewReader(picture.Data)) } // TxtToImageConverter is a converter for the text file type TxtToImageConverter struct { fontLoader *FontLoader } // Convert reads the text file and renders it into a thumbnail image func (t TxtToImageConverter) Convert(r io.Reader) (any, error) { img := image.NewRGBA(image.Rect(0, 0, 640, 480)) imgBounds := img.Bounds() draw.Draw(img, imgBounds, image.White, image.Point{}, draw.Src) fontSizeAsInt := int(math.Ceil(t.fontLoader.GetFaceOptSize())) margin := 10 minX := fixed.I(imgBounds.Min.X + margin) maxX := fixed.I(imgBounds.Max.X - margin) maxY := fixed.I(imgBounds.Max.Y - margin) initialPoint := fixed.P(imgBounds.Min.X+margin, imgBounds.Min.Y+margin+fontSizeAsInt) canvas := &font.Drawer{ Dst: img, Src: image.Black, Dot: initialPoint, } scriptList := t.fontLoader.GetScriptList() textAnalyzer := NewTextAnalyzer(scriptList) taOpts := AnalysisOpts{ UseMergeMap: true, MergeMap: DefaultMergeMap, } scanner := bufio.NewScanner(r) Scan: // Label for the scanner loop, so we can break it easily for scanner.Scan() { txt := scanner.Text() height := fixed.I(fontSizeAsInt) // reset to default height textResult := textAnalyzer.AnalyzeString(txt, taOpts) textResult.MergeCommon(DefaultMergeMap) for _, sRange := range textResult.ScriptRanges { targetFontFace, err := t.fontLoader.LoadFaceForScript(sRange.TargetScript) if err != nil { return nil, err } // if the target script is "_unknown" it's expected that the loaded face // uses the default font faceHeight := targetFontFace.Face.Metrics().Height if faceHeight > height { height = faceHeight } canvas.Face = targetFontFace.Face initialByte := sRange.Low for _, sRangeSpace := range sRange.Spaces { if canvas.Dot.Y > maxY { break Scan } drawWord(canvas, textResult.Text[initialByte:sRangeSpace], minX, maxX, height, maxY) initialByte = sRangeSpace } if initialByte <= sRange.High { // some bytes left to be written if canvas.Dot.Y > maxY { break Scan } drawWord(canvas, textResult.Text[initialByte:sRange.High+1], minX, maxX, height, maxY) } } canvas.Dot.X = minX canvas.Dot.Y += height.Mul(fixed.Int26_6(1<<6 + 1<<5)) // height * 1.5 if canvas.Dot.Y > maxY { break } } return img, scanner.Err() } // GGPStruct is the layout of a ggp file (which is basically json) type GGPStruct struct { Sections []struct { Cards []struct { Element struct { Image struct { Base64Image string } } } } } // GgpDecoder is a converter for the geogebra pinboard file type GgpDecoder struct{ limit decodeLimit } // Convert reads the ggp file and returns the first thumbnail image func (j GgpDecoder) Convert(r io.Reader) (any, error) { ggp := &GGPStruct{} err := json.NewDecoder(r).Decode(ggp) if err != nil { return nil, err } elem, err := extractBase64ImageFromGGP(ggp) if err != nil { return nil, err } b, err := base64.StdEncoding.DecodeString(elem) if err != nil { return nil, err } r2, err := j.limit.guardDimensions(bytes.NewReader(b), func(rr io.Reader) (image.Config, error) { cfg, _, err := image.DecodeConfig(rr) return cfg, err }) if err != nil { return nil, err } img, _, err := image.Decode(r2) return img, err } func extractBase64ImageFromGGP(ggp *GGPStruct) (string, error) { if len(ggp.Sections) < 1 || len(ggp.Sections[0].Cards) < 1 { return "", errors.New("cant find thumbnail in ggp file") } raw := strings.Split(ggp.Sections[0].Cards[0].Element.Image.Base64Image, "base64,") if len(raw) < 2 { return "", errors.New("cant decode ggp thumbnail") } return raw[1], nil } // Draw the word in the canvas. The mixX and maxX defines the drawable range // (X axis) where the word can be drawn (in case the word is too big and doesn't // fit in the canvas), and the incY defines the increment in the Y axis if we // need to draw the word in a new line // // Note that the word will likely start with a white space char func drawWord(canvas *font.Drawer, word string, minX, maxX, incY, maxY fixed.Int26_6) { // calculate the actual measurement of the string at a given X position measure := func(s string, dotX fixed.Int26_6) (min, max fixed.Int26_6) { bbox, _ := canvas.BoundString(s) return dotX + bbox.Min.X, dotX + bbox.Max.X } // first try to draw the whole word absMin, absMax := measure(word, canvas.Dot.X) if absMin >= minX && absMax <= maxX { canvas.DrawString(word) return } // try to draw the trimmed word in a new line trimmed := strings.TrimSpace(word) oldDot := canvas.Dot canvas.Dot.X = minX canvas.Dot.Y += incY if canvas.Dot.Y <= maxY { tMin, tMax := measure(trimmed, canvas.Dot.X) if tMin >= minX && tMax <= maxX { canvas.DrawString(trimmed) return } } // if the trimmed word is still too big, draw it char by char canvas.Dot = oldDot for _, char := range trimmed { s := string(char) _, cMax := measure(s, canvas.Dot.X) if cMax > maxX { canvas.Dot.X = minX canvas.Dot.Y += incY } // stop drawing if we exceed maxY if canvas.Dot.Y > maxY { return } // ensure that we don't start drawing before minX cMin, _ := measure(s, canvas.Dot.X) if cMin < minX { canvas.Dot.X += minX - cMin } canvas.DrawString(s) } } // decodeLimit bounds the source image dimensions a decoder accepts. A zero // value on an axis disables the limit for that axis. type decodeLimit struct { maxWidth int maxHeight int } func decodeLimitFromOpts(opts map[string]any) decodeLimit { l := decodeLimit{} if v, ok := opts["maxInputWidth"].(int); ok { l.maxWidth = v } if v, ok := opts["maxInputHeight"].(int); ok { l.maxHeight = v } return l } func (l decodeLimit) exceeded(width, height int) bool { return (l.maxWidth > 0 && width > l.maxWidth) || (l.maxHeight > 0 && height > l.maxHeight) } // opts renders the limit back into an options map so decoders that recurse // into ForType can forward it to the nested image decoder. func (l decodeLimit) opts() map[string]any { return map[string]any{"maxInputWidth": l.maxWidth, "maxInputHeight": l.maxHeight} } // guardDimensions reads only the image header (no pixel allocation) and // rejects a source whose declared dimensions exceed the limit, before the // full bitmap is decoded. It returns a reader that replays the consumed // header so the caller can still decode from the start. A header that cannot // be parsed is passed through unchecked, letting the real decoder report it. func (l decodeLimit) guardDimensions(r io.Reader, config func(io.Reader) (image.Config, error)) (io.Reader, error) { if l.maxWidth <= 0 && l.maxHeight <= 0 { return r, nil } var head bytes.Buffer cfg, err := config(io.TeeReader(r, &head)) replay := io.MultiReader(&head, r) if err != nil { return replay, nil } if l.exceeded(cfg.Width, cfg.Height) { return nil, thumbnailerErrors.ErrImageTooLarge } return replay, nil } // ForType returns the converter for the specified mimeType func ForType(mimeType string, opts map[string]any) FileConverter { // We can ignore the error here because we parse it in IsMimeTypeSupported before and if it fails // return the service call. So we should only get here when the mimeType parses fine. mimeType, _, _ = mime.ParseMediaType(mimeType) limit := decodeLimitFromOpts(opts) switch mimeType { case "text/plain": fontFileMap := "" fontFaceOpts := &opentype.FaceOptions{ Size: 12, DPI: 72, Hinting: font.HintingNone, } if optedFontFileMap, ok := opts["fontFileMap"]; ok { if stringFontFileMap, ok := optedFontFileMap.(string); ok { fontFileMap = stringFontFileMap } } if optedFontFaceOpts, ok := opts["fontFaceOpts"]; ok { if typedFontFaceOpts, ok := optedFontFaceOpts.(*opentype.FaceOptions); ok { fontFaceOpts = typedFontFaceOpts } } fontLoader, err := NewFontLoader(fontFileMap, fontFaceOpts) if err != nil { // if it couldn't create the FontLoader with the specified fontFileMap, // try to use the default font fontLoader, _ = NewFontLoader("", fontFaceOpts) } return TxtToImageConverter{ fontLoader: fontLoader, } case "application/vnd.geogebra.slides": return GgsDecoder{thumbnailpath: "_slide0/geogebra_thumbnail.png", limit: limit} case "application/vnd.geogebra.pinboard": return GgpDecoder{limit: limit} case "image/gif": return GifDecoder{limit: limit} case "audio/flac": fallthrough case "audio/mpeg": fallthrough case "audio/ogg": return AudioDecoder{limit: limit} default: return ImageDecoder{limit: limit} } }