Allow rendering a text preview using custom fonts
This commit is contained in:
@@ -66,6 +66,7 @@ type Thumbnail struct {
|
|||||||
CS3AllowInsecure bool `ocisConfig:"cs3_allow_insecure"`
|
CS3AllowInsecure bool `ocisConfig:"cs3_allow_insecure"`
|
||||||
RevaGateway string `ocisConfig:"reva_gateway"`
|
RevaGateway string `ocisConfig:"reva_gateway"`
|
||||||
WebdavNamespace string `ocisConfig:"webdav_namespace"`
|
WebdavNamespace string `ocisConfig:"webdav_namespace"`
|
||||||
|
FontMapFile string `ocisConfig:"font_map_file"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// New initializes a new configuration with or without defaults.
|
// New initializes a new configuration with or without defaults.
|
||||||
|
|||||||
@@ -91,6 +91,10 @@ func structMappings(cfg *Config) []shared.EnvBinding {
|
|||||||
EnvVars: []string{"THUMBNAILS_GRPC_NAMESPACE"},
|
EnvVars: []string{"THUMBNAILS_GRPC_NAMESPACE"},
|
||||||
Destination: &cfg.Server.Namespace,
|
Destination: &cfg.Server.Namespace,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
EnvVars: []string{"THUMBNAILS_TXT_FONTMAP_FILE"},
|
||||||
|
Destination: &cfg.Thumbnail.FontMapFile,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
EnvVars: []string{"THUMBNAILS_FILESYSTEMSTORAGE_ROOT"},
|
EnvVars: []string{"THUMBNAILS_FILESYSTEMSTORAGE_ROOT"},
|
||||||
Destination: &cfg.Thumbnail.FileSystemStorage.RootDirectory,
|
Destination: &cfg.Thumbnail.FileSystemStorage.RootDirectory,
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
package preprocessor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/owncloud/ocis/ocis-pkg/sync"
|
||||||
|
"golang.org/x/image/font"
|
||||||
|
"golang.org/x/image/font/gofont/goregular"
|
||||||
|
"golang.org/x/image/font/opentype"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FontMap maps a script with the target font to be used for that script
|
||||||
|
// It also uses a DefaultFont in case there isn't a matching script in the map
|
||||||
|
//
|
||||||
|
// For cases like Japanese where multiple scripts are used, we rely on the text
|
||||||
|
// analyzer to use the script which is unique to japanese (Hiragana or Katakana)
|
||||||
|
// even if it has to overwrite the "official" detected script (Han). This means
|
||||||
|
// that "Han" should be used just for chinese while "Hiragana" and "Katakana"
|
||||||
|
// should be used for japanese
|
||||||
|
type FontMap struct {
|
||||||
|
FontMap map[string]string `json:"fontMap"`
|
||||||
|
DefaultFont string `json:"defaultFont"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// It contains the location of the loaded file (in FLoc) and the FontMap loaded
|
||||||
|
// from the file
|
||||||
|
type FontMapData struct {
|
||||||
|
FMap *FontMap
|
||||||
|
FLoc string
|
||||||
|
}
|
||||||
|
|
||||||
|
// It contains the location of the font used, and the loaded face (font.Face)
|
||||||
|
// ready to be used
|
||||||
|
type LoadedFace struct {
|
||||||
|
FontFile string
|
||||||
|
Face font.Face
|
||||||
|
}
|
||||||
|
|
||||||
|
// Represents a FontLoader. Use the "NewFontLoader" to get a instance
|
||||||
|
type FontLoader struct {
|
||||||
|
faceCache sync.Cache
|
||||||
|
fontMapData *FontMapData
|
||||||
|
faceOpts *opentype.FaceOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new FontLoader based on the fontMapFile. The FaceOptions will
|
||||||
|
// be the same for all the font loaded by this instance.
|
||||||
|
// Note that only the fonts described in the fontMapFile will be used.
|
||||||
|
//
|
||||||
|
// The fontMapFile has the following structure
|
||||||
|
// {
|
||||||
|
// "fontMap": {
|
||||||
|
// "Han": "packaged/myFont-CJK.otf",
|
||||||
|
// "Arabic": "packaged/myFont-Arab.otf",
|
||||||
|
// "Latin": "/fonts/regular/myFont.otf"
|
||||||
|
// }
|
||||||
|
// "defaultFont": "/fonts/regular/myFont.otf"
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// The fontMapFile contains paths to where the fonts are located in the FS.
|
||||||
|
// Absolute paths can be used as shown above. If a relative path is used,
|
||||||
|
// it will be relative to the fontMapFile location. This should make the
|
||||||
|
// packaging easier since all the fonts can be placed in the same directory
|
||||||
|
// where the fontMapFile is, or in inner directories.
|
||||||
|
func NewFontLoader(fontMapFile string, faceOpts *opentype.FaceOptions) (*FontLoader, error) {
|
||||||
|
fontMap := &FontMap{}
|
||||||
|
|
||||||
|
if fontMapFile != "" {
|
||||||
|
file, err := os.Open(fontMapFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
parser := json.NewDecoder(file)
|
||||||
|
if err = parser.Decode(fontMap); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &FontLoader{
|
||||||
|
faceCache: sync.NewCache(5),
|
||||||
|
fontMapData: &FontMapData{
|
||||||
|
FMap: fontMap,
|
||||||
|
FLoc: fontMapFile,
|
||||||
|
},
|
||||||
|
faceOpts: faceOpts,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load and return the font face to be used for that script according to the
|
||||||
|
// FontMap set when the FontLoader was created. If the script doesn't have
|
||||||
|
// an associated font, a default font will be used. Note that the default font
|
||||||
|
// might not be able to handle properly the script
|
||||||
|
func (fl *FontLoader) LoadFaceForScript(script string) (*LoadedFace, error) {
|
||||||
|
var parsedFont *opentype.Font
|
||||||
|
var parsingError error
|
||||||
|
|
||||||
|
fontFile := fl.fontMapData.FMap.DefaultFont
|
||||||
|
if val, ok := fl.fontMapData.FMap.FontMap[script]; ok {
|
||||||
|
fontFile = val
|
||||||
|
}
|
||||||
|
|
||||||
|
if fontFile != "" && !filepath.IsAbs(fontFile) {
|
||||||
|
fontFile = filepath.Join(filepath.Dir(fl.fontMapData.FLoc), fontFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
// if the face for the script isn't cached, load the font file and create a new face
|
||||||
|
cachedFace := fl.faceCache.Load(fontFile)
|
||||||
|
if cachedFace != nil {
|
||||||
|
return cachedFace.V.(*LoadedFace), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if fontFile == "" {
|
||||||
|
parsedFont, parsingError = opentype.Parse(goregular.TTF)
|
||||||
|
if parsingError != nil {
|
||||||
|
return nil, parsingError
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// opentype.ParseReaderAt seems to require to keep the file opened
|
||||||
|
// so read the font file into memory
|
||||||
|
data, err := os.ReadFile(fontFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
parsedFont, parsingError = opentype.Parse(data)
|
||||||
|
if parsingError != nil {
|
||||||
|
return nil, parsingError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
face, err := opentype.NewFace(parsedFont, fl.faceOpts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
loadedFace := &LoadedFace{
|
||||||
|
FontFile: fontFile,
|
||||||
|
Face: face,
|
||||||
|
}
|
||||||
|
fl.faceCache.Store(fontFile, loadedFace, time.Now().Add(10*time.Minute))
|
||||||
|
return loadedFace, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fl *FontLoader) GetFaceOptSize() float64 {
|
||||||
|
return fl.faceOpts.Size
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fl *FontLoader) GetFaceOptDPI() float64 {
|
||||||
|
return fl.faceOpts.DPI
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fl *FontLoader) GetScriptList() []string {
|
||||||
|
fontMap := fl.fontMapData.FMap.FontMap
|
||||||
|
|
||||||
|
arePresent := map[string]bool{
|
||||||
|
"Common": false,
|
||||||
|
"Inherited": false,
|
||||||
|
}
|
||||||
|
listSize := len(fontMap)
|
||||||
|
|
||||||
|
for key := range arePresent {
|
||||||
|
if _, inFontMap := fontMap[key]; inFontMap {
|
||||||
|
arePresent[key] = true
|
||||||
|
} else {
|
||||||
|
listSize++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
keys := make([]string, listSize)
|
||||||
|
|
||||||
|
i := 0
|
||||||
|
for k := range fontMap {
|
||||||
|
keys[i] = k
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
for script, isPresent := range arePresent {
|
||||||
|
if !isPresent {
|
||||||
|
keys[i] = script
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return keys
|
||||||
|
}
|
||||||
@@ -2,21 +2,17 @@ package preprocessor
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"github.com/golang/freetype"
|
|
||||||
"github.com/golang/freetype/truetype"
|
|
||||||
"github.com/pkg/errors"
|
|
||||||
"golang.org/x/image/font"
|
|
||||||
"golang.org/x/image/font/gofont/goregular"
|
|
||||||
"image"
|
"image"
|
||||||
"image/draw"
|
"image/draw"
|
||||||
"io"
|
"io"
|
||||||
|
"math"
|
||||||
"mime"
|
"mime"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
"github.com/pkg/errors"
|
||||||
fontSize = 12
|
"golang.org/x/image/font"
|
||||||
spacing float64 = 1.5
|
"golang.org/x/image/font/opentype"
|
||||||
|
"golang.org/x/image/math/fixed"
|
||||||
)
|
)
|
||||||
|
|
||||||
type FileConverter interface {
|
type FileConverter interface {
|
||||||
@@ -33,73 +29,160 @@ func (i ImageDecoder) Convert(r io.Reader) (image.Image, error) {
|
|||||||
return img, nil
|
return img, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type TxtToImageConverter struct{}
|
type TxtToImageConverter struct {
|
||||||
|
fontLoader *FontLoader
|
||||||
|
}
|
||||||
|
|
||||||
func (t TxtToImageConverter) Convert(r io.Reader) (image.Image, error) {
|
func (t TxtToImageConverter) Convert(r io.Reader) (image.Image, error) {
|
||||||
img := image.NewRGBA(image.Rect(0, 0, 640, 480))
|
img := image.NewRGBA(image.Rect(0, 0, 640, 480))
|
||||||
draw.Draw(img, img.Bounds(), image.White, image.Point{}, draw.Src)
|
|
||||||
|
|
||||||
c := freetype.NewContext()
|
imgBounds := img.Bounds()
|
||||||
// Ignoring the error since we are using the embedded Golang font.
|
draw.Draw(img, imgBounds, image.White, image.Point{}, draw.Src)
|
||||||
// This shouldn't return an error.
|
|
||||||
f, _ := truetype.Parse(goregular.TTF)
|
fontSizeAsInt := int(math.Ceil(t.fontLoader.GetFaceOptSize()))
|
||||||
c.SetFont(f)
|
margin := 10
|
||||||
c.SetFontSize(fontSize)
|
minX := fixed.I(imgBounds.Min.X + margin)
|
||||||
c.SetClip(img.Bounds())
|
maxX := fixed.I(imgBounds.Max.X - margin)
|
||||||
c.SetDst(img)
|
maxY := fixed.I(imgBounds.Max.Y - margin)
|
||||||
c.SetSrc(image.Black)
|
initialPoint := fixed.P(imgBounds.Min.X+margin, imgBounds.Min.Y+margin+fontSizeAsInt)
|
||||||
c.SetHinting(font.HintingFull)
|
canvas := &font.Drawer{
|
||||||
pt := freetype.Pt(10, 10+int(c.PointToFixed(fontSize)>>6))
|
Dst: img,
|
||||||
|
Src: image.Black,
|
||||||
|
Dot: initialPoint,
|
||||||
|
}
|
||||||
|
|
||||||
|
scriptList := t.fontLoader.GetScriptList()
|
||||||
|
textAnalyzer := NewTextAnalyzer(scriptList)
|
||||||
|
taOpts := AnalysisOpts{
|
||||||
|
UseMergeMap: true,
|
||||||
|
MergeMap: DefaultMergeMap,
|
||||||
|
}
|
||||||
|
|
||||||
scanner := bufio.NewScanner(r)
|
scanner := bufio.NewScanner(r)
|
||||||
|
Scan: // Label for the scanner loop, so we can break it easily
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
txt := scanner.Text()
|
txt := scanner.Text()
|
||||||
cs := chunks(txt, 80)
|
height := fixed.I(fontSizeAsInt) // reset to default height
|
||||||
for _, s := range cs {
|
if txt != "" {
|
||||||
_, err := c.DrawString(strings.TrimSpace(s), pt)
|
textResult := textAnalyzer.AnalyzeString(txt, taOpts)
|
||||||
if err != nil {
|
textResult.MergeCommon(DefaultMergeMap)
|
||||||
return nil, err
|
|
||||||
}
|
for _, sRange := range textResult.ScriptRanges {
|
||||||
pt.Y += c.PointToFixed(fontSize * spacing)
|
targetFontFace, _ := t.fontLoader.LoadFaceForScript(sRange.TargetScript)
|
||||||
if pt.Y.Round() >= img.Bounds().Dy() {
|
// if the target script is "_unknown" it's expected that the loaded face
|
||||||
return img, scanner.Err()
|
// 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()
|
return img, scanner.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Code from https://stackoverflow.com/a/61469854
|
// Draw the word in the canvas. The mixX and maxX defines the drawable range
|
||||||
// Written By Igor Mikushkin
|
// (X axis) where the word can be drawn (in case the word is too big and doesn't
|
||||||
func chunks(s string, chunkSize int) []string {
|
// fit in the canvas), and the incY defines the increment in the Y axis if we
|
||||||
if chunkSize >= len(s) {
|
// need to draw the word in a new line
|
||||||
return []string{s}
|
//
|
||||||
}
|
// Note that the word will likely start with a white space char
|
||||||
var chunks []string
|
func drawWord(canvas *font.Drawer, word string, minX, maxX, incY, maxY fixed.Int26_6, goToNewLine bool) {
|
||||||
chunk := make([]rune, chunkSize)
|
bbox, _ := canvas.BoundString(word)
|
||||||
length := 0
|
if bbox.Max.X <= maxX {
|
||||||
for _, r := range s {
|
// word fits in the current line
|
||||||
chunk[length] = r
|
canvas.DrawString(word)
|
||||||
length++
|
} else {
|
||||||
if length == chunkSize {
|
// word doesn't fit -> retry in a new line
|
||||||
chunks = append(chunks, string(chunk))
|
trimmedWord := strings.TrimSpace(word)
|
||||||
length = 0
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if length > 0 {
|
|
||||||
chunks = append(chunks, string(chunk[:length]))
|
|
||||||
}
|
|
||||||
return chunks
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func ForType(mimeType string) FileConverter {
|
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
|
// 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.
|
// return the service call. So we should only get here when the mimeType parses fine.
|
||||||
mimeType, _, _ = mime.ParseMediaType(mimeType)
|
mimeType, _, _ = mime.ParseMediaType(mimeType)
|
||||||
switch mimeType {
|
switch mimeType {
|
||||||
case "text/plain":
|
case "text/plain":
|
||||||
return TxtToImageConverter{}
|
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 couldn't create the FontLoader with the specified fontFileMap,
|
||||||
|
// try to use the default font
|
||||||
|
fontLoader, _ = NewFontLoader("", fontFaceOpts)
|
||||||
|
}
|
||||||
|
return TxtToImageConverter{
|
||||||
|
fontLoader: fontLoader,
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return ImageDecoder{}
|
return ImageDecoder{}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,269 @@
|
|||||||
|
package preprocessor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Default list of scripts to be analyzed within the string.
|
||||||
|
//
|
||||||
|
// Scripts that aren't present in the list will be considered as part
|
||||||
|
// of the last "known" script. For example, if "Avestan" script (which isn't
|
||||||
|
// present) is preceeded by "Arabic" script, then the "Avestan" script will
|
||||||
|
// be considered as "Arabic"
|
||||||
|
//
|
||||||
|
// Punctuation symbols are usually considered part of the "Common" script
|
||||||
|
var DefaultScripts = []string{
|
||||||
|
"Arabic",
|
||||||
|
"Common",
|
||||||
|
"Devanagari",
|
||||||
|
"Han",
|
||||||
|
"Hangul",
|
||||||
|
"Hiragana",
|
||||||
|
"Inherited",
|
||||||
|
"Katakana",
|
||||||
|
"Latin",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenient map[string]map[string]string type used to merge multiple
|
||||||
|
// scripts into one. This is mainly used for japanese language which uses
|
||||||
|
// "Han", "Hiragana" and "Katakana" scripts.
|
||||||
|
//
|
||||||
|
// The map contains the expected previous script as first key, the expected
|
||||||
|
// current script as second key, and the resulting script (if both keys
|
||||||
|
// match) as value
|
||||||
|
type MergeMap map[string]map[string]string
|
||||||
|
|
||||||
|
// The default mergeMap containing info for the japanese scripts
|
||||||
|
var DefaultMergeMap = MergeMap{
|
||||||
|
"Han": map[string]string{
|
||||||
|
"Hiragana": "Hiragana",
|
||||||
|
"Katakana": "Katakana",
|
||||||
|
},
|
||||||
|
"Hiragana": map[string]string{
|
||||||
|
"Han": "Hiragana",
|
||||||
|
"Katakana": "Hiragana",
|
||||||
|
},
|
||||||
|
"Katakana": map[string]string{
|
||||||
|
"Han": "Katakana",
|
||||||
|
"Hiragana": "Hiragana",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Analysis options.
|
||||||
|
type AnalysisOpts struct {
|
||||||
|
UseMergeMap bool
|
||||||
|
MergeMap MergeMap
|
||||||
|
}
|
||||||
|
|
||||||
|
// A script range. The range should be attached to a string which could contain
|
||||||
|
// multiple scripts. The "TargetScript" will go from bytes "Low" to "High"
|
||||||
|
// (both inclusive), and contains a "RuneCount" number of runes or chars
|
||||||
|
// (mostly for debugging purposes).
|
||||||
|
// The Space contains the bytes (inside the range) that are considered as
|
||||||
|
// white space.
|
||||||
|
type ScriptRange struct {
|
||||||
|
Low, High int
|
||||||
|
Spaces []int
|
||||||
|
TargetScript string
|
||||||
|
RuneCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
// The result of a text analysis. It contains the analyzed text, a list of
|
||||||
|
// script ranges (see the ScriptRange type) and a map containing how many
|
||||||
|
// runes have been detected for a particular script.
|
||||||
|
type TextAnalysis struct {
|
||||||
|
ScriptRanges []ScriptRange
|
||||||
|
RuneCount map[string]int
|
||||||
|
Text string
|
||||||
|
}
|
||||||
|
|
||||||
|
// The TextAnalyzer object contains private members. It should be created via
|
||||||
|
// "NewTextAnalyzer" function.
|
||||||
|
type TextAnalyzer struct {
|
||||||
|
scripts map[string]*unicode.RangeTable
|
||||||
|
scriptListCache []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new TextAnalyzer. A list of scripts must be provided.
|
||||||
|
// You can use the "DefaultScripts" variable for a default list,
|
||||||
|
// although it doesn't contain all the available scripts.
|
||||||
|
// See the unicode.Scripts variable (in the unicode package) for a
|
||||||
|
// full list. Note that using invalid scripts will cause an undefined
|
||||||
|
// behavior
|
||||||
|
func NewTextAnalyzer(scriptList []string) TextAnalyzer {
|
||||||
|
scriptRanges := make(map[string]*unicode.RangeTable, len(scriptList))
|
||||||
|
for _, script := range scriptList {
|
||||||
|
scriptRanges[script] = unicode.Scripts[script]
|
||||||
|
}
|
||||||
|
return TextAnalyzer{
|
||||||
|
scripts: scriptRanges,
|
||||||
|
scriptListCache: scriptList,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Analyze the target string using the specified options.
|
||||||
|
// A TextAnalysis will be returned with the result of the analysis.
|
||||||
|
func (ta *TextAnalyzer) AnalyzeString(word string, opts AnalysisOpts) TextAnalysis {
|
||||||
|
analysis := TextAnalysis{
|
||||||
|
RuneCount: make(map[string]int),
|
||||||
|
Text: word,
|
||||||
|
}
|
||||||
|
var lastRange *ScriptRange
|
||||||
|
|
||||||
|
runeCount := 0
|
||||||
|
for wordIndex, char := range word {
|
||||||
|
script := "_unknown"
|
||||||
|
for scriptIndex, scriptFound := range ta.scriptListCache {
|
||||||
|
// if we can't match with a known script, do nothing and jump to the next char
|
||||||
|
if unicode.Is(ta.scripts[scriptFound], char) {
|
||||||
|
if scriptIndex > 3 {
|
||||||
|
// we might expect more chars with the same script
|
||||||
|
// so move the script first to match it faster next time
|
||||||
|
ta.reorderScriptList(scriptFound)
|
||||||
|
}
|
||||||
|
script = scriptFound
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isWhiteSpace := unicode.Is(unicode.White_Space, char)
|
||||||
|
if lastRange == nil {
|
||||||
|
runeCount = 1
|
||||||
|
lastRange = &ScriptRange{
|
||||||
|
Low: wordIndex,
|
||||||
|
Spaces: make([]int, 0),
|
||||||
|
TargetScript: script,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if script != lastRange.TargetScript {
|
||||||
|
if opts.UseMergeMap {
|
||||||
|
// This option mainly target japanese chars; multiple scripts can be used
|
||||||
|
// in the same piece of text (Han, Hiragana and Katakana)
|
||||||
|
// Instead of starting a new range, adjust the target script of the last range
|
||||||
|
if expCurrent, currentOk := opts.MergeMap[lastRange.TargetScript]; currentOk {
|
||||||
|
if expFinal, finalOk := expCurrent[script]; finalOk {
|
||||||
|
lastRange.TargetScript = expFinal
|
||||||
|
if isWhiteSpace {
|
||||||
|
lastRange.Spaces = append(lastRange.Spaces, wordIndex)
|
||||||
|
}
|
||||||
|
runeCount++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lastRange.High = wordIndex - 1
|
||||||
|
lastRange.RuneCount = runeCount
|
||||||
|
analysis.ScriptRanges = append(analysis.ScriptRanges, *lastRange)
|
||||||
|
if _, exists := analysis.RuneCount[lastRange.TargetScript]; !exists {
|
||||||
|
analysis.RuneCount[lastRange.TargetScript] = 0
|
||||||
|
}
|
||||||
|
analysis.RuneCount[lastRange.TargetScript] += runeCount
|
||||||
|
lastRange = &ScriptRange{
|
||||||
|
Low: wordIndex,
|
||||||
|
Spaces: make([]int, 0),
|
||||||
|
TargetScript: script,
|
||||||
|
}
|
||||||
|
runeCount = 1
|
||||||
|
} else {
|
||||||
|
runeCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if isWhiteSpace {
|
||||||
|
lastRange.Spaces = append(lastRange.Spaces, wordIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if lastRange != nil {
|
||||||
|
// close the last range
|
||||||
|
lastRange.High = len(word) - 1
|
||||||
|
lastRange.RuneCount = runeCount
|
||||||
|
analysis.RuneCount[lastRange.TargetScript] += runeCount
|
||||||
|
analysis.ScriptRanges = append(analysis.ScriptRanges, *lastRange)
|
||||||
|
}
|
||||||
|
return analysis
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reorder the scriptListCache in the TextAnalyzer in order to speed up
|
||||||
|
// the next script searches. A "Latin" script is expected to be surrounded
|
||||||
|
// by "Latin" chars, although "Common" script chars might be present too
|
||||||
|
func (ta *TextAnalyzer) reorderScriptList(matchedScript string) {
|
||||||
|
for index, script := range ta.scriptListCache {
|
||||||
|
if script == matchedScript {
|
||||||
|
if index != 0 {
|
||||||
|
// move the script to the first position for a faster matching
|
||||||
|
newList := append([]string{script}, ta.scriptListCache[:index]...)
|
||||||
|
ta.scriptListCache = append(newList, ta.scriptListCache[index+1:]...)
|
||||||
|
}
|
||||||
|
// if index == 0 there is nothing to do: the element is already the first
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Change the "Common" script to the one used in the previous script range.
|
||||||
|
// The ranges will be readjusted and merged if they're adjacent.
|
||||||
|
// This naive approach should be good enough for normal use cases
|
||||||
|
//
|
||||||
|
// The MergeMap is needed in case of the japanese language: the ranges
|
||||||
|
// "Han"-"Common"-"Katakana" might be replaced to "Han"-"Hiragana"-"Katakana"
|
||||||
|
// However, the ranges should be merged together into a big "Hiragana" range.
|
||||||
|
// If the MergeMap isn't needed, use an empty one
|
||||||
|
func (tr *TextAnalysis) MergeCommon(mergeMap MergeMap) {
|
||||||
|
var finalRanges []ScriptRange
|
||||||
|
var previousRange *ScriptRange
|
||||||
|
for _, sRange := range tr.ScriptRanges {
|
||||||
|
if previousRange != nil {
|
||||||
|
if previousRange.TargetScript == sRange.TargetScript {
|
||||||
|
previousRange.High = sRange.High
|
||||||
|
previousRange.Spaces = append(previousRange.Spaces, sRange.Spaces...)
|
||||||
|
} else if sRange.TargetScript == "Common" || sRange.TargetScript == "Inherited" {
|
||||||
|
// new range will be absorbed into the previous one
|
||||||
|
previousRange.High = sRange.High
|
||||||
|
previousRange.Spaces = append(previousRange.Spaces, sRange.Spaces...)
|
||||||
|
previousRange.RuneCount += sRange.RuneCount
|
||||||
|
tr.RuneCount[previousRange.TargetScript] += sRange.RuneCount
|
||||||
|
tr.RuneCount[sRange.TargetScript] -= sRange.RuneCount
|
||||||
|
} else if previousRange.TargetScript == "Common" || previousRange.TargetScript == "Inherited" {
|
||||||
|
// might happen if the text starts with a Common script
|
||||||
|
previousRange.High = sRange.High
|
||||||
|
previousRange.Spaces = append(previousRange.Spaces, sRange.Spaces...)
|
||||||
|
tr.RuneCount[sRange.TargetScript] += previousRange.RuneCount
|
||||||
|
tr.RuneCount[previousRange.TargetScript] -= previousRange.RuneCount
|
||||||
|
previousRange.TargetScript = sRange.TargetScript
|
||||||
|
} else {
|
||||||
|
if expCurrent, currentOk := mergeMap[previousRange.TargetScript]; currentOk {
|
||||||
|
if expFinal, finalOk := expCurrent[sRange.TargetScript]; finalOk {
|
||||||
|
if sRange.TargetScript == expFinal {
|
||||||
|
// the previous range has changed the target script
|
||||||
|
tr.RuneCount[previousRange.TargetScript] -= previousRange.RuneCount
|
||||||
|
} else {
|
||||||
|
// new range has been absorbed
|
||||||
|
tr.RuneCount[sRange.TargetScript] -= sRange.RuneCount
|
||||||
|
}
|
||||||
|
tr.RuneCount[expFinal] += sRange.RuneCount
|
||||||
|
previousRange.TargetScript = expFinal
|
||||||
|
previousRange.High = sRange.High
|
||||||
|
previousRange.Spaces = append(previousRange.Spaces, sRange.Spaces...)
|
||||||
|
previousRange.RuneCount += sRange.RuneCount
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finalRanges = append(finalRanges, *previousRange)
|
||||||
|
*previousRange = sRange
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
previousRange = &ScriptRange{}
|
||||||
|
*previousRange = sRange
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
finalRanges = append(finalRanges, *previousRange)
|
||||||
|
tr.ScriptRanges = finalRanges
|
||||||
|
delete(tr.RuneCount, "Common")
|
||||||
|
delete(tr.RuneCount, "Inherited")
|
||||||
|
for index, rCount := range tr.RuneCount {
|
||||||
|
if rCount == 0 {
|
||||||
|
delete(tr.RuneCount, index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,9 @@ func NewService(opts ...Option) v0proto.ThumbnailServiceHandler {
|
|||||||
cs3Source: options.CS3Source,
|
cs3Source: options.CS3Source,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
cs3Client: options.CS3Client,
|
cs3Client: options.CS3Client,
|
||||||
|
preprocessorOpts: PreprocessorOpts{
|
||||||
|
TxtFontFileMap: options.Config.Thumbnail.FontMapFile,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
return svc
|
return svc
|
||||||
@@ -48,13 +51,18 @@ func NewService(opts ...Option) v0proto.ThumbnailServiceHandler {
|
|||||||
|
|
||||||
// Thumbnail implements the GRPC handler.
|
// Thumbnail implements the GRPC handler.
|
||||||
type Thumbnail struct {
|
type Thumbnail struct {
|
||||||
serviceID string
|
serviceID string
|
||||||
webdavNamespace string
|
webdavNamespace string
|
||||||
manager thumbnail.Manager
|
manager thumbnail.Manager
|
||||||
webdavSource imgsource.Source
|
webdavSource imgsource.Source
|
||||||
cs3Source imgsource.Source
|
cs3Source imgsource.Source
|
||||||
logger log.Logger
|
logger log.Logger
|
||||||
cs3Client gateway.GatewayAPIClient
|
cs3Client gateway.GatewayAPIClient
|
||||||
|
preprocessorOpts PreprocessorOpts
|
||||||
|
}
|
||||||
|
|
||||||
|
type PreprocessorOpts struct {
|
||||||
|
TxtFontFileMap string
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetThumbnail retrieves a thumbnail for an image
|
// GetThumbnail retrieves a thumbnail for an image
|
||||||
@@ -114,7 +122,10 @@ func (g Thumbnail) handleCS3Source(ctx context.Context, req *v0proto.GetThumbnai
|
|||||||
return nil, merrors.InternalServerError(g.serviceID, "could not get image from source: %s", err.Error())
|
return nil, merrors.InternalServerError(g.serviceID, "could not get image from source: %s", err.Error())
|
||||||
}
|
}
|
||||||
defer r.Close() // nolint:errcheck
|
defer r.Close() // nolint:errcheck
|
||||||
pp := preprocessor.ForType(sRes.GetInfo().GetMimeType())
|
ppOpts := map[string]interface{}{
|
||||||
|
"fontFileMap": g.preprocessorOpts.TxtFontFileMap,
|
||||||
|
}
|
||||||
|
pp := preprocessor.ForType(sRes.GetInfo().GetMimeType(), ppOpts)
|
||||||
img, err := pp.Convert(r)
|
img, err := pp.Convert(r)
|
||||||
if img == nil || err != nil {
|
if img == nil || err != nil {
|
||||||
return nil, merrors.InternalServerError(g.serviceID, "could not get image")
|
return nil, merrors.InternalServerError(g.serviceID, "could not get image")
|
||||||
@@ -188,7 +199,10 @@ func (g Thumbnail) handleWebdavSource(ctx context.Context, req *v0proto.GetThumb
|
|||||||
return nil, merrors.InternalServerError(g.serviceID, "could not get image from source: %s", err.Error())
|
return nil, merrors.InternalServerError(g.serviceID, "could not get image from source: %s", err.Error())
|
||||||
}
|
}
|
||||||
defer r.Close() // nolint:errcheck
|
defer r.Close() // nolint:errcheck
|
||||||
pp := preprocessor.ForType(sRes.GetInfo().GetMimeType())
|
ppOpts := map[string]interface{}{
|
||||||
|
"fontFileMap": g.preprocessorOpts.TxtFontFileMap,
|
||||||
|
}
|
||||||
|
pp := preprocessor.ForType(sRes.GetInfo().GetMimeType(), ppOpts)
|
||||||
img, err := pp.Convert(r)
|
img, err := pp.Convert(r)
|
||||||
if img == nil || err != nil {
|
if img == nil || err != nil {
|
||||||
return nil, merrors.InternalServerError(g.serviceID, "could not get image")
|
return nil, merrors.InternalServerError(g.serviceID, "could not get image")
|
||||||
|
|||||||
Reference in New Issue
Block a user