Files
opencloud/services/thumbnails/pkg/preprocessor/preprocessor.go
Ralf Haferkamp b63676ff93 Fix GeoGebra thumbnails when libvips support is enabled
For GeoGebra files we where still using the imaging package to decode the
embedded png, but handed it of to vips for scaling, which can't work. Now
we use vips for decoding the image as well.
2024-10-17 14:05:27 +02:00

274 lines
7.5 KiB
Go

package preprocessor
import (
"archive/zip"
"bufio"
"bytes"
"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/owncloud/ocis/v2/services/thumbnails/pkg/errors"
)
// FileConverter is the interface for the file converter
type FileConverter interface {
Convert(r io.Reader) (interface{}, error)
}
// GifDecoder is a converter for the gif file
type GifDecoder struct{}
// Convert reads the gif file and returns the thumbnail image
func (i GifDecoder) Convert(r io.Reader) (interface{}, error) {
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{}
// Convert reads the ggs file and returns the thumbnail image
func (g GgsDecoder) Convert(r io.Reader) (interface{}, error) {
geogebraThumbnail := "_slide0/geogebra_thumbnail.png"
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 == geogebraThumbnail {
thumbnail, err := file.Open()
if err != nil {
return nil, err
}
converter := ForType("image/png", nil)
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", geogebraThumbnail)
}
// AudioDecoder is a converter for the audio file
type AudioDecoder struct{}
// Convert reads the audio file and extracts the thumbnail image from the id3 tag
func (i AudioDecoder) Convert(r io.Reader) (interface{}, 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, nil)
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) (interface{}, 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, _ := t.fontLoader.LoadFaceForScript(sRange.TargetScript)
// 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, true)
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, len(sRange.Spaces) > 0)
}
}
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()
}
// 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, goToNewLine bool) {
bbox, _ := canvas.BoundString(word)
if bbox.Max.X <= maxX {
// word fits in the current line
canvas.DrawString(word)
} else {
// word doesn't fit -> retry in a new line
trimmedWord := strings.TrimSpace(word)
oldDot := canvas.Dot
canvas.Dot.X = minX
canvas.Dot.Y += incY
bbox2, _ := canvas.BoundString(trimmedWord)
if goToNewLine && bbox2.Max.X <= maxX {
if canvas.Dot.Y > maxY {
// Don't draw if we're over the Y limit
return
}
canvas.DrawString(trimmedWord)
} else {
// word doesn't fit in a new line -> draw as many chars as possible
canvas.Dot = oldDot
for _, char := range trimmedWord {
charBytes := []byte(string(char))
bbox3, _ := canvas.BoundBytes(charBytes)
if bbox3.Max.X > maxX {
canvas.Dot.X = minX
canvas.Dot.Y += incY
if canvas.Dot.Y > maxY {
// Don't draw if we're over the Y limit
return
}
}
canvas.DrawBytes(charBytes)
}
}
}
}
// ForType returns the converter for the specified mimeType
func ForType(mimeType string, opts map[string]interface{}) 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)
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{}
case "image/gif":
return GifDecoder{}
case "audio/flac":
fallthrough
case "audio/mpeg":
fallthrough
case "audio/ogg":
return AudioDecoder{}
default:
return ImageDecoder{}
}
}