From 99f563f8c832b10b4ff32a8fe5e3743731027f63 Mon Sep 17 00:00:00 2001 From: LMSCloudPaulD Date: Mon, 19 Dec 2022 22:36:03 +0100 Subject: [PATCH] Bug 31624: REST API: Add endpoint for generated covers This commit contains the main changes for the endpoint and the controller for the image generation. To test this, if you don't have a very elaborate setup: 1. Apply the patch 2. Request the endpoint, for example using: http://localhost:8080/api/v1/public/biblios/1/default_cover 3. Now you could use this JS in your console to get an unescaped version of the JSON response const response = await fetch('http://localhost:8080/api/v1/public/biblios/1/default_cover'); const result = await response.json(); console.log(result); 4. Copy the response and check whether it actually is a valid base64 encoded image. For example using this tool: https://base64.guru/converter/decode/file 5. Give me your thoughts on this patch I'm still in the process of using unit tests and hunting bugs, so it's now ready for a sign-off but I thought I'd already show it to whoever is interested. --- Koha/CoverGenerator.pm | 342 ++++++++++++++++++++++++++++++ Koha/REST/V1/Biblios.pm | 73 +++++++ api/v1/swagger/paths/biblios.yaml | 67 ++++++ api/v1/swagger/swagger.yaml | 2 + 4 files changed, 484 insertions(+) create mode 100644 Koha/CoverGenerator.pm diff --git a/Koha/CoverGenerator.pm b/Koha/CoverGenerator.pm new file mode 100644 index 0000000000..1152a48246 --- /dev/null +++ b/Koha/CoverGenerator.pm @@ -0,0 +1,342 @@ +package Koha::CoverGenerator; + +# Copyright 2022 LMSCloud GmbH +# +# This file is part of Koha. +# +# Koha is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# Koha is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Koha; if not, see . + +use 5.032; + +use utf8; +use strict; +use warnings; +use Modern::Perl; + +our $VERSION = 1.0.0; + +use GD::Image; +use GD::Text::Align; +use MIME::Base64; +use Koha::Exceptions; +use Try::Tiny; + +=head1 NAME + +Koha::CoverGenerator - generate images as substitutes for missing cover images based +on two input strings. + +=head1 SYNOPSIS + + use Koha::CoverGenerator; + +=head1 DESCRIPTION + +This module provides a public to draw an image based on two input +strings through GD::Image and GD::Text::Align. + +The module returns a base64 encoded string, that can be used as a +data url in JavaScript. + +=head1 FUNCTIONS + +=head2 render_image( $args ) + + my $args = { + first_line => $first_line, + second_line => $second_line, + font => $font, + font_path => $font_path, + width => $width, + height => $height, + fontsize => $fontsize, + padding => $padding + format => $format, + } + +=over 4 + +=item C + +First and second line are the strings to be drawn on the canvas. + +=item C + +First and second line are the strings to be drawn on the canvas. + +=item C + +The exact filename of the font to be used while omitting any extensions +like .ttf or .otf. + +=item C + +The path to the fonts directory where $font is located. Don't forget the +'/' at the end of the path. + +=item C + +Width of the canvas. + +=item C + +Height of the canvas. + +=item C + +Fontsize of the text drawn to the canvas. + +=item C + +Padding of the text drawn to the canvas. + +=back + +=cut + +use constant { + TWO => 2, + SIX => 6, + + RED => 244, + GREEN => 244, + BLUE => 244, +}; + +use constant SPACE => q{ }; +use constant EMPTY => q{}; + +my @leftover_words = EMPTY; +my $leftover_words = EMPTY; + +sub new { + my ( $class, $args ) = @_; + my $self = bless { + first_line => { string => $args->{'first_line'}, }, + second_line => { string => $args->{'second_line'}, }, + font => { + name => $args->{'font'}, + path => $args->{'font_path'}, + size => $args->{'fontsize'}, + }, + dimensions => { + width => $args->{'width'}, + height => $args->{'height'}, + padding => $args->{'padding'}, + content_width => $args->{'width'} - $args->{'padding'}, + }, + positions => { + horizontal_center => $args->{'width'} / TWO, + vertical_top => $args->{'height'} / SIX, + vertical_bottom => $args->{'height'} / TWO, + }, + image => GD::Image->new( $args->{'width'}, $args->{'height'} ), + format => $args->{'format'}, + }, $class; + + $self->{'image'}->trueColor(1); + $self->{'image'}->colorAllocate( RED, GREEN, BLUE ); + $self->{'font'}->{'color'} = $self->{'image'}->colorAllocate( 0, 0, 0 ); + + $self->{'align'} = GD::Text::Align->new( + $self->{'image'}, + valign => 'center', + halign => 'center', + color => $self->{'font'}->{'color'}, + ); + $self->{'align'}->font_path( $args->{'font_path'} ); + + return $self; + +} + +sub get_string_width_by_params { + my ( $class, $args ) = @_; + + if ( !defined $class->{'align'} ) { + Koha::Exceptions::MissingParameter->throw('image is undefined as parameter in get_string_width_by_params'); + } + + $class->{'align'}->set_font( $class->{'font'}->{'name'}, $args->{'fontsize'} || $class->{'font'}->{'size'} ); + $class->{'align'}->set_text( $args->{'string'} ); + + return $class->{'align'}->get('width'); +} + +sub trim_string { + my ( $class, $args ) = @_; + + # turning point for the trim_string function. + if ( $args->{'string'} eq $leftover_words ) { + $leftover_words = EMPTY; + } + + # Break the string on spaces and assign to array. + my @words = split SPACE, $args->{'string'}; + + # Pop the last word and store in leftovers. + $leftover_words .= pop(@words) . SPACE; + + # Set the return value to empty string. + my $new_line = EMPTY; + + # Append the remaining words to the return value. + for my $word (@words) { + $new_line .= "$word "; + } + + # Check if the new line fits into the box. + my $new_string_width = $class->get_string_width_by_params( { string => $new_line } ); + + # If it does, return the new line and handle the leftovers. + if ( $new_string_width <= $class->{'dimensions'}->{'content_width'} ) { + return $new_line; + } + + # If a single word is bigger than the box, we have to prevent an infinite loop. + if ( $new_string_width > $class->{'dimensions'}->{'content_width'} + && scalar @words == 1 ) + { + return $new_line; + } + + # If it doesn't, repeat the process until it does. + return $class->trim_string( { string => $new_line, } ); +} + +sub format_string { + my ( $class, $args ) = @_; + + my $string_width = $class->get_string_width_by_params( { string => $args->{'string'} } ); + + if ( $string_width <= $class->{'dimensions'}->{'content_width'} ) { + return $args->{'string'}; + } + + my $return_value; + my $formatted_string = EMPTY; + + $return_value = $class->trim_string( { string => $args->{'string'}, } ); + $formatted_string .= "$return_value\n"; + + # reverse order of the leftovers before entering the while loop. + @leftover_words = reverse split SPACE, $leftover_words; + $leftover_words = EMPTY; + + for my $word (@leftover_words) { + $leftover_words .= "$word "; + } + + while ( !$leftover_words eq EMPTY ) { + $return_value = EMPTY; + $return_value = $class->trim_string( { string => $leftover_words, } ); + + $formatted_string .= "$return_value\n"; + + @leftover_words = reverse split SPACE, $leftover_words; + $leftover_words = EMPTY; + + for my $word (@leftover_words) { + $leftover_words .= "$word "; + } + + my $new_string_width = $class->get_string_width_by_params( { string => $leftover_words } ); + + if ( $new_string_width <= $class->{'dimensions'}->{'content_width'} ) { + $formatted_string .= "$leftover_words\n"; + $leftover_words = EMPTY; + } + + if ( $new_string_width > $class->{'dimensions'}->{'content_width'} + && scalar @leftover_words == 1 ) + { + $formatted_string .= "$leftover_words\n"; + $leftover_words = EMPTY; + } + + } + + return $formatted_string; + +} + +sub draw_text { + my ( $class, $args ) = @_; + + my $result_string = $class->format_string( { string => $args->{'content_string'}, } ); + + my $new_image_width = $class->get_string_width_by_params( { string => $result_string, } ); + + my $new_fontsize = 0; + + while ( $new_image_width > $class->{'dimensions'}->{'content_width'} ) { + if ( !$new_fontsize ) { + $new_fontsize = $class->{'font'}->{'size'} - 2; + } + $class->{'align'}->set_font( $class->{'font'}->{'name'}, $new_fontsize ); + $new_image_width = $class->get_string_width_by_params( + { fontsize => $new_fontsize, + string => $result_string + } + ); + $new_fontsize -= 2; + } + + my @centered_result = split m/[\n]/xms, $result_string; + + my $index = 0; + for my $line (@centered_result) { + $class->{'align'}->set_text($line); + $class->{'align'}->draw( $class->{'positions'}->{'horizontal_center'}, $args->{'vertical_position'} + $index * ( $class->{'font'}->{'size'} + ( $class->{'font'}->{'size'} / 2 ) ), 0 ); + $index++; + } + + return; +} + +sub render_image { + my $class = shift; + + if ( $class->{'first_line'}->{'string'} ) { + $class->draw_text( + { content_string => $class->{'first_line'}->{'string'}, + vertical_position => $class->{'positions'}->{'vertical_top'}, + } + + ); + } + + if ( $class->{'second_line'}->{'string'} ) { + $class->draw_text( + { content_string => $class->{'second_line'}->{'string'}, + vertical_position => !$class->{'first_line'}->{'string'} + ? $class->{'positions'}->{'vertical_top'} + : $class->{'positions'}->{'vertical_bottom'}, + } + + ); + } + + my $formats = { + 'jpeg' => sub { return encode_base64( $class->{'image'}->jpeg ) }, + 'png' => sub { return encode_base64( $class->{'image'}->png ) }, + 'gif' => sub { return encode_base64( $class->{'image'}->gif ) }, + }; + + return $formats->{ $class->{'format'} }->(); +} + +1; + + diff --git a/Koha/REST/V1/Biblios.pm b/Koha/REST/V1/Biblios.pm index f923255cfa..7e20ee74d8 100644 --- a/Koha/REST/V1/Biblios.pm +++ b/Koha/REST/V1/Biblios.pm @@ -20,6 +20,7 @@ use Modern::Perl; use Mojo::Base 'Mojolicious::Controller'; use Koha::Biblios; +use Koha::CoverGenerator; use Koha::Ratings; use Koha::RecordProcessor; use C4::Biblio qw( DelBiblio ); @@ -373,6 +374,78 @@ sub pickup_locations { }; } +=head3 get_default_cover_public + +Method that returns a generated default cover image for a given biblio, +for unprivileged access. + +=cut + +sub get_default_cover_public { + my $c = shift->openapi->valid_input or return; + + my $biblio_id = $c->validation->param('biblio_id'); + my $biblio = Koha::Biblios->find($biblio_id); + my $record = $biblio->metadata->record; + my $first_line = $record->title; + my $second_line = $record->author; + + my $width = $c->validation->param('width'); + my $height = $c->validation->param('height'); + my $fontsize = $c->validation->param('fontsize'); + my $padding = $c->validation->param('padding'); + my $format = $c->validation->param('format'); + + use constant { + FONT => 'DejaVuSans', + FONT_PATH => '/usr/share/fonts/truetype/dejavu/', + WIDTH => 400, + HEIGHT => 480, + FONTSIZE => 28, + PADDING => 20, + FORMAT => 'png', + }; + + unless ($biblio) { + return $c->render( + status => 404, + openapi => { error => 'Biblio not found' } + ); + } + + return try { + my $cover_generator = Koha::CoverGenerator->new( + { first_line => $first_line, + second_line => $second_line, + font => FONT, + font_path => FONT_PATH, + width => $width || WIDTH, + height => $height || HEIGHT, + fontsize => $fontsize || FONTSIZE, + padding => $padding || PADDING, + format => $format || FORMAT, + } + ); + + my $generated_cover_image_source = $cover_generator->render_image; + + if ( !$generated_cover_image_source ) { + return $c->render( + status => 404, + openapi => { error => 'No cover image could be generated' }, + ); + } + + return $c->render( + status => 200, + openapi => $generated_cover_image_source =~ s/[\r\n]+//smxrg, + ); + } + catch { + $c->unhandled_exception($_); + }; +} + =head3 get_items_public Controller function that handles retrieving biblio's items, for unprivileged diff --git a/api/v1/swagger/paths/biblios.yaml b/api/v1/swagger/paths/biblios.yaml index 782de211e7..f43e612dc7 100644 --- a/api/v1/swagger/paths/biblios.yaml +++ b/api/v1/swagger/paths/biblios.yaml @@ -326,6 +326,73 @@ description: Under maintenance schema: $ref: "../swagger.yaml#/definitions/error" +"/public/biblios/{biblio_id}/default_cover": + get: + x-mojo-to: Biblios#get_default_cover_public + operationId: getBiblioDefaultCoverPublic + summary: Get a default cover image + parameters: + - $ref: "../swagger.yaml#/parameters/biblio_id_pp" + - name: width + in: query + description: Width of the canvas + required: false + type: integer + - name: height + in: query + description: Height of the canvas + required: false + type: integer + - name: fontsize + in: query + description: Fontsize of text drawn to the canvas + required: false + type: integer + - name: padding + in: query + description: Padding that wraps the text to the canvas + required: false + type: integer + - name: format + in: query + required: false + description: The image format of the resulting base64 encoded string + type: string + - $ref: "../swagger.yaml#/parameters/page" + - $ref: "../swagger.yaml#/parameters/per_page" + - $ref: "../swagger.yaml#/parameters/match" + - $ref: "../swagger.yaml#/parameters/order_by" + - $ref: "../swagger.yaml#/parameters/q_param" + - $ref: "../swagger.yaml#/parameters/q_body" + - $ref: "../swagger.yaml#/parameters/q_header" + - $ref: "../swagger.yaml#/parameters/request_id_header" + produces: + - application/json + responses: + "200": + description: The default cover for a biblio + schema: + type: string + format: binary + "403": + description: Access forbidden + schema: + $ref: "../swagger.yaml#/definitions/error" + "404": + description: Biblio not found + schema: + $ref: "../swagger.yaml#/definitions/error" + "500": + description: | + Internal server error. Possible `error_code` attribute values: + + * `internal_server_error` + schema: + $ref: "../swagger.yaml#/definitions/error" + "503": + description: Under maintenance + schema: + $ref: "../swagger.yaml#/definitions/error" "/public/biblios/{biblio_id}/items": get: x-mojo-to: Biblios#get_items_public diff --git a/api/v1/swagger/swagger.yaml b/api/v1/swagger/swagger.yaml index 696a73be8b..72906264b5 100644 --- a/api/v1/swagger/swagger.yaml +++ b/api/v1/swagger/swagger.yaml @@ -273,6 +273,8 @@ paths: $ref: "./paths/patrons_password.yaml#/~1patrons~1{patron_id}~1password~1expiration_date" "/public/biblios/{biblio_id}": $ref: "./paths/biblios.yaml#/~1public~1biblios~1{biblio_id}" + "/public/biblios/{biblio_id}/default_cover": + $ref: "./paths/biblios.yaml#/~1public~1biblios~1{biblio_id}~1default_cover" "/public/biblios/{biblio_id}/items": $ref: "./paths/biblios.yaml#/~1public~1biblios~1{biblio_id}~1items" "/public/biblios/{biblio_id}/ratings": -- 2.37.1 (Apple Git-137.1)