From 51aa911b9a6cc7aabfb898699c0e4672beb921f9 Mon Sep 17 00:00:00 2001 From: Jake Jackson Date: Tue, 22 Sep 2026 07:26:57 +1000 Subject: [PATCH] Leave a character the document font has in that font when substituting (#76) A run moved to a backup font carried the spaces inside it along, so a space between two emoji was the emoji font's. Noto Color Emoji's space is as wide as an emoji. The Plane 2 branch's space clause never applied, since a space is never above U+1FFFF. Co-Authored-By: Claude Opus 5 (1M context) --- src/Mpdf.php | 24 ++++++++------- src/Unicode/Emoji.php | 7 +++-- tests/Mpdf/EmojiSubstitutionTest.php | 31 ++++++++++++++++++-- tests/data/snapshots/color-emoji-cbdt.pdf | Bin 32977 -> 41973 bytes tests/data/snapshots/color-emoji-colrv0.pdf | Bin 30328 -> 39324 bytes tests/data/snapshots/color-emoji-sbix.pdf | Bin 37821 -> 47238 bytes 6 files changed, 48 insertions(+), 14 deletions(-) diff --git a/src/Mpdf.php b/src/Mpdf.php index 965c12a83..521ec2723 100644 --- a/src/Mpdf.php +++ b/src/Mpdf.php @@ -25776,7 +25776,7 @@ function SubstituteCharsNonCore(&$writehtml_a, &$writehtml_i, &$writehtml_e) foreach ($unicode as $c => $char) { - if (($char > 127 || ($flag == 1 && $char == 32)) && $char != 173 && (!isset($this->subArrMB['a'][$char]) || ($flag == 1 && $char == 32)) && ($char < 1536 || ($char > 1791 && $char < 2304) || $char > 3455)) { + if ($char > 127 && $char != 173 && !isset($this->subArrMB['a'][$char]) && ($char < 1536 || ($char > 1791 && $char < 2304) || $char > 3455)) { if ($flag == 0) { $start = $c; } @@ -25803,7 +25803,8 @@ function SubstituteCharsNonCore(&$writehtml_a, &$writehtml_i, &$writehtml_e) foreach ($this->backupSubsFont as $bsfctr => $bsf) { - if ($this->fonttrans[$bsf] == 'chelvetica' || $this->fonttrans[$bsf] == 'ctimes' || $this->fonttrans[$bsf] == 'ccourier') { + // A backup font that is a core font's name draws nothing a core font does not + if (isset($this->fonttrans[$bsf]) && in_array($this->fonttrans[$bsf], ['chelvetica', 'ctimes', 'ccourier'], true)) { continue; } @@ -25880,25 +25881,30 @@ function SubstituteCharsMB(&$writehtml_a, &$writehtml_i, &$writehtml_e) $ftype = ''; $u = []; // Every emoji is outside ASCII, a keycap's U+20E3 included - $emojiClusters = preg_match('/[^\x00-\x7F]/', $writehtml_e) ? Emoji::clusters($unicode) : []; + $mayHaveEmoji = preg_match('/[^\x00-\x7F]/', $writehtml_e) === 1; + // Each emoji up to where the run ends, by where it starts: found as the scan reaches it, since the + // rest of the text is scanned again on the next pass + $emojiClusters = []; $codepointCount = count($unicode); // Not foreach, so the loop can jump past the rest of a multi-codepoint sequence once it has been handled for ($c = 0; $c < $codepointCount; $c++) { $char = $unicode[$c]; + $cluster = $mayHaveEmoji ? Emoji::clusterAt($unicode, $c, $codepointCount) : null; // An emoji moves whole or not at all, so its joiners and selectors go with it to the font // whose GSUB joins them - if (isset($emojiClusters[$c])) { + if ($cluster !== null) { // An emoji ends a SIP run; it is handled on the next pass if ($flag == 2) { break; } - $length = $emojiClusters[$c][0]; + $emojiClusters[$c] = $cluster; + $length = $cluster[0]; $emoji = array_slice($unicode, $c, $length); - if ($this->fontSubstitution->emojiWantsAnotherFont($emoji, $emojiClusters[$c][1])) { + if ($this->fontSubstitution->emojiWantsAnotherFont($emoji, $cluster[1])) { if ($flag == 0) { $start = $c; } @@ -25912,7 +25918,7 @@ function SubstituteCharsMB(&$writehtml_a, &$writehtml_i, &$writehtml_e) continue; } - if (($flag == 0 || $flag == 2) && (!$this->_charDefined($cw, $char) || ($flag == 2 && $char == 32)) && $this->checkSIP && $char > 131071) { // Unicode Plane 2 (SIP) + if (($flag == 0 || $flag == 2) && !$this->_charDefined($cw, $char) && $this->checkSIP && $char > 131071) { // Unicode Plane 2 (SIP) if (in_array($this->FontFamily, $this->available_CJK_fonts)) { return 0; @@ -25925,9 +25931,7 @@ function SubstituteCharsMB(&$writehtml_a, &$writehtml_i, &$writehtml_e) $flag = 2; $u[] = $char; - // elseif (($flag == 0 || $flag==1) && $char != 173 && !$this->_charDefined($cw,$char) && ($char<1423 || ($char>3583 && $char < 11263))) { - - } elseif (($flag == 0 || $flag == 1) && $char != 173 && (!$this->_charDefined($cw, $char) || ($flag == 1 && $char == 32)) && ($char < 1536 || ($char > 1791 && $char < 2304) || $char > 3455)) { + } elseif (($flag == 0 || $flag == 1) && $char != 173 && !$this->_charDefined($cw, $char) && ($char < 1536 || ($char > 1791 && $char < 2304) || $char > 3455)) { if ($flag == 0) { $start = $c; diff --git a/src/Unicode/Emoji.php b/src/Unicode/Emoji.php index f5945972d..c31f5792b 100644 --- a/src/Unicode/Emoji.php +++ b/src/Unicode/Emoji.php @@ -253,13 +253,16 @@ public static function isTag($codepoint) } /** + * The emoji at one place in a run, for a caller walking the run itself that would otherwise find + * every emoji in it with clusters() before it knows how far it will look + * * @param int[] $codepoints The run - * @param int $i Where in it to look + * @param int $i Where in it to look, which is not inside an emoji that starts earlier * @param int $count How long it is * * @return array|null [length, presentation] of the emoji starting at $i, or null where none does */ - private static function clusterAt(array $codepoints, $i, $count) + public static function clusterAt(array $codepoints, $i, $count) { $first = $codepoints[$i]; diff --git a/tests/Mpdf/EmojiSubstitutionTest.php b/tests/Mpdf/EmojiSubstitutionTest.php index f7ba32a3b..351f6acdf 100644 --- a/tests/Mpdf/EmojiSubstitutionTest.php +++ b/tests/Mpdf/EmojiSubstitutionTest.php @@ -89,10 +89,37 @@ public function testAKeycapGoesToTheFontThatHasAllOfIt() */ public function testAFlagAndASkinToneStayWithTheEmojiTheyQualify() { - $pieces = $this->drawn([0x1F1E6, 0x1F1FA, 0x20, 0x1F44D, 0x1F3FD], ['coloremoji']); + $pieces = $this->drawn([0x1F1E6, 0x1F1FA, 0x1F44D, 0x1F3FD], ['coloremoji']); + $this->assertCount(1, $pieces); + $this->assertSame('coloremoji', $pieces[0][0]); + $this->assertCount(2, $pieces[0][1], 'the flag and the thumb'); + } + + /** + * A space between two emoji is the document font's, which has one, and not carried into the run + * of the font the emoji go to: an emoji font's space can be as wide as an emoji, as Noto Color + * Emoji's is. The recorder leaves out a piece that is only a space, so the space shows as the two + * runs either side of it. + */ + public function testASpaceBetweenEmojiStaysInTheDocumentFont() + { + $pieces = $this->drawn([0x1F1E6, 0x1F1FA, 0x20, 0x1F44D], ['coloremoji']); + + $this->assertCount(2, $pieces, 'a run either side of the space'); $this->assertSame('coloremoji', $pieces[0][0]); - $this->assertCount(3, $pieces[0][1], 'the flag, the space the run carries, and the thumb'); + $this->assertCount(1, $pieces[0][1], 'the flag, without the space'); + $this->assertSame(['coloremoji', [0x1F44D]], $pieces[1]); + } + + /** + * A space in a core font's text is the core font's too, and ends the run a backup font draws + */ + public function testASpaceBetweenWordsACoreFontLacksStaysInTheCoreFont() + { + $pieces = $this->drawn([0x61, 0x416, 0x416, 0x20, 0x416, 0x62], ['dejavusans'], ['default_font' => 'chelvetica']); + + $this->assertSame([['chelvetica', [0x61]], ['dejavusans', [0x416, 0x416]], ['dejavusans', [0x416]], ['chelvetica', [0x62]]], $pieces); } /** diff --git a/tests/data/snapshots/color-emoji-cbdt.pdf b/tests/data/snapshots/color-emoji-cbdt.pdf index 859c193ecb868b8b73ff787fb964504856ad7964..e4126b53bf68334aa2be3cb481b3c4d83777688a 100644 GIT binary patch delta 6456 zcmb_g&2Ah;5MHl|wO$7ah}UtvRx(}`5i&v{q2xTr(o_Zl0Zh|(7Qc7RssKV!n#w_n zGbLkNXb)O2wluZcO49e+@4ks-&ducNM)%4_!kLgZecI;p+33yH)<$cq^|-ZnDY-1t zsqUGcAl#*7l|~=7K5gA^?Sz>popaL?b^7S`_P-Wo;^&?!dSc zp`sO2N(3tMI8?lQIddXZEN5D~Kn0FN#R=wwYDlFU{KP3{Z2HM`e-V$tL~@b~fQC$h z$4cSlKn0_y<4}>32}R-G+-s#It&=`@mKJq=DmNq;CJpIBHI-p-@3bEIcslsG*J8zi zlM;pW{Hd8!z^w2AP)`z`&-MpKF|JO;_kje`$;|ibD#G*GfsV@iKB$iNGj2cfuImanf}q~FUYzK$ng*; zD38q`tKx)X4tX#F)TQ9dYaas*DF8B5I#9s66p#$4Nh2FlKuQ@#1`4V!1w|ILO`I{i z2n`Ia4HR@e3j4y41^tt%(h7N@A8G+U`VlID;m=VWFdX=zwbR;b-J@tKKNIqyONkHL z^xMwslVUw9z9<9$0#H=9*U>?T1oW-Y4@-l)O=VQ??(*t`B7@`-eY|AM8nze%G0s z6z=Kw-OjY3-ovC&KQylo6a|QZNkaCjx*~X(lJq*l%)_cfy@CP-i1d%VN-aGa1#0b> zAa_eusr~ovwb}J#X=+0jXU1;v*_=5;N)au|!PG<0l=a8K zg)4hDr{~Eu@tT0{AsTASL`Hk@^XYq^ofA?jOsEx0>E=<=4gVlVsnXsPvo9ndM6*KD zd}&OfipPMP@)%MhWBfGgErKnLSuhFTAEPBuikJ*?5F!GDG*-uB;0$dzwjZFNu2(!p zkG#>?er69qV`7GY7UM4gbX<q_ga@*nK&Kk^DNAW91jIb;mB+?A|gp;RR wv#svH=BDO?gS2+9Xm_ipd*J>K2g?ox|JneN4lS3kP;KhO|}XaE2J delta 2449 zcmd6p%W9KB6vsb98=D~U+F+Abrj|-t=ydKEs96Z@LIo*gClyMX(iE){0~R--dl%}w zfZ!w4g}AX@xpv_L_ypR8u06T-`({E#5o%z_ne&}9bLKby8Ro~Q)aSj_%cBuyjePa< zTY(Wnp)qJ2nt&#uDMs9#QC2V<8>^6322-j~Wdos7REu_^4RvZmB_uKtaRDs0Zs5yrgC@)254n}$w)uU##6?fI5N73VG zJF0h_ZZ9*D($cM-zaCC`G5F(V$uFm&xL*UR-fgnenhj0MTNCU~M?kRAEhMha$nfbO2M2cH#uuNO0WoH{iN} z=MWU_USx77Fq}+tB`~$_d#3o`GQ2C4#Z=0F!q9ygpGNV`BbW%-mSY7IsvR9GfQ0k; z?ibjmgfiXg%tT615a#ryA<18}#k5vf5#;Em_QeP_xTBfri^JAYlv-nM1=57UN<~kAy|Rx5*gKSc{kg;O%QwP<+VlJ&jkC-O zI0W+aam-E%iKT|)c#1S6W!Ngwrm=XEIEHQW^U_MryQ&XL^oq4uGlG zA32kU;30SX{`7>qO8o+vY%}b~wYogOY5WR-8XTm4 zU_Z_h<@*$&J}sswKc1wQt5`sRwG?m-1{BJtz?3xMfXZ?|7bG9h(jp|I;Q;`-ZT91nU%b%H>k3L@T#p^QQNe%x(Ub1z!J)6>9-+k*>XAiw7K4B T?lx+hSx&8@+1c6Udn>;HAd&=8 diff --git a/tests/data/snapshots/color-emoji-colrv0.pdf b/tests/data/snapshots/color-emoji-colrv0.pdf index f6aa10fb2a6ef550e774634acfead68004f5dbc1..2d88362dd386cd4b2468e55deb8650836b256d09 100644 GIT binary patch delta 6184 zcmb_gU29xL7(UsUu-R0w=A&sgv?p6C$wi0x{*bU>vAs}G3QKQF1@lE)ZG|>s5j6Lr zR+)<;2>B8H1K#;3ywH0;F9fgDXXb2@-E(rbJvn=o?999~&zX6j=Y3}W`E}y^XN_qiMkEVa2Pkws}!=<7!rO$_6?D2XUOPi3Ji3ldBa&4#~s!>Sl0S)r>R zq$p85zZW_LEgDvYo4FvQu{xrS6{{7;*95JQ_RnVB7#Z(;pByRemm{G zg>}>PMz(Z_zdw_n9KU>tNllbysbQKpW?~Ld(qHV>S|u3RDJcYCEHAO{6D2L>}n>+}7I{vwrkimv= ziGsf{H9aMWWq|U9Ur9QS7lnVc3nl5Cm zuonY1g8I*!=Nr_(M08%shxxA?OR+Sd-U%oC@6DBYns*Rb3=U%Tq~P|dH8?w@0lc4p zO9~c}!XHL>!9Kj$@-CDEhvkmq;V5ORS3ae2M*MPAS97WM+mYi1c?pQ(8!t)Bua+i5rV1;L171Non*`oLJK0T zjSzG-2>Z-n2g8-BbQk(YKY9Ur^dmZf!=Fb!;IQG79 z084Z&&5Oq%ge8n-X;#kn5(pA5qL%1o5ELgG-Z$uOIp0elfF-(?>P4Gs2CM7+znKQ?LYi^da|df|FStdithfaR&&ze z?O_zvPps=51%encO30q)D`JKzj@yyUGOV2HB@jRm9xi#MTKZxT@U&y1+%1=t_Fr1p zXS!+cUU)aT;s2Xl@GFhEiwY6}OQT_N3>Vq!Sa_v59Cy%M%*Nqb%lp;5zAB;ByjjBH!JaA-BXZ1X` zCO;-dFG-06LrEKs)?NR0gFgH5%+I^$8HY-xc#%cbd62f^KWK5*|M1A{GcNIXJKW2- z68Q+mwc+`3Fzv-Qg{QCFd_rqgXmb!hx*tX!!D3R#JQzS#g*NV($W16D!`*gxVIg@j zR$6uZar^!KkS9#2C|=HT6DlV2Uni7ut}xDWn{P9m>U`TBBa4l=1{S`=SWy6DDIPp? z04$Z33j@sSaq+2^XTf3Wb+j8TW+XY!Ro;&1rtJ`fo+I#=u U-s6WmyDeei=~|aBUwilZ|Kqb(UH||9 delta 2183 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