Bugzilla – Attachment 144726 Details for
Bug 31624
REST API: Add endpoint for generated covers
Home
|
New
|
Browse
|
Search
|
[?]
|
Reports
|
Help
|
New Account
|
Log In
[x]
|
Forgot Password
Login:
[x]
[patch]
Bug 31624: REST API: Add endpoint for generated covers
Bug-31624-REST-API-Add-endpoint-for-generated-cove.patch (text/plain), 16.90 KB, created by
Paul Derscheid
on 2022-12-19 21:37:03 UTC
(
hide
)
Description:
Bug 31624: REST API: Add endpoint for generated covers
Filename:
MIME Type:
Creator:
Paul Derscheid
Created:
2022-12-19 21:37:03 UTC
Size:
16.90 KB
patch
obsolete
>From 99f563f8c832b10b4ff32a8fe5e3743731027f63 Mon Sep 17 00:00:00 2001 >From: LMSCloudPaulD <paul.derscheid@lmscloud.de> >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 <http://www.gnu.org/licenses>. >+ >+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_line> >+ >+First and second line are the strings to be drawn on the canvas. >+ >+=item C<second_line> >+ >+First and second line are the strings to be drawn on the canvas. >+ >+=item C<font> >+ >+The exact filename of the font to be used while omitting any extensions >+like .ttf or .otf. >+ >+=item C<font_path> >+ >+The path to the fonts directory where $font is located. Don't forget the >+'/' at the end of the path. >+ >+=item C<width> >+ >+Width of the canvas. >+ >+=item C<height> >+ >+Height of the canvas. >+ >+=item C<fontsize> >+ >+Fontsize of the text drawn to the canvas. >+ >+=item C<padding> >+ >+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)
You cannot view the attachment while viewing its details because your browser does not support IFRAMEs.
View the attachment on a separate page
.
View Attachment As Diff
View Attachment As Raw
Actions:
View
|
Diff
|
Splinter Review
Attachments on
bug 31624
: 144726