Intervention / image

PHP Image Processing
https://image.intervention.io
MIT License
13.79k stars 1.5k forks source link

Centering Issue with Japanese Characters (v3) #1356

Closed tumur1 closed 1 month ago

tumur1 commented 1 month ago

I'm using the library to create user icons with the first letter of their name. In version 2, placing the first letter on the canvas worked perfectly and it was centered. However, after updating to version 3, the centering is no longer working for Japanese characters (English characters seem to be unaffected).

Code Example v2:

$img = Image::canvas(200, 200, $bg);
$img->text($char, 100, 100, function ($font) use ($font_path) {
    $font->file(resource_path($font_path));
    $font->size(130);
    $font->color('#fff');
    $font->align('center');
    $font->valign('middle');            
})

v3:

$img = Image::create(200, 200)->fill($bg);
$img->text($firstChar, 100, 100, function ($font) use ($font_path) {
    $font->filename(resource_path($font_path));
    $font->size(130);
    $font->color('#fff');
    $font->align('center');
    $font->valign('middle');            
});

Result: v2 v2

v3 v3

For example, it's small(ゥ)character

Environment (please complete the following information):

olivervogel commented 1 month ago

Thanks for reporting. I will look into this.

gammalogic commented 1 month ago

I do not know what the issue is, but from some tests I made with three different Japanese fonts when I look at the output from imageftbbox for the first font the bounding box is the correct size but there is a significant difference in the bounding box x position when outputting Japanese and Latin characters in the same Japanese font. However, for the second and third fonts I tried the positioning was more accurate but still not exactly aligned. The output from the Imagick driver is slightly more accurate but not exact.

GD Driver

II Japanese Fonts Gd Driver

Imagick Driver

II Japanese Fonts Imagick Driver

$image->text('W', 250, 200, function (FontFactory $font) {
    $font->filename('./ちはや純.ttf');
    $font->size(250);
    $font->color('#fff');
    $font->align('center');
});
$image->text('ム', 250, 200, function (FontFactory $font) {
    $font->filename('./ちはや純.ttf');
    $font->size(250);
    $font->color('#f00');
    $font->align('center');
});

$image->text('W', 250, 300, function (FontFactory $font) {
    $font->filename('./mini-wakuwaku.otf');
    $font->size(150);
    $font->color('#fff');
    $font->align('center');
});
$image->text('ム', 250, 300, function (FontFactory $font) {
    $font->filename('./mini-wakuwaku.otf');
    $font->size(150);
    $font->color('#f00');
    $font->align('center');
});

$image->text('W', 250, 450, function (FontFactory $font) {
    $font->filename('./Tryk2kp.ttc');
    $font->size(150);
    $font->color('#fff');
    $font->align('center');
});
$image->text('ム', 250, 450, function (FontFactory $font) {
    $font->filename('./Tryk2kp.ttc');
    $font->size(150);
    $font->color('#f00');
    $font->align('center');
});
olivervogel commented 1 month ago

I've also done a few more tests and unfortunately I don't know where to locate the problem.

However, I suspect that the font used is the deciding factor here.

A test I did with version 2 and "Arial Unicode MS" as the font did not show a correctly centered result either. Neither with version 2 nor with version 3.

@tumur1 What font did you use in your example?

Version 2

v2

$manager = new ImageManager(['driver' => 'gd']);

$image = $manager->canvas(200, 200, '666');

$image->line(100, 0, 100, 200, function ($line) {
    $line->color('333');
});

$image->line(0, 100, 200, 100, function ($line) {
    $line->color('333');
});

$image->text('ゥ', 100, 100, function ($font) {
    $font->file('./ArialUnicodeMS.ttf');
    $font->size(130);
    $font->color('fff');
    $font->align('center');
    $font->valign('middle');
});

Version 3

v3

$image = ImageManager::gd()
    ->create(200, 200)
    ->fill('666');

$image->drawLine(function ($line) {
    $line->color('333');
    $line->from(100, 0);
    $line->to(100, 200);
});

$image->drawLine(function ($line) {
    $line->color('333');
    $line->from(0, 100);
    $line->to(200, 100);
});

$image->text('ゥ', 100, 100, function ($font) {
    $font->file('./ArialUnicodeMS.ttf');
    $font->size(130);
    $font->color('fff');
    $font->align('center');
    $font->valign('middle');
});
gammalogic commented 1 month ago

Looking at the output from the Imagick::queryFontMetrics function, it seems that with some Japanese fonts the textWidth value of Japanese characters is significantly greater than the same character's boundingBox value, and there is too much space between characters if you output 2 or more. The closer the textWidth value is to the characterWidth value (the font size specified by the user), the more likely it is that a character will be centered correctly.

The boundingBox values are very accurate but can only be used for single characters, so a possible solution might be to split the text string into separate characters, total the width of all the bounding boxes, and include a calculation for the letter spacing (kerning).

gammalogic commented 1 month ago

I have created a test script using the standard Imagick functions that seems to horizontally center the text correctly:

II Japanese Text Alignment Imagick Driver

In my test script I am using the originX value from the Imagick::queryFontMetrics function which seems to be more accurate than the textWidth value. Latin fonts seem to be horizontally centered correctly as well.

The code I used to generate this image was:

$width = 500;
$height = 500;

$image = new Imagick();
$image->setAntiAlias(true);
$image->newImage($width, $height, new ImagickPixel('#444'));

$draw = new ImagickDraw();

// draw center target lines
$draw->setStrokeColor('#f00');
$draw->line($width / 2, 0, $width / 2, $height);
$draw->line(0, $height / 2, $width, $height / 2);

// define text and retrieve font metrics
$draw->setFont('./Tryk2kp.ttc');
$draw->setFontSize(400);
$text = 'ム';
$metrics = $image->queryFontMetrics($draw, $text);
$baseline = $metrics['boundingBox']['y2'];
$character_width = $metrics['boundingBox']['x2'] - $metrics['boundingBox']['x1'];
$text_height = $metrics['ascender'] + $metrics['descender'];

// draw rectangle with a size of $metrics['originX']
$draw->setStrokeColor('#f00');
$draw->setFillColor('transparent');
$draw->rectangle(
    ($width / 2) - ($metrics['originX'] / 2),
    ($height / 2) - ($text_height / 2),
    ($width / 2) - ($metrics['originX'] / 2) + $metrics['originX'],
    ($height / 2) - ($text_height / 2) + $text_height
);

// draw rectangle with a size of $metrics['boundingBox']['x2'] - $metrics['boundingBox']['x1']
$draw->setStrokeColor('#f00');
$draw->setFillColor('transparent');
$draw->rectangle(
    ($width / 2) - ($character_width / 2),
    ($height / 2) - ($text_height / 2),
    ($width / 2) - ($character_width / 2) + $character_width,
    ($height / 2) - ($text_height / 2) + $text_height
);

// draw text
$draw->setFillColor('#f00');
$x = ($width / 2) - ($metrics['originX'] / 2);
$y = ($height / 2) + ($text_height / 2);
$image->annotateImage(
    $draw,
    $x,
    $y,
    0,
    $text
);

// define text and retrieve font metrics
$draw->setFont('./mini-wakuwaku.otf');
$draw->setFontSize(90);
$text = 'アイウエオ';
$metrics = $image->queryFontMetrics($draw, $text);
$baseline = $metrics['boundingBox']['y2'];
$character_width = $metrics['boundingBox']['x2'] - $metrics['boundingBox']['x1'];
$text_height = $metrics['ascender'] + $metrics['descender'];

// draw rectangle with a size of $metrics['originX']
$draw->setStrokeColor('#ff0');
$draw->setFillColor('transparent');
$draw->rectangle(
    ($width / 2) - ($metrics['originX'] / 2),
    ($height / 2) - ($text_height / 2),
    ($width / 2) - ($metrics['originX'] / 2) + $metrics['originX'],
    ($height / 2) - ($text_height / 2) + $text_height
);

// draw rectangle with a size of $metrics['boundingBox']['x2'] - $metrics['boundingBox']['x1']
$draw->setStrokeColor('#ff0');
$draw->setFillColor('transparent');
$draw->rectangle(
    ($width / 2) - ($character_width / 2),
    ($height / 2) - ($text_height / 2),
    ($width / 2) - ($character_width / 2) + $character_width,
    ($height / 2) - ($text_height / 2) + $text_height
);

// draw text
$draw->setFillColor('#ff0');
$x = ($width / 2) - ($metrics['originX'] / 2);
$y = ($height / 2) + ($text_height / 2);
$image->annotateImage(
    $draw,
    $x,
    $y,
    0,
    $text
);

$image->drawImage($draw);

$image->setImageFormat('png');

header("Content-Type: image/png");
echo $image;
olivervogel commented 1 month ago

As it stands now, Imagick seems to be working without any problems.

I did another comparison with just GD (without Intervention Image). This script draws characters (latin and japanese for comparision) and visualizes the coordinate of the "box size". Here you can see that the box is drawn correctly with a latin character. With a Japanese character you can see clear deviations. This could be a bug in GD.

Latin

gd

Japanese

gd-1

define('CHAR', 'ゥ');
define('COLOR_GREY', 14540253);
define('COLOR_BLACK', 0);
define('FONT_SIZE', 140);
define('FONT_ANGLE', 0);
define('FONT_FILE', './ArialUnicodeMS.ttf');

// create gdimage
$gd = imagecreatetruecolor(400, 400);

// fill with grey
imagefill($gd, 0, 0, COLOR_GREY);

// draw grid
imageline($gd, 200, 0, 200, 400, COLOR_BLACK);
imageline($gd, 0, 200, 400, 200, COLOR_BLACK);

// read box size
$box = imageftbbox(
    FONT_SIZE,
    FONT_ANGLE,
    FONT_FILE,
    CHAR
);

// map box size to text position (200x200)
$box = array_map(function ($point) {
    return $point + 200;
}, $box);

// draw box
imagepolygon(
    $gd,
    $box,
    COLOR_BLACK,
);

// draw text
imagettftext(
    $gd,
    FONT_SIZE,
    FONT_ANGLE,
    200,
    200,
    COLOR_BLACK,
    FONT_FILE,
    CHAR
);

// output
header('content-type: image/png');
imagepng($gd);
exit;
gammalogic commented 1 month ago

I have created another test script using standard GD functions that seems to horizontally center the text correctly:

II Japanese Text Alignment With GD Driver

II Japanese Text Alignment GD Driver Latin Fonts

I noticed in the src/Drivers/Gd/FontProcessor.php script that the text width is calculated as follows:

intval(abs($box[4] - $box[0]))

However, if I do this

intval(abs($box[4]) + abs($box[0]))

then (in my test script) I get the correct text width. The text width is also correct for Latin fonts.

This also applies with the height calculation. Currently the code to do this is

intval(abs($box[5] - $box[1]))

but if I change the code to

intval(abs($box[5]) + abs($box[1]))

then the vertical centering is also correct for Japanese characters.

The code I used to generate the first image was:

$width = 500;
$height = 500;

$image = imagecreatetruecolor($width, $height);

$black = imagecolorallocate($image, hexdec(44), hexdec(44), hexdec(44));
$red = imagecolorallocate($image, 255, 0, 0);
$yellow = imagecolorallocate($image, 255, 255, 0);

// draw center target lines
imageline($image, $width / 2, 0, $width / 2, $height, $red);
imageline($image, 0, $height / 2, $width, $height / 2, $red);

// define text and retrieve font metrics (bounding box only)
$font = './Tryk2kp.ttc';
$font_size = (int) 200;
$text = 'ム';
$box = imageftbbox($font_size, 0, $font, $text);
$text_width = intval(abs($box[4]) + abs($box[0]));
$text_height = intval(abs($box[5]) + abs($box[1]));

// draw rectangle with a size of abs($box[4]) + abs($box[0])
imagerectangle(
    $image,
    (int) ($width / 2) - (int) ($text_width / 2),
    (int) ($height / 2) - (int) ($text_height / 2),
    (int) ($width / 2) - (int) ($text_width / 2) + (int) $text_width,
    (int) ($height / 2) - (int) ($text_height / 2) + $text_height,
    $red
);

// draw text
$x = (int) ($width / 2) - (int) ($text_width / 2);
$y = ($height / 2) + ($text_height / 2);
imagefttext($image, $font_size, 0, (int) $x, (int) $y, $red, $font, $text);

// define text and retrieve font metrics (bounding box only)
$font = './mini-wakuwaku.otf';
$font_size = (int) 50;
$text = 'アイウエオ';
$box = imageftbbox($font_size, 0, $font, $text);
$text_width = intval(abs($box[4]) + abs($box[0]));
$text_height = intval(abs($box[5]) + abs($box[1]));

// draw rectangle with a size of abs($box[4]) + abs($box[0])
imagerectangle(
    $image,
    (int) ($width / 2) - (int) ($text_width / 2),
    (int) ($height / 2) - (int) ($text_height / 2),
    (int) ($width / 2) - (int) ($text_width / 2) + (int) $text_width,
    (int) ($height / 2) - (int) ($text_height / 2) + $text_height,
    $yellow
);

// draw text
$x = (int) ($width / 2) - (int) ($text_width / 2);
$y = ($height / 2) + ($text_height / 2);
imagefttext($image, $font_size, 0, (int) $x, (int) $y, $yellow, $font, $text);

header('Content-Type: image/png');
imagepng($image);
imagedestroy($image);
tumur1 commented 1 month ago

@olivervogel I am using the Noto_Sans_CJK-Bold font. It seems to be on the GD driver. When I switched the driver to Imagick, the problem was solved. [My server did not support Imagick before, but it has been upgraded now]. Additionally, I have attached the font I used. Feel free to close the issue if no further investigation is needed. font.zip

GD: GD

Imagick: Imagick

olivervogel commented 1 month ago

I found the error. The result of imageftbbox contains an offset that is not included in the calculation of Intervention Image. For Latin fonts this does not seem to make a difference, but for Japanese (and probably also other non-latin) fonts the offset is needed to render the position correctly.

Latin Font

Top-Left corner is 0, -10

array(8) {
  [0]=>
  int(0)
  [1]=>
  int(0)
  [2]=>
  int(39)
  [3]=>
  int(0)
  [4]=>
  int(39)
  [5]=>
  int(-10)
  [6]=>
  int(0)
  [7]=>
  int(-10)
}

Japanese

Top-Left corner is at 29, -91 (offset of 29 is currently disregarded).

array(8) {
  [0]=>
  int(29)
  [1]=>
  int(-8)
  [2]=>
  int(131)
  [3]=>
  int(-8)
  [4]=>
  int(131)
  [5]=>
  int(-91)
  [6]=>
  int(29)
  [7]=>
  int(-91)
}
olivervogel commented 1 month ago

This issue has been fixed as of version 3.7.0.