View | Details | Raw Unified | Return to bug 31624
Collapse All | Expand All

(-)a/Koha/CoverGenerator.pm (+342 lines)
Line 0 Link Here
1
package Koha::CoverGenerator;
2
3
# Copyright 2022 LMSCloud GmbH
4
#
5
# This file is part of Koha.
6
#
7
# Koha is free software; you can redistribute it and/or modify it
8
# under the terms of the GNU General Public License as published by
9
# the Free Software Foundation; either version 3 of the License, or
10
# (at your option) any later version.
11
#
12
# Koha is distributed in the hope that it will be useful, but
13
# WITHOUT ANY WARRANTY; without even the implied warranty of
14
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15
# GNU General Public License for more details.
16
#
17
# You should have received a copy of the GNU General Public License
18
# along with Koha; if not, see <http://www.gnu.org/licenses>.
19
20
use 5.032;
21
22
use utf8;
23
use strict;
24
use warnings;
25
use Modern::Perl;
26
27
our $VERSION = 1.0.0;
28
29
use GD::Image;
30
use GD::Text::Align;
31
use MIME::Base64;
32
use Koha::Exceptions;
33
use Try::Tiny;
34
35
=head1 NAME
36
37
Koha::CoverGenerator - generate images as substitutes for missing cover images based 
38
on two input strings.
39
40
=head1 SYNOPSIS
41
42
  use Koha::CoverGenerator;
43
44
=head1 DESCRIPTION
45
46
This module provides a public to draw an image based on two input 
47
strings through GD::Image and GD::Text::Align.
48
49
The module returns a base64 encoded string, that can be used as a
50
data url in JavaScript.
51
52
=head1 FUNCTIONS
53
54
=head2 render_image( $args )
55
56
    my $args = { 
57
        first_line      => $first_line,
58
        second_line     => $second_line,
59
        font            => $font,
60
        font_path       => $font_path,
61
        width           => $width,
62
        height          => $height,
63
        fontsize        => $fontsize,
64
        padding         => $padding
65
        format          => $format,
66
    }
67
68
=over 4
69
70
=item C<first_line>
71
72
First and second line are the strings to be drawn on the canvas.
73
74
=item C<second_line>
75
76
First and second line are the strings to be drawn on the canvas.
77
78
=item C<font>
79
80
The exact filename of the font to be used while omitting any extensions 
81
like .ttf or .otf.
82
83
=item C<font_path>
84
85
The path to the fonts directory where $font is located. Don't forget the
86
'/' at the end of the path.
87
88
=item C<width>
89
90
Width of the canvas.
91
92
=item C<height>
93
94
Height of the canvas.
95
96
=item C<fontsize>
97
98
Fontsize of the text drawn to the canvas.
99
100
=item C<padding>
101
102
Padding of the text drawn to the canvas.
103
104
=back
105
106
=cut
107
108
use constant {
109
    TWO => 2,
110
    SIX => 6,
111
112
    RED   => 244,
113
    GREEN => 244,
114
    BLUE  => 244,
115
};
116
117
use constant SPACE => q{ };
118
use constant EMPTY => q{};
119
120
my @leftover_words = EMPTY;
121
my $leftover_words = EMPTY;
122
123
sub new {
124
    my ( $class, $args ) = @_;
125
    my $self = bless {
126
        first_line  => { string => $args->{'first_line'}, },
127
        second_line => { string => $args->{'second_line'}, },
128
        font        => {
129
            name => $args->{'font'},
130
            path => $args->{'font_path'},
131
            size => $args->{'fontsize'},
132
        },
133
        dimensions => {
134
            width         => $args->{'width'},
135
            height        => $args->{'height'},
136
            padding       => $args->{'padding'},
137
            content_width => $args->{'width'} - $args->{'padding'},
138
        },
139
        positions => {
140
            horizontal_center => $args->{'width'} / TWO,
141
            vertical_top      => $args->{'height'} / SIX,
142
            vertical_bottom   => $args->{'height'} / TWO,
143
        },
144
        image  => GD::Image->new( $args->{'width'}, $args->{'height'} ),
145
        format => $args->{'format'},
146
    }, $class;
147
148
    $self->{'image'}->trueColor(1);
149
    $self->{'image'}->colorAllocate( RED, GREEN, BLUE );
150
    $self->{'font'}->{'color'} = $self->{'image'}->colorAllocate( 0, 0, 0 );
151
152
    $self->{'align'} = GD::Text::Align->new(
153
        $self->{'image'},
154
        valign => 'center',
155
        halign => 'center',
156
        color  => $self->{'font'}->{'color'},
157
    );
158
    $self->{'align'}->font_path( $args->{'font_path'} );
159
160
    return $self;
161
162
}
163
164
sub get_string_width_by_params {
165
    my ( $class, $args ) = @_;
166
167
    if ( !defined $class->{'align'} ) {
168
        Koha::Exceptions::MissingParameter->throw('image is undefined as parameter in get_string_width_by_params');
169
    }
170
171
    $class->{'align'}->set_font( $class->{'font'}->{'name'}, $args->{'fontsize'} || $class->{'font'}->{'size'} );
172
    $class->{'align'}->set_text( $args->{'string'} );
173
174
    return $class->{'align'}->get('width');
175
}
176
177
sub trim_string {
178
    my ( $class, $args ) = @_;
179
180
    # turning point for the trim_string function.
181
    if ( $args->{'string'} eq $leftover_words ) {
182
        $leftover_words = EMPTY;
183
    }
184
185
    # Break the string on spaces and assign to array.
186
    my @words = split SPACE, $args->{'string'};
187
188
    # Pop the last word and store in leftovers.
189
    $leftover_words .= pop(@words) . SPACE;
190
191
    # Set the return value to empty string.
192
    my $new_line = EMPTY;
193
194
    # Append the remaining words to the return value.
195
    for my $word (@words) {
196
        $new_line .= "$word ";
197
    }
198
199
    # Check if the new line fits into the box.
200
    my $new_string_width = $class->get_string_width_by_params( { string => $new_line } );
201
202
    # If it does, return the new line and handle the leftovers.
203
    if ( $new_string_width <= $class->{'dimensions'}->{'content_width'} ) {
204
        return $new_line;
205
    }
206
207
    # If a single word is bigger than the box, we have to prevent an infinite loop.
208
    if ( $new_string_width > $class->{'dimensions'}->{'content_width'}
209
        && scalar @words == 1 )
210
    {
211
        return $new_line;
212
    }
213
214
    # If it doesn't, repeat the process until it does.
215
    return $class->trim_string( { string => $new_line, } );
216
}
217
218
sub format_string {
219
    my ( $class, $args ) = @_;
220
221
    my $string_width = $class->get_string_width_by_params( { string => $args->{'string'} } );
222
223
    if ( $string_width <= $class->{'dimensions'}->{'content_width'} ) {
224
        return $args->{'string'};
225
    }
226
227
    my $return_value;
228
    my $formatted_string = EMPTY;
229
230
    $return_value = $class->trim_string( { string => $args->{'string'}, } );
231
    $formatted_string .= "$return_value\n";
232
233
    # reverse order of the leftovers before entering the while loop.
234
    @leftover_words = reverse split SPACE, $leftover_words;
235
    $leftover_words = EMPTY;
236
237
    for my $word (@leftover_words) {
238
        $leftover_words .= "$word ";
239
    }
240
241
    while ( !$leftover_words eq EMPTY ) {
242
        $return_value = EMPTY;
243
        $return_value = $class->trim_string( { string => $leftover_words, } );
244
245
        $formatted_string .= "$return_value\n";
246
247
        @leftover_words = reverse split SPACE, $leftover_words;
248
        $leftover_words = EMPTY;
249
250
        for my $word (@leftover_words) {
251
            $leftover_words .= "$word ";
252
        }
253
254
        my $new_string_width = $class->get_string_width_by_params( { string => $leftover_words } );
255
256
        if ( $new_string_width <= $class->{'dimensions'}->{'content_width'} ) {
257
            $formatted_string .= "$leftover_words\n";
258
            $leftover_words = EMPTY;
259
        }
260
261
        if ( $new_string_width > $class->{'dimensions'}->{'content_width'}
262
            && scalar @leftover_words == 1 )
263
        {
264
            $formatted_string .= "$leftover_words\n";
265
            $leftover_words = EMPTY;
266
        }
267
268
    }
269
270
    return $formatted_string;
271
272
}
273
274
sub draw_text {
275
    my ( $class, $args ) = @_;
276
277
    my $result_string = $class->format_string( { string => $args->{'content_string'}, } );
278
279
    my $new_image_width = $class->get_string_width_by_params( { string => $result_string, } );
280
281
    my $new_fontsize = 0;
282
283
    while ( $new_image_width > $class->{'dimensions'}->{'content_width'} ) {
284
        if ( !$new_fontsize ) {
285
            $new_fontsize = $class->{'font'}->{'size'} - 2;
286
        }
287
        $class->{'align'}->set_font( $class->{'font'}->{'name'}, $new_fontsize );
288
        $new_image_width = $class->get_string_width_by_params(
289
            {   fontsize => $new_fontsize,
290
                string   => $result_string
291
            }
292
        );
293
        $new_fontsize -= 2;
294
    }
295
296
    my @centered_result = split m/[\n]/xms, $result_string;
297
298
    my $index = 0;
299
    for my $line (@centered_result) {
300
        $class->{'align'}->set_text($line);
301
        $class->{'align'}->draw( $class->{'positions'}->{'horizontal_center'}, $args->{'vertical_position'} + $index * ( $class->{'font'}->{'size'} + ( $class->{'font'}->{'size'} / 2 ) ), 0 );
302
        $index++;
303
    }
304
305
    return;
306
}
307
308
sub render_image {
309
    my $class = shift;
310
311
    if ( $class->{'first_line'}->{'string'} ) {
312
        $class->draw_text(
313
            {   content_string    => $class->{'first_line'}->{'string'},
314
                vertical_position => $class->{'positions'}->{'vertical_top'},
315
            }
316
317
        );
318
    }
319
320
    if ( $class->{'second_line'}->{'string'} ) {
321
        $class->draw_text(
322
            {   content_string    => $class->{'second_line'}->{'string'},
323
                vertical_position => !$class->{'first_line'}->{'string'}
324
                ? $class->{'positions'}->{'vertical_top'}
325
                : $class->{'positions'}->{'vertical_bottom'},
326
            }
327
328
        );
329
    }
330
331
    my $formats = {
332
        'jpeg' => sub { return encode_base64( $class->{'image'}->jpeg ) },
333
        'png'  => sub { return encode_base64( $class->{'image'}->png ) },
334
        'gif'  => sub { return encode_base64( $class->{'image'}->gif ) },
335
    };
336
337
    return $formats->{ $class->{'format'} }->();
338
}
339
340
1;
341
342
(-)a/Koha/REST/V1/Biblios.pm (+73 lines)
Lines 20-25 use Modern::Perl; Link Here
20
use Mojo::Base 'Mojolicious::Controller';
20
use Mojo::Base 'Mojolicious::Controller';
21
21
22
use Koha::Biblios;
22
use Koha::Biblios;
23
use Koha::CoverGenerator;
23
use Koha::Ratings;
24
use Koha::Ratings;
24
use Koha::RecordProcessor;
25
use Koha::RecordProcessor;
25
use C4::Biblio qw( DelBiblio );
26
use C4::Biblio qw( DelBiblio );
Lines 373-378 sub pickup_locations { Link Here
373
    };
374
    };
374
}
375
}
375
376
377
=head3 get_default_cover_public
378
379
Method that returns a generated default cover image for a given biblio,
380
for unprivileged access.
381
382
=cut
383
384
sub get_default_cover_public {
385
    my $c = shift->openapi->valid_input or return;
386
387
    my $biblio_id   = $c->validation->param('biblio_id');
388
    my $biblio      = Koha::Biblios->find($biblio_id);
389
    my $record      = $biblio->metadata->record;
390
    my $first_line  = $record->title;
391
    my $second_line = $record->author;
392
393
    my $width    = $c->validation->param('width');
394
    my $height   = $c->validation->param('height');
395
    my $fontsize = $c->validation->param('fontsize');
396
    my $padding  = $c->validation->param('padding');
397
    my $format   = $c->validation->param('format');
398
399
    use constant {
400
        FONT      => 'DejaVuSans',
401
        FONT_PATH => '/usr/share/fonts/truetype/dejavu/',
402
        WIDTH     => 400,
403
        HEIGHT    => 480,
404
        FONTSIZE  => 28,
405
        PADDING   => 20,
406
        FORMAT    => 'png',
407
    };
408
409
    unless ($biblio) {
410
        return $c->render(
411
            status  => 404,
412
            openapi => { error => 'Biblio not found' }
413
        );
414
    }
415
416
    return try {
417
        my $cover_generator = Koha::CoverGenerator->new(
418
            {   first_line  => $first_line,
419
                second_line => $second_line,
420
                font        => FONT,
421
                font_path   => FONT_PATH,
422
                width       => $width    || WIDTH,
423
                height      => $height   || HEIGHT,
424
                fontsize    => $fontsize || FONTSIZE,
425
                padding     => $padding  || PADDING,
426
                format      => $format   || FORMAT,
427
            }
428
        );
429
430
        my $generated_cover_image_source = $cover_generator->render_image;
431
432
        if ( !$generated_cover_image_source ) {
433
            return $c->render(
434
                status  => 404,
435
                openapi => { error => 'No cover image could be generated' },
436
            );
437
        }
438
439
        return $c->render(
440
            status  => 200,
441
            openapi => $generated_cover_image_source =~ s/[\r\n]+//smxrg,
442
        );
443
    }
444
    catch {
445
        $c->unhandled_exception($_);
446
    };
447
}
448
376
=head3 get_items_public
449
=head3 get_items_public
377
450
378
Controller function that handles retrieving biblio's items, for unprivileged
451
Controller function that handles retrieving biblio's items, for unprivileged
(-)a/api/v1/swagger/paths/biblios.yaml (+67 lines)
Lines 326-331 Link Here
326
        description: Under maintenance
326
        description: Under maintenance
327
        schema:
327
        schema:
328
          $ref: "../swagger.yaml#/definitions/error"
328
          $ref: "../swagger.yaml#/definitions/error"
329
"/public/biblios/{biblio_id}/default_cover":
330
  get:
331
    x-mojo-to: Biblios#get_default_cover_public
332
    operationId: getBiblioDefaultCoverPublic
333
    summary: Get a default cover image
334
    parameters:
335
      - $ref: "../swagger.yaml#/parameters/biblio_id_pp"
336
      - name: width
337
        in: query
338
        description: Width of the canvas
339
        required: false
340
        type: integer
341
      - name: height
342
        in: query
343
        description: Height of the canvas
344
        required: false
345
        type: integer
346
      - name: fontsize
347
        in: query
348
        description: Fontsize of text drawn to the canvas
349
        required: false
350
        type: integer
351
      - name: padding
352
        in: query
353
        description: Padding that wraps the text to the canvas
354
        required: false
355
        type: integer
356
      - name: format
357
        in: query
358
        required: false
359
        description: The image format of the resulting base64 encoded string
360
        type: string
361
      - $ref: "../swagger.yaml#/parameters/page"
362
      - $ref: "../swagger.yaml#/parameters/per_page"
363
      - $ref: "../swagger.yaml#/parameters/match"
364
      - $ref: "../swagger.yaml#/parameters/order_by"
365
      - $ref: "../swagger.yaml#/parameters/q_param"
366
      - $ref: "../swagger.yaml#/parameters/q_body"
367
      - $ref: "../swagger.yaml#/parameters/q_header"
368
      - $ref: "../swagger.yaml#/parameters/request_id_header"
369
    produces:
370
      - application/json
371
    responses:
372
      "200":
373
        description: The default cover for a biblio
374
        schema:
375
          type: string
376
          format: binary
377
      "403":
378
        description: Access forbidden
379
        schema:
380
          $ref: "../swagger.yaml#/definitions/error"
381
      "404":
382
        description: Biblio not found
383
        schema:
384
          $ref: "../swagger.yaml#/definitions/error"
385
      "500":
386
        description: |
387
          Internal server error. Possible `error_code` attribute values:
388
389
          * `internal_server_error`
390
        schema:
391
          $ref: "../swagger.yaml#/definitions/error"
392
      "503":
393
        description: Under maintenance
394
        schema:
395
          $ref: "../swagger.yaml#/definitions/error"
329
"/public/biblios/{biblio_id}/items":
396
"/public/biblios/{biblio_id}/items":
330
  get:
397
  get:
331
    x-mojo-to: Biblios#get_items_public
398
    x-mojo-to: Biblios#get_items_public
(-)a/api/v1/swagger/swagger.yaml (-1 / +2 lines)
Lines 273-278 paths: Link Here
273
    $ref: "./paths/patrons_password.yaml#/~1patrons~1{patron_id}~1password~1expiration_date"
273
    $ref: "./paths/patrons_password.yaml#/~1patrons~1{patron_id}~1password~1expiration_date"
274
  "/public/biblios/{biblio_id}":
274
  "/public/biblios/{biblio_id}":
275
    $ref: "./paths/biblios.yaml#/~1public~1biblios~1{biblio_id}"
275
    $ref: "./paths/biblios.yaml#/~1public~1biblios~1{biblio_id}"
276
  "/public/biblios/{biblio_id}/default_cover":
277
    $ref: "./paths/biblios.yaml#/~1public~1biblios~1{biblio_id}~1default_cover"
276
  "/public/biblios/{biblio_id}/items":
278
  "/public/biblios/{biblio_id}/items":
277
    $ref: "./paths/biblios.yaml#/~1public~1biblios~1{biblio_id}~1items"
279
    $ref: "./paths/biblios.yaml#/~1public~1biblios~1{biblio_id}~1items"
278
  "/public/biblios/{biblio_id}/ratings":
280
  "/public/biblios/{biblio_id}/ratings":
279
- 

Return to bug 31624