From 216cf8e81396b2fad130a273d2951a728aa8e2a9 Mon Sep 17 00:00:00 2001 From: Taiki Noda Date: Sun, 19 Apr 2026 02:54:19 +0900 Subject: [PATCH 1/3] fix: use Adobe Standard 14 AFM widths for alignment --- .../component/testdata/golden/01_invoice.pdf | Bin 8102 -> 8188 bytes .../component/testdata/golden/02_report.pdf | Bin 10660 -> 10764 bytes .../component/testdata/golden/03_letter.pdf | Bin 3389 -> 3324 bytes .../testdata/golden/00_from_template.pdf | Bin 2340 -> 2305 bytes .../gotemplate/testdata/golden/00_invoice.pdf | Bin 6246 -> 6317 bytes .../json/testdata/golden/00_overview.pdf | Bin 16237 -> 16240 bytes _examples/testdata/golden/02_text_styling.pdf | Bin 2778 -> 2791 bytes _examples/testdata/golden/03_grid_layout.pdf | Bin 5276 -> 5327 bytes .../testdata/golden/04_fixed_height_row.pdf | Bin 1927 -> 1942 bytes _examples/testdata/golden/08_table_styled.pdf | Bin 7185 -> 7262 bytes _examples/testdata/golden/12_multi_page.pdf | Bin 13098 -> 13098 bytes .../testdata/golden/13_header_footer.pdf | Bin 3596 -> 3629 bytes _examples/testdata/golden/16_margins.pdf | Bin 1521 -> 1467 bytes _examples/testdata/golden/17_colors.pdf | Bin 3320 -> 3342 bytes _examples/testdata/golden/18_invoice.pdf | Bin 8093 -> 8182 bytes _examples/testdata/golden/19_report.pdf | Bin 12147 -> 12252 bytes _examples/testdata/golden/21_facade.pdf | Bin 1090 -> 1037 bytes _examples/testdata/golden/23_text_indent.pdf | Bin 1703 -> 1703 bytes .../testdata/golden/24_text_decoration.pdf | Bin 1811 -> 1778 bytes .../golden/25_table_vertical_align.pdf | Bin 4211 -> 4232 bytes _examples/testdata/golden/26_page_number.pdf | Bin 4683 -> 4679 bytes .../testdata/golden/29_qr_barcode_invoice.pdf | Bin 16451 -> 16498 bytes pdf/font/standard14.go | 106 +++++++++ pdf/font/standard14_data.go | 221 ++++++++++++++++++ pdf/font/standard14_test.go | 140 +++++++++++ pdf/writer.go | 2 +- template/builder.go | 5 +- template/fontresolver.go | 46 +++- template/fontresolver_test.go | 89 +++++++ template/template_test.go | 32 +-- 30 files changed, 615 insertions(+), 26 deletions(-) create mode 100644 pdf/font/standard14.go create mode 100644 pdf/font/standard14_data.go create mode 100644 pdf/font/standard14_test.go create mode 100644 template/fontresolver_test.go diff --git a/_examples/component/testdata/golden/01_invoice.pdf b/_examples/component/testdata/golden/01_invoice.pdf index e3ff194c3e9f08fd6998fbaea6b84ff3469498a0..d25cbcdfe59f918d7381a91181ef5e12d7817396 100644 GIT binary patch delta 536 zcmZWmy)Oho9L?O`EjzyUkh^nx+)7+X$Ygfr=1U@12+EVVMkRtyX#5=_`9-VLP+%La z@>E(SjfAKrLYPJF4zHTuoA=(#yg9DD)ehcMrh5KyfLv~fHUq(}rIRK!VsOKXQe%I+<|szBh{8svw-T zHMjTyrL9E9SG|+S;e^AuPhhPFL%IJkV`r(Yc?QmM9@GZzGs2#c*k|Ipe~;4ad*bod zpwnsh;-jH7`sK%PJN(-AL*4gcoV!78M2Y%(3WtHhbH^P#U?Wd|G8)CZpNwWP08`8+ zIj|i>eng^v^H{|2?f> PwLFgvXSh;Xs4wDAD9Vdx delta 440 zcmexkzs!EZUnT>KjsGVwvYDFbnOc}np30=hW@Z3po@6p-g$N2T+d^2IgP3D6uHXjw{ zWdv(6o6IZv3$BH0vy<3)kQR^$KrNHKB;G>p$&mcQXtX&|N}Q3&#A0%-bebB-Yi5>k zpkQKRsAp_yZmJNH!le;hnpBcsl9*#PnO8#-*z2>hHz{0G@MnCjbBd diff --git a/_examples/component/testdata/golden/02_report.pdf b/_examples/component/testdata/golden/02_report.pdf index ed76e6772b5431cbed63b69c1cc9fe490ddfb22c..cbea42efd8e7cf93d615eaa6bcb1fcb5c789c3a6 100644 GIT binary patch delta 573 zcmZWmO-ma=7-lxRG0xVIXqsqEWQ#5;=rB7oJG*-k@%x~(wBp$h)SJQh0se=gh8K~Z zdeE~H=H|g4PGtcwB&+{IC{xZ9Nl86Ma)~d;X5h_EN zLVq%n@JQ0%{6V zG!dfiJJY8+0Br*_3s!pW6M^pk7>L?+w^5VO3yi2xcJBo$h#++0RXS@eIKFt4MIQX? Zn<;oAdiBOd2y^{o*k=KD-SH0-_yMHRpEv*j delta 487 zcmeAPSrR;99kYe8<;0B>+04xJOfAeOdobQ+G1W6OnQYAD%xYl?Vzo25vl!}GSZ=<| z1CnKA^(2I70!+Y5fEGo%MEh^5)EKo>FEh$e;&6}La zp)^^HRcx{Ys~MxhFdC?u!mJ0vFOrs^f<=PKkEfo-zm6rcQz(_PTmLeIj?+|<+n3QQ;4aTy6( z80uM?m|MUE4U8u@av4o7;1b(>l4}j4xPh^y0$5HVkCzLg$jHLNd~y$uueiC1DTbV- ViOJ*-JkDGO78YEps;>TSTmbc{SAYNj delta 378 zcmZ9GJxjwt7{_TfZF9Cm!8R%gM@vxD9?9jBOTeL^)uD(Weu1<(Y=o!gqha(C2gnSTB*XWQd;f% zG6{u@JKa8RiDW25h@q}HlY*yR3SaKwp}-w~AaK-*g&c?g!<$0jBU^#}Oc_2EJ%!;c za|iDkcRA?@40`r;BC1fzl?s9AMPZysKbe9%IX+D`DJP7Yo^AXd!{-6o4n*9&2+53H zQ44UNYfffV;6~NvkJr@0sj)>lC9Z2(3*MRWZ4uS|c2AIY)Wea)@Sq+SDI?6`_M*z+ zTcsdT85*cov@K#X?*8pC`1lHqQkrQnJo9i^K=Y7#Hic&XL~~8!e`Xnm2T%C}WZD+e J^~UKD`T-f|Y83zg diff --git a/_examples/gotemplate/testdata/golden/00_from_template.pdf b/_examples/gotemplate/testdata/golden/00_from_template.pdf index ee2e74704a7d72f2a2c6a2b9d292f23447e36e9d..a2968120d028e18328140bb52bd571023bcb3c99 100644 GIT binary patch delta 214 zcmZ1?)F?DzJ(G#y#*Ma&EDA=Zs*?j*6ejaAiEY+kGGXLYC@w81$S*3<0}IzM^KG8Y zY{bZ`P*9YgUX+-dn^=;W43tuw{ES6(G6$Vf%LLM&{m|SsGiInJR>&aA_2m z78K+cmFOvKp3H0nQlL2bGm9v1K~a8sQDSaxVo7GQ!ekCsXOIk!p{bs+sX0XBWFOWn z7IPClqshBijVIT!@lIxD^8i_?U}9{hXJ~F_3{h8-np=>QSdv;Cqp72iR$5i1u(_LU yJ)@YZse%CrDCF^Sff)vdmPRI%133J|EQ~OvEDR@a;c(_MG%(>(Rdw}u;{pIisXT4~ diff --git a/_examples/gotemplate/testdata/golden/00_invoice.pdf b/_examples/gotemplate/testdata/golden/00_invoice.pdf index 3cf45d05165d68176100c880fb597ba53e31180b..3fc4552ffc839120d2ce54623272c72bd0cbc0b4 100644 GIT binary patch delta 413 zcmZXQy-EX75QVu(c9ZNH+>Hr|L@-UjLdW~R_bMU?+S=Ls00v*cTCq~*2?Q&_Y8xvX zAHc?5L{u^s$R3F-7_Dt4jjQ=J zk8w~u?dHp*0F-fz!xj#TF&^ZKcva~6oC2jX!*|}7r?hio6fTU7Z~mt*6$m~yrGsLh zc!>pJ+{`j=mP)u7^gRJWnluiV$f(ql=4~m!!bw7uj?-t8hGEWU>ScPhP+) zIQc$MqYc+5pqvbte?XXXat8P3$&UOGzP=FW|q}AOsaVeS!<7wH?x#$`=s# z73@S1-$BG8U}NvzAwh7e@0w7`?b$m3LSW4{{)yzuP$`~rCz;$gI4 z`_Usnh`r`IF^neIUEB0zj_h6Yy+mRfC)gK(9k*7%OBhuOd+h+nt*ZTOFG7i`6o;*j zjp&mjj5`uwCaUM#a(^MISeZ5p97Oh)zPVHK?z1-e_vP`KG%8qv4pl*1` j@(HizD+S;(l^nC(+qx3}PMT?4?jHCFt3=V>(SGFz>X>2C delta 363 zcmexR_qJ|Bq%xDa<>YAPsjL=yrWWR#zbacYPClq6IoZLSUD(XhRL{)F*xXbhB!x>O zIX@*;VRNGDCXfP!$yw@r?1?#<>3K1lFb%Ax=4N^ZlO5ExSwM`<`RY%Z8I2}0no4gj z(UxOmGByLTCzogoPWG@8o_t;BFPvXtB)nN%ZzCgEK;BRgXg|j!ga1rM24L;-h8m2F zCUD8!<|3PWj8-!;LJWke-&|nAfl#Hod9NuOf-5)~q>01)5Kw56smx@U9u`YsMu;1M zLaLi%tUyL=&a~EKWHmFWg;pRX_Hx^byJ!5mT$-mg7CvRrm%wnQvU^4k9n+%X6Ie9kQGFCGq zJ#&ai9Q$fk3q3OWo#pXapHx@>t$@y$1lP|E&Vl)7^8 zBddX)sfGFG1di#9Vy1=)1|Xo2$IAs~7#JCwnoZ{ADit@hG{KNFF*Ke$lgpLUz<^6t J)z#mP3jjGA9r^$O diff --git a/_examples/testdata/golden/03_grid_layout.pdf b/_examples/testdata/golden/03_grid_layout.pdf index b92ea6f7afe590dfb0a6459c4fbfc6ea7d55bf2c..63a1555eb5d9e779c52447978617099d6aed6cfc 100644 GIT binary patch delta 353 zcmbQEd0unEdL~QDjTCooD+Hei*W%*^zT)znna*kp2{fbQfE0-TdI zn2)fT0Tr2>K@|y3_7`HGtjO|-&B#K}zyPdwvjM9pBN88Imf+@MHa>K*$?Ms_ASv5? zfI|R9i2$GKWM*!~$Xjm_X#gMz3cV-n>yjiCMzbz{E(w u00b2Bc)7p~0~1RV^U1G;eZ?&dOwr{`4Gc^s`-`}6S{ibxs=E5SaRC6In^>;^ delta 262 zcmX@FIY)ECdL|Q#jTQyb(oyQ_Zh+jIf`@h1b#tACJTrokFg7Hej=d6EMa14X{cZT0t$J&TwsQQ ii3QLAIT2rRb5j!xIZIRH$xR|IoF)cbs;aL3Zd?Fdr%l!X diff --git a/_examples/testdata/golden/04_fixed_height_row.pdf b/_examples/testdata/golden/04_fixed_height_row.pdf index 430b33a6e3773cbe12263a27c5b0e08bd7449f22..7def705987f28f140d0c2adca269f3d7607cfa3a 100644 GIT binary patch delta 126 zcmZqYpT<979kY?4$;ORrjI4%6dS(`zcQPt5vI1F_Mw8DmD^32#bb$pZY_jwL(0t1aGncu68JDW6 ItG^o;0CD6W8vpJ9C*?8gi+sy863u0RR-F9IF5T diff --git a/_examples/testdata/golden/08_table_styled.pdf b/_examples/testdata/golden/08_table_styled.pdf index 8d484a9a5187d5aedd49a68d92875e1769bcd277..2220ca1dc72b4f702564762c7ec0caa93439200a 100644 GIT binary patch delta 403 zcmbPeanEAHI%ZR2lZ_iC7+Fm9jEyHZipxy?$0RuU8{=y>QzJbC1B=Ol9MV9cpR7g( zKAK8p7^vuj)S_C&ovV8}eYqB|jLyD2bL=P;dGkJ~} z=i~*PcUg?}j7@+h>Q3etVV}H_>o&8VnI+IX-N}0F+>;M;-(>|VFtgaakjIaS*+9>7 z@u>vww!!K+KJoTz{reCRn^tsjSB$0KXUQ_ delta 311 zcmca-G0|eeI%X47^NkxN7#WQwJF@9c{>}J`(PXj%kR{Iig3%DnGGuwqWM%-A)|u?Z z`jN@lV6!8eG80hQMlqeqTD)A7=W{$}Gz0QXC-3IG3p5BMzK)k;@%5^5+66Ts5$bX;745Zs^a<;%7s9yHTb%HmcdTw)bO`arlA8Mbh6!+$} z!p|6)Ode#BFA2V#%ed>gw;t1pp)*V`%^Y diff --git a/_examples/testdata/golden/12_multi_page.pdf b/_examples/testdata/golden/12_multi_page.pdf index b5368204f0230fbe3c8f51ae4530fdb2bc997192..d675ed91afd63afc4795717bcd153c5f93188c61 100644 GIT binary patch literal 13098 zcmeI3OK;ma5PFc$5qkR?~57Tx~(9ZI%B zJAI_9jRmw60y`d%LykBUKMjXxS1)Gg)Q`Oo2vj#8y{AthxR8ySRi%dDl`8&LI$z53 zi>k;VxNdLsKU)RC0zYa01wmK#*)va-nNh*Nuj-y!gr((;D%bi0FcznP?KHYpa^n$L zdlw5Ym{DK^7AsFgew2m~N5ZFR42#TreAyN{KfjV|1uv>)yHTZn0*jB{%Z2yJBYuJ^ z_O*EA6Bfn-ctZUo+YkfF?1qT&qc~=wYwAr^s}1DarrkhR6;%yQu0d{; z{sWd(*{G#fx~)NG`8IEsdASBvFO|gM9&|`+g|c$aN_}7Wt=E`g{AosVs?()bgRx=J8lqE68Mn#v@#jhiCM7t8hg(D+o__SC0hK znTJ#3KRibaR_%yq2UwAXM$eJc5NaAQyfla4Qf0Zks6N9xf&!7^z$TDzLp1LpxRNy% z2G*CItEOt}rE0)XUq^A>KNJiNqOutS19NkPF7ww>pHYG|b&Gp3me`Y{12sdI-y|ys zYWKiT2WlsHWpJSOV^0(g)E?dwg#)#pcsF;Tc8Y)s|Gz*jvCwD$HN#7jfLdzx#(|mx zwXtzR2Wk($JVp-G9H_a7+QUyY7g4*%))4M>&)f}t2M72{8s7qA)%1pbPs7CFh07F=f?y}2HpQ?6nCk->7|1;i|5(cByK3yxwN3Cw4K5T1^67vroe7 zwLX_x7S-CS@VQni%p{|cx6e5x1R^0q0jo#7jIxMhjM7|C4l=>emIImjaa;~X3674r zhPs4MaYx2@f)*Z@V-dq$>_Zt(1>U_wnV_isNEY)Dqnn52ViDhIH%-a09jHwl>!U18 zaGVbG;e9bfxR>c#=7p-eqb;w?RR!IU;mg9jsx+SR=z_f0`4@#Z+YCUXWv#p0kBZX7 LJ3D*%*UbADs1wuc literal 13098 zcmeI3&2HN`5PHHyA&ag=6>XosL&;WX zXR*6ZEE^~&1a?eNLk>9-Kh6vA>OJG)bmmk|w%d4AwDd4r5w<}TUXE6KXy_tDmz0gll z#P%*0`yq`I4lE^pnlT_y3vc25dEXEPLhXh^?#D?&dDqkjRf`qKbjo&S8lmv+U)V+VL#3tl_kC#_=h*x0j=86%)%EPOKG6{=r_3s$E1a zYdEoXB(?12s9K?och<0Z&cbuN@>!Z$hI35ySi-HvtCM?@r zJrbN)J$xRX_8u`nN`Khk&6$6gHt?U~Y=gW&S$qGeVH2?r|@{8e4L7pk~Hw z2NE5q9f+R})W+H}I8ZxiBg7r3jbw@3f!gEt<_^?GbEvQpKrOY^%FW!8)HlBON1wRkk6aBuZW`dUwP^}}T^RSwoIpIiE5l%?Xcaxi+_&gcXIL1N9HEG_~&?lJwp7G*hc{dw;I`Gmz_RU zZ5JI)wmgU6y(}8|Y;x+RU;?&N=)8gW)u+jIN=z|%&YXGZv)>vk{f*cM6>e%(v~y9z z<#Jstp4rtbYBdRT%{B?Im-;H#d8wAx4c}|Az>^0g_O>~vVF)qj5r@SeeHo!KLm#EN zpc=|JMO}8v%#ZzgSd`-Im}|H<3<-ZwMp=p)?$)C*#VGb}8Ou11-fkHuD1EOiVG(*a zck3lQdC+c_h5LG-G`=s7&?v=u+L4FjVy19grt4gmqVA5iysH)px+%lW!c-}Zr#w0* c@2&hPaInn;G&-+!H~c6lOTCMWH=icnFKgt}>i_@% diff --git a/_examples/testdata/golden/13_header_footer.pdf b/_examples/testdata/golden/13_header_footer.pdf index 68d72d6f802b516fb3edf3c9ad0d3b63f2ddbdda..2a56f9cfc0a93397370faa3ccd774c5ca238d429 100644 GIT binary patch delta 403 zcmeB?St~PPEwhDz*~AUef~H1#mX>CgaA3AskkOQpQz1D&uOu-uuXyr)CdJ9}Ts)I& zn4Gy4GV{{%6%zAO6cT~rx|7#)tz$9MGd7u=$ZbFQ1@l7c8+wGDx`u8}BJ4uY$nEfrWWR#Wf)BvCp)s~awg~Jl_X~76)Q~6V{-1VNmzJ-Pn3tlEs4)2g*E$vhpr*;Q+3hEPW?o1YlO9mT6#LBwxq}(Sj4TukKtLgn zmkZ1=Fg7%_oUFy`mj)C>lQJ|kF~ShDw7@XJ$j}%=uaTv>8MS5^s diff --git a/_examples/testdata/golden/16_margins.pdf b/_examples/testdata/golden/16_margins.pdf index 276c761c6c14df17a881dcebf7598f9796b786f0..b42cd91944aa29d43a2c409b64337b234278d85d 100644 GIT binary patch delta 164 zcmey!y_9xoSE+R(&sG8e0#n58AAl%>VwJXRM@3sWvt JRabvEE&w@rGqL~x diff --git a/_examples/testdata/golden/17_colors.pdf b/_examples/testdata/golden/17_colors.pdf index fef0eaf13cee40965f3d6eb4044bd543831f77de..cbbfa9a27e4e3a32f11b396b78514476f88fb62a 100644 GIT binary patch delta 204 zcmew%*(Wt&9kZFC$;OQl?5su>dS(`r?YN{Tt8wVFDg>pbC`^vv=w&rA&@(WCiVIE_ z=G5X-a4$+t%~Q}+2+87d4VgTVQ*ClSrw5y{0nh*wxXP!Ta%>7tIi;xzli9f{*-VV| z3=ARW%;hrVP>4*;$;mHQ*nEX6mQl>qT)_YY6!LhvzzhQ;OEaU%?mWKY7Dfi>a>fRR VmXlZWICB{qm~pA9y863u0RU0QGBW@G delta 174 zcmeB^`XM=C9kZ#W(Z-Dt>`aChlN&juCv$NMPGVx8?8qU`9F&?eIhLcB$=CoYW6kPb zl$x41*@v@~$;bqzeDY^bIToj!($vY~T$N15W?=D+T!w6ssX00M<(pq|#WIQ+n<^N9 rfI=QG7nosSWNBhBIhMy)+}zRvQ_j+S@%@me>W}wv*|5) diff --git a/_examples/testdata/golden/18_invoice.pdf b/_examples/testdata/golden/18_invoice.pdf index 1281f7c17b9579af05dadddb11e156956e7fd634..cc9af4a7d1d338f9eb4657c8c9ccf5278308460c 100644 GIT binary patch delta 531 zcmZXQyGz4R6vj!?o3_!~)(5psutBZk<>n^$QBW#~I@a0EU8LaX;$xTMK_~qK93_8& zZjOS3;8Jl@#HDnqk0fmq#4}yK^PS&0_q%HJ8tuMDrM&rRD>`+t#~e6UvCagh4ugAD z6zW)VUXP0d(pC`1%ys-t?OM|?ao|w5pqVYgrXq;E&j35QBr{=azH0vgo2uei}5aUvFphQ6{u%p2HfV}LXi)|)#(bnOuUAQ z7~ng3q^2ky?jNDblhIUy0x}iBW6oE?2o9+D`!ml9atIcK{|!O@NhzcJJt`m>{@pB! zDS6T&urAy)%C4&QVUbStwG?xl4c4U&V^x(l<40~O3w@8AfkjWs2mQ_V| zSa51>hgoyN{SWI=DM=x05|+J}Gol9(B1rjZZl}Zq{oj)gY*p8cI^{;Kw%*(@egS5o Bj{yJx delta 419 zcmexnKi7W3A7(>K%Z>jhFtV8#=$TrWPoB!8$7Tj$o@6p-H316>Fxx^{n}e8FGEXk# z(wS_)!amuE{WYWESByrJ8+mjl|KoeZXaZtwmKONJ$Y=m$8c%i;dIeR=IXO%CHC!p@BB58Dz`Uli_N=kZif^;aq1<*HUmT)ln zsI=1LSJJ8&;_@;olig&p^$aahH7FQc8ta)DT38yufSE!_3YSJmeo11E)#PU~QLKi> zdS)h@-DIyYikn+lD1g-|APZT$?FvgU#G@86p-kHn7*qlpM)z#mP F3ji69bB+K2 diff --git a/_examples/testdata/golden/19_report.pdf b/_examples/testdata/golden/19_report.pdf index 634f2d0db87dbf990dbcdd18e0504aeb87a42d21..3cc0decac73b2b9ea579c2e071e450060edfbcbd 100644 GIT binary patch delta 801 zcmZWnJ4_To7|!hDIQQOg2gq?cJPr_!VR!bO7y}V31tTQdXinIRm%uG9M z|Hc-HcE*_LObpgmS{Q?|k!WdRVkL!fZ%-D)sb;?UzwiJ4$ImODuReIeY3jq5cSVip zI)w?elQxKI8*+FkoZ!x8Y=x+n7J{J2|?)thtaor$>ZWwrWK^lTL;&>aLz3$+7cgc@j!=AHLuGT;q>1qhYHA?7yFq?=x z`_Fcs5q$#_v@u4#jQdqJp7d~J*|10Fec6*GicN<95sqbvRrme39 z6w!QN^uTNDNrXg;7*+tWG5i{drc4a`ko73*?d8HuY^JXT@qWc&#CI&Q`~8oQNPX>Q zznXqyaCzX9sLPF_8_k&+{4JP7ZFZo+x;50oi+E+eya=g|0z4b(hPNa57+!EHj^{ZG z{^XAXUn;<<(#5|c+}bE{9X8c)u!*>qkb;Y%m{ftt-4L26k~5gS{A! zMr&f7s~0p)Og-4<9sCPC8sn`ddNN*WJbCouvLvLb?=-}h$T%(tySn=jrAioSRB zOe|)LdI>MVSAQ|9skkIu7WJ|l@khcs;JPn?e|@3g{Rf8KVK%k4DUSNDc)aVE$n72K zbZpDKLvh!|iKRrS?7H)1y-2HdlEyFo2vD37pSrmueiDOW_ONZ3PQx;3!|YfTkHt#g zhifC_(du1BYgWq@>``z6ANm;yA$~)ZjjeLP0O9`eV50^CjenyU z@GzXgC(#IhJK+a@7*2yw(C{cMb23Q;K~b?M$ygBsd@T|L@3I<;@>fv+-jh-QIgiDs z2#4uo0Bxub&xAZZUyVuqpo06eKTpX&#!CPyhxyE~=MAvkZO08VKO8x<2 ClDkj< diff --git a/_examples/testdata/golden/21_facade.pdf b/_examples/testdata/golden/21_facade.pdf index 0b6c646ed6700be1a6af518c822a379996f1cbbf..372386e596cf90ca2edaf9d4d80b41a7f5f4dbec 100644 GIT binary patch delta 98 zcmX@a(aSMmEwhoi(Zmg_*%S;-3QF`QzEhlB!^pe&2;+7}adT5+1p^RJ$m8V#GYkyO h3@s;5V)hj`F*nDQGdG+3joF#gz=%s#)z#mP3jlrK8B_oO delta 110 zcmeC>IK(kwEwiza>BJ4ICnqxMO^#ty6*RN7)H5+MHaD?^g3Vowdlk=3wI8tj(mr$gYr>l9HL14wuMg=9}EWJd;HsF|QIXXvxC6*^4EX F2>|!3Bq;y@ delta 133 zcmZ3^yPS7J8sp?ZCdtX`nAs;!VHD#l$yX@JNL47xOwTCMQJB1n(GDujFVuuNfQJ41qGn=96ogdfChj^b8Hn zCkrs!z?ebIAK45H^-PRRCx@^!vzq7`nVWBZ&XU3?ZenSyU;qLNdAwX;hJm56rN!hV cHa{^_GYlycQ`5=E*jzYGjJQ-)UH#p-0QVavi2wiq delta 192 zcmeywJDG37dL|3wjT^-n1&vJf%q)#8;lN_@Mn>VuuNfN!O)Su5ikN!Y%#DDe#*PgI{kfzk_p=L5?q_44Y|C_;)x=EC046H2*_T}dI)z#mP F3jk^aBQ*d3 diff --git a/_examples/testdata/golden/26_page_number.pdf b/_examples/testdata/golden/26_page_number.pdf index 9fa5b04087e42249a0c81b3dad7553e7aee7eec5..e7148957d001bad6123ced49cdd3e16091b474dd 100644 GIT binary patch delta 523 zcmX@Da$IG?dPa+h8=_duP4rAR^D`wn^=;W zoS2hSsZd&wl30?O0+d#mT+byw`4iJ2HVacdBV*Iah0NlUFEa0AG=rP?o<*Kw6W6ov zV>E%8IGLYu_T&uCCQ6;z&3ypjOt7JDye$+Ox;dMF0i&p)f&mC9n7XSs;kevVk delta 522 zcmX@Ea$04=dPd8M8=_b(O!Q1GHuEtmGENTUQ<%)p#mkYHm!eRTk*YA+k<9@pXf9e> zlAoJcl9`;ClT)csT9A@hlA5A0`2*8oHcOCh^T~zW;*&2h?_)HFn>d+|arWd6))op4 zUCX|Y(G=CtRL&-f484x-N+(_lUAZ}(e*vSYse%CrDCF^Sff)uShK3fCWd!}}P0(cw z4Nc52#4If_)EOBXW2iH-v@k-~YiwwQVY{)VIn+EpWG7jgPW~b2&SAo(s_N?R#svWX Cu8>s# diff --git a/_examples/testdata/golden/29_qr_barcode_invoice.pdf b/_examples/testdata/golden/29_qr_barcode_invoice.pdf index 0496a3f2d516b0cf96653322f5fc2cd7e64c58dd..3bb99334b19091857e0669e0cb5deaf57e8d1e37 100644 GIT binary patch delta 304 zcmZ9Gtxf|$6ooUeOT&aEEP@J}P(g8fx$}2B(=BN#07(d*J^?%esYRf!<0%LuNox8O z2!bI21SI@O1l@`y;iz-IbHtgB#phVuycUT?<>Om%yRa6(6NOsDXS+?lLr4jnQSlvD zL;|QZANx(7xIFG?DN>Dq$REAd9G#j~v7`eS_cyz?4Ut+0Mpv^qaW7&K$j?H-BW&<} z_bD-8EGyIEtjzhUl>F_VD@ZlGAj#{!$8-j}R9%Q!xh-;oV70DKd&9&27LWQ54Xc0@ sx;WhX5gRtNu~PdylXIh72!>AvmralMe@Ez$zXpfP!OKo(|L8#e0`HAgIRF3v delta 238 zcmey=z<9WUal=bp7Gq0u!_9AXH!-oA=$TrWPhM}}3Sn*jX~4rc+0j&ba)PnoW>X_B zMkYfG5Px%mu{qL9z(fMbHiN5sWqO@)@ ? + 556, 556, 278, 278, 584, 584, 584, 556, + // 0x40..0x47: @ A B C D E F G + 1015, 667, 667, 722, 722, 667, 611, 778, + // 0x48..0x4F: H I J K L M N O + 722, 278, 500, 667, 556, 833, 722, 778, + // 0x50..0x57: P Q R S T U V W + 667, 778, 722, 667, 611, 722, 667, 944, + // 0x58..0x5F: X Y Z [ \ ] ^ _ + 667, 667, 611, 278, 278, 278, 469, 556, + // 0x60..0x67: ` a b c d e f g + 333, 556, 556, 500, 556, 556, 278, 556, + // 0x68..0x6F: h i j k l m n o + 556, 222, 222, 500, 222, 833, 556, 556, + // 0x70..0x77: p q r s t u v w + 556, 556, 333, 500, 278, 556, 500, 722, + // 0x78..0x7E: x y z { | } ~ + 500, 500, 500, 334, 260, 334, 584, +} + +var helveticaBoldASCII = [95]uint16{ + 278, 333, 474, 556, 556, 889, 722, 238, + 333, 333, 389, 584, 278, 333, 278, 278, + 556, 556, 556, 556, 556, 556, 556, 556, + 556, 556, 333, 333, 584, 584, 584, 611, + 975, 722, 722, 722, 722, 667, 611, 778, + 722, 278, 556, 722, 611, 833, 722, 778, + 667, 778, 722, 667, 611, 722, 667, 944, + 667, 667, 611, 333, 278, 333, 584, 556, + 333, 556, 611, 556, 611, 556, 333, 611, + 611, 278, 278, 556, 278, 889, 611, 611, + 611, 611, 389, 556, 333, 611, 556, 778, + 556, 556, 500, 389, 280, 389, 584, +} + +var timesRomanASCII = [95]uint16{ + 250, 333, 408, 500, 500, 833, 778, 180, + 333, 333, 500, 564, 250, 333, 250, 278, + 500, 500, 500, 500, 500, 500, 500, 500, + 500, 500, 278, 278, 564, 564, 564, 444, + 921, 722, 667, 667, 722, 611, 556, 722, + 722, 333, 389, 722, 611, 889, 722, 722, + 556, 722, 667, 556, 611, 722, 722, 944, + 722, 722, 611, 333, 278, 333, 469, 500, + 333, 444, 500, 444, 500, 444, 333, 500, + 500, 278, 278, 500, 278, 778, 500, 500, + 500, 500, 333, 389, 278, 500, 500, 722, + 500, 500, 444, 480, 200, 480, 541, +} + +var timesBoldASCII = [95]uint16{ + 250, 333, 555, 500, 500, 1000, 833, 278, + 333, 333, 500, 570, 250, 333, 250, 278, + 500, 500, 500, 500, 500, 500, 500, 500, + 500, 500, 333, 333, 570, 570, 570, 500, + 930, 722, 667, 722, 722, 667, 611, 778, + 778, 389, 500, 778, 667, 944, 722, 778, + 611, 778, 722, 556, 667, 722, 722, 1000, + 722, 722, 667, 333, 278, 333, 581, 500, + 333, 500, 556, 444, 556, 444, 333, 500, + 556, 278, 333, 556, 278, 833, 556, 500, + 556, 556, 444, 389, 333, 556, 500, 722, + 500, 500, 444, 394, 220, 394, 520, +} + +var timesItalicASCII = [95]uint16{ + 250, 333, 420, 500, 500, 833, 778, 214, + 333, 333, 500, 675, 250, 333, 250, 278, + 500, 500, 500, 500, 500, 500, 500, 500, + 500, 500, 333, 333, 675, 675, 675, 500, + 920, 611, 611, 667, 722, 611, 611, 722, + 722, 333, 444, 667, 556, 833, 667, 722, + 611, 722, 611, 500, 556, 722, 611, 833, + 611, 556, 556, 389, 278, 389, 422, 500, + 333, 500, 500, 444, 500, 444, 278, 500, + 500, 278, 278, 444, 278, 722, 500, 500, + 500, 500, 389, 389, 278, 500, 444, 667, + 444, 444, 389, 400, 275, 400, 541, +} + +var timesBoldItalicASCII = [95]uint16{ + 250, 389, 555, 500, 500, 833, 778, 278, + 333, 333, 500, 570, 250, 333, 250, 278, + 500, 500, 500, 500, 500, 500, 500, 500, + 500, 500, 333, 333, 570, 570, 570, 500, + 832, 667, 667, 667, 722, 667, 667, 722, + 778, 389, 500, 667, 611, 889, 722, 722, + 611, 722, 667, 556, 611, 722, 667, 889, + 667, 611, 611, 333, 278, 333, 570, 500, + 333, 500, 500, 444, 500, 444, 333, 500, + 556, 278, 278, 500, 278, 778, 556, 500, + 500, 500, 389, 389, 278, 556, 444, 667, + 500, 444, 389, 348, 220, 348, 570, +} + +// Symbol and ZapfDingbats use specialized encodings that don't map to +// printable ASCII in the usual way. For layout measurement we return a +// plausible average width — users generally don't set body text in these +// fonts, and any mismatch only affects alignment within those contexts. +var symbolFallbackWidth = uint16(500) +var zapfDingbatsFallbackWidth = uint16(788) + +// standard14Data holds per-font metrics and ASCII width tables. +// Helvetica-Oblique / Helvetica-BoldOblique share widths with their upright +// counterparts (obliquing does not alter advance widths). Courier-* are all +// 600 em units wide (monospace). +var standard14Data = map[string]standard14FontEntry{ + Helvetica: { + metrics: Metrics{UnitsPerEm: 1000, Ascender: 718, Descender: -207, CapHeight: 718, XHeight: 523, LineGap: 0}, + widths: printableASCIIWidths(helveticaASCII), + }, + HelveticaBold: { + metrics: Metrics{UnitsPerEm: 1000, Ascender: 718, Descender: -207, CapHeight: 718, XHeight: 532, LineGap: 0}, + widths: printableASCIIWidths(helveticaBoldASCII), + }, + HelveticaOblique: { + metrics: Metrics{UnitsPerEm: 1000, Ascender: 718, Descender: -207, CapHeight: 718, XHeight: 523, LineGap: 0, ItalicAngle: -12}, + widths: printableASCIIWidths(helveticaASCII), + }, + HelveticaBoldOblique: { + metrics: Metrics{UnitsPerEm: 1000, Ascender: 718, Descender: -207, CapHeight: 718, XHeight: 532, LineGap: 0, ItalicAngle: -12}, + widths: printableASCIIWidths(helveticaBoldASCII), + }, + TimesRoman: { + metrics: Metrics{UnitsPerEm: 1000, Ascender: 683, Descender: -217, CapHeight: 662, XHeight: 450, LineGap: 0}, + widths: printableASCIIWidths(timesRomanASCII), + }, + TimesBold: { + metrics: Metrics{UnitsPerEm: 1000, Ascender: 683, Descender: -217, CapHeight: 676, XHeight: 461, LineGap: 0}, + widths: printableASCIIWidths(timesBoldASCII), + }, + TimesItalic: { + metrics: Metrics{UnitsPerEm: 1000, Ascender: 683, Descender: -217, CapHeight: 653, XHeight: 441, LineGap: 0, ItalicAngle: -15.5}, + widths: printableASCIIWidths(timesItalicASCII), + }, + TimesBoldItalic: { + metrics: Metrics{UnitsPerEm: 1000, Ascender: 683, Descender: -217, CapHeight: 669, XHeight: 462, LineGap: 0, ItalicAngle: -15}, + widths: printableASCIIWidths(timesBoldItalicASCII), + }, + Courier: { + metrics: Metrics{UnitsPerEm: 1000, Ascender: 629, Descender: -157, CapHeight: 562, XHeight: 426, LineGap: 0}, + widths: printableASCIIWidths(monospaceWidths(600)), + }, + CourierBold: { + metrics: Metrics{UnitsPerEm: 1000, Ascender: 629, Descender: -157, CapHeight: 562, XHeight: 439, LineGap: 0}, + widths: printableASCIIWidths(monospaceWidths(600)), + }, + CourierOblique: { + metrics: Metrics{UnitsPerEm: 1000, Ascender: 629, Descender: -157, CapHeight: 562, XHeight: 426, LineGap: 0, ItalicAngle: -12}, + widths: printableASCIIWidths(monospaceWidths(600)), + }, + CourierBoldOblique: { + metrics: Metrics{UnitsPerEm: 1000, Ascender: 629, Descender: -157, CapHeight: 562, XHeight: 439, LineGap: 0, ItalicAngle: -12}, + widths: printableASCIIWidths(monospaceWidths(600)), + }, + Symbol: { + metrics: Metrics{UnitsPerEm: 1000, Ascender: 1010, Descender: -293, CapHeight: 729, XHeight: 525, LineGap: 0}, + widths: printableASCIIWidths(monospaceWidths(symbolFallbackWidth)), + }, + ZapfDingbats: { + metrics: Metrics{UnitsPerEm: 1000, Ascender: 820, Descender: -143, CapHeight: 820, XHeight: 456, LineGap: 0}, + widths: printableASCIIWidths(monospaceWidths(zapfDingbatsFallbackWidth)), + }, +} + +// Variant aliases: template.buildFontVariantID emits generic "-Italic" +// suffixes regardless of family, but the Adobe Core 14 canonical names use +// "-Oblique" for Helvetica/Courier (and "-Roman" as the base form of +// Times). Map those variants so layout lookups find the same metrics the +// PDF renderer writes out via base14Variants in pdftarget.go. +func init() { + for alias, canonical := range map[string]string{ + "Helvetica-Italic": HelveticaOblique, + "Helvetica-BoldItalic": HelveticaBoldOblique, + "Courier-Italic": CourierOblique, + "Courier-BoldItalic": CourierBoldOblique, + "Times": TimesRoman, + } { + if _, exists := standard14Data[alias]; !exists { + standard14Data[alias] = standard14Data[canonical] + } + } +} diff --git a/pdf/font/standard14_test.go b/pdf/font/standard14_test.go new file mode 100644 index 0000000..30b72d2 --- /dev/null +++ b/pdf/font/standard14_test.go @@ -0,0 +1,140 @@ +package font + +import "testing" + +// Widths are checked against values published in the Adobe Core 14 AFM +// files. Spot-checking across letters with distinct widths (narrow 'I', +// wide 'M', digits, punctuation) verifies the table transcription. +func TestStandard14Width_Helvetica(t *testing.T) { + cases := []struct { + r rune + want int + }{ + {' ', 278}, {'I', 278}, {'M', 833}, {'W', 944}, + {'i', 222}, {'m', 833}, {'w', 722}, + {'0', 556}, {'1', 556}, {':', 278}, {',', 278}, {'-', 333}, + } + for _, c := range cases { + got, ok := Standard14Width(Helvetica, c.r) + if !ok { + t.Fatalf("Standard14Width(Helvetica, %q) not found", c.r) + } + if got != c.want { + t.Errorf("Helvetica %q = %d, want %d", c.r, got, c.want) + } + } +} + +func TestStandard14Width_HelveticaBold(t *testing.T) { + // Spot-check individual glyph widths against Adobe AFM values. + for _, c := range []struct { + r rune + want int + }{ + {'I', 278}, {'N', 722}, {'V', 667}, {'O', 778}, {'C', 722}, {'E', 667}, + } { + got, _ := Standard14Width(HelveticaBold, c.r) + if got != c.want { + t.Errorf("HelveticaBold %q = %d, want %d", c.r, got, c.want) + } + } + // Sum for all 7 glyphs in "INVOICE" must equal 4112 em units — + // at 28pt that is 115.136pt, matching pdftotext measurement of + // the INVOICE glyph run in golden 18_invoice.pdf. + total := 0 + for _, r := range "INVOICE" { + w, _ := Standard14Width(HelveticaBold, r) + total += w + } + if total != 4112 { + t.Errorf("sum of INVOICE glyphs = %d em units, want 4112", total) + } + wantAt28 := float64(total) * 28 / 1000 + if wantAt28 < 115.13 || wantAt28 > 115.14 { + t.Errorf("INVOICE@28pt HelveticaBold = %.3f pt, want 115.136", wantAt28) + } +} + +func TestStandard14Width_CourierMonospace(t *testing.T) { + // Every printable ASCII glyph must be 600 em units in Courier. + for r := rune(0x20); r <= 0x7E; r++ { + got, ok := Standard14Width(Courier, r) + if !ok { + t.Fatalf("Courier width missing for %q", r) + } + if got != 600 { + t.Errorf("Courier %q = %d, want 600", r, got) + } + } +} + +func TestStandard14Width_UnknownFontReturnsFalse(t *testing.T) { + if _, ok := Standard14Width("NotAFont", 'A'); ok { + t.Error("Standard14Width should return false for unknown font") + } +} + +func TestStandard14Width_UnmappedRuneFallsBackToSpace(t *testing.T) { + want, _ := Standard14Width(Helvetica, ' ') + got, ok := Standard14Width(Helvetica, '\u3042') // Japanese あ — outside ASCII + if !ok { + t.Fatal("expected fallback ok=true for unmapped rune") + } + if got != want { + t.Errorf("unmapped rune width = %d, want space width %d", got, want) + } +} + +func TestIsStandard14(t *testing.T) { + for _, name := range []string{ + Helvetica, HelveticaBold, HelveticaOblique, HelveticaBoldOblique, + TimesRoman, TimesBold, TimesItalic, TimesBoldItalic, + Courier, CourierBold, CourierOblique, CourierBoldOblique, + Symbol, ZapfDingbats, + } { + if !IsStandard14(name) { + t.Errorf("IsStandard14(%q) = false, want true", name) + } + } + if IsStandard14("RandomFont") { + t.Error("IsStandard14(\"RandomFont\") should be false") + } +} + +func TestStandard14Metrics_Helvetica(t *testing.T) { + m, ok := Standard14Metrics(Helvetica) + if !ok { + t.Fatal("Helvetica metrics missing") + } + if m.UnitsPerEm != 1000 || m.Ascender != 718 || m.Descender != -207 { + t.Errorf("Helvetica metrics %+v mismatch", m) + } +} + +func TestNewStandard14Font_MeasureStringMatchesAFM(t *testing.T) { + // Exact values from pdftotext -bbox-layout applied to a PDF rendered + // by a conformant viewer against Helvetica's AFM widths. If these drift, + // right-alignment will no longer match visible text extents. + cases := []struct { + fontName string + text string + size float64 + want float64 + }{ + {HelveticaBold, "INVOICE", 28, 115.136}, + {Helvetica, "Date: March 1, 2026", 12, 108.72}, + {Helvetica, "Due: March 31, 2026", 12, 112.056}, + } + for _, c := range cases { + f, ok := NewStandard14Font(c.fontName) + if !ok { + t.Fatalf("NewStandard14Font(%q) missing", c.fontName) + } + got := MeasureString(f, c.text, c.size) + // Allow 0.01pt tolerance for floating-point arithmetic. + if got < c.want-0.01 || got > c.want+0.01 { + t.Errorf("MeasureString(%s, %q, %v) = %.3f, want %.3f", + c.fontName, c.text, c.size, got, c.want) + } + } +} diff --git a/pdf/writer.go b/pdf/writer.go index 25128c5..e41a62b 100644 --- a/pdf/writer.go +++ b/pdf/writer.go @@ -30,7 +30,7 @@ type Writer struct { compress bool closed bool - // Extension hooks for gpdf-pro features (PDF/A, encryption, signatures). + // Extension hooks for same-module features (PDF/A, encryption, signatures). catalogExtra Dict // extra entries merged into catalog dict trailerExtra Dict // extra entries merged into trailer dict onWriteObject func(ref ObjectRef, obj Object) Object // object transformation hook diff --git a/template/builder.go b/template/builder.go index 0d2c746..3f91489 100644 --- a/template/builder.go +++ b/template/builder.go @@ -72,8 +72,9 @@ func WithMetadata(info document.DocumentMetadata) Option { } // WithWriterSetup registers a function that configures the PDF Writer -// before rendering begins. This is used by extensions (e.g., gpdf-pro) -// to set up hooks for PDF/A, encryption, or other features. +// before rendering begins. This is used by built-in extensions +// (PDF/A, encryption) and any external hook that needs to mutate +// the Writer before rendering begins. func WithWriterSetup(fn func(pw *pdf.Writer)) Option { return func(c *Config) { c.WriterSetup = fn } } diff --git a/template/fontresolver.go b/template/fontresolver.go index 2f486f4..fc6a864 100644 --- a/template/fontresolver.go +++ b/template/fontresolver.go @@ -25,6 +25,12 @@ func newBuiltinFontResolver(fonts map[string]*font.TrueTypeFont) *builtinFontRes // falls back to the base family for metrics. func (r *builtinFontResolver) Resolve(family string, weight document.FontWeight, italic bool) layout.ResolvedFont { bold := weight >= document.WeightBold + // Empty family means "the PDF renderer default" — which is Helvetica + // (see document/render/pdftarget.go:resolvePDFFontName). Mirror that + // here so layout uses the same metrics the viewer will. + if family == "" { + family = font.Helvetica + } variantID := buildFontVariantID(family, bold, italic) // Try variant-specific font first, then fall back to base family. @@ -34,7 +40,22 @@ func (r *builtinFontResolver) Resolve(family string, weight document.FontWeight, } if !ok { - // Return approximate metrics for standard fonts. + // If the requested font is one of the Adobe 14 standard fonts, use + // its AFM-derived metrics so that layout matches what PDF viewers + // render from non-embedded Type1 entries. + if m, std := font.Standard14Metrics(variantID); std { + scale := 1.0 / float64(m.UnitsPerEm) + return layout.ResolvedFont{ + ID: variantID, + Metrics: layout.FontMetrics{ + Ascender: float64(m.Ascender) * scale, + Descender: float64(m.Descender) * scale, + LineHeight: float64(m.Ascender-m.Descender+m.LineGap) * scale, + CapHeight: float64(m.CapHeight) * scale, + }, + } + } + // Return approximate metrics for non-standard unregistered fonts. return layout.ResolvedFont{ ID: variantID, Metrics: layout.FontMetrics{ @@ -75,21 +96,26 @@ func buildFontVariantID(family string, bold, italic bool) string { // MeasureString measures the width of text at the given font size. func (r *builtinFontResolver) MeasureString(f layout.ResolvedFont, text string, size float64) float64 { - ttf, ok := r.fonts[f.ID] - if !ok { - // Fallback to approximate: average char width ≈ 0.5 * fontSize. - return float64(len([]rune(text))) * size * 0.5 + if ttf, ok := r.fonts[f.ID]; ok { + return font.MeasureString(ttf, text, size) } - return font.MeasureString(ttf, text, size) + // Non-embedded Adobe 14 fonts: use AFM widths so layout matches viewer. + if std, ok := font.NewStandard14Font(f.ID); ok { + return font.MeasureString(std, text, size) + } + // Fallback to approximate: average char width ≈ 0.5 * fontSize. + return float64(len([]rune(text))) * size * 0.5 } // LineBreak splits text into lines fitting within maxWidth. func (r *builtinFontResolver) LineBreak(f layout.ResolvedFont, text string, size float64, maxWidth float64) []string { - ttf, ok := r.fonts[f.ID] - if !ok { - return approximateBreak(text, size, maxWidth) + if ttf, ok := r.fonts[f.ID]; ok { + return font.LineBreak(ttf, text, size, maxWidth) + } + if std, ok := font.NewStandard14Font(f.ID); ok { + return font.LineBreak(std, text, size, maxWidth) } - return font.LineBreak(ttf, text, size, maxWidth) + return approximateBreak(text, size, maxWidth) } // approximateBreak performs rough line breaking without font metrics. diff --git a/template/fontresolver_test.go b/template/fontresolver_test.go new file mode 100644 index 0000000..e930dc0 --- /dev/null +++ b/template/fontresolver_test.go @@ -0,0 +1,89 @@ +package template + +import ( + "math" + "testing" + + "github.com/gpdf-dev/gpdf/document" + "github.com/gpdf-dev/gpdf/pdf/font" +) + +// Without any TTF registered, MeasureString must still return AFM-accurate +// widths for Adobe 14 font names so that right/center alignment lines up +// with the glyphs PDF viewers actually draw. +func TestFontResolver_MeasureString_Standard14Fallback(t *testing.T) { + r := newBuiltinFontResolver(nil) + + cases := []struct { + family string + weight document.FontWeight + italic bool + text string + size float64 + want float64 + }{ + {"Helvetica", document.WeightBold, false, "INVOICE", 28, 115.136}, + {"Helvetica", document.WeightNormal, false, "Date: March 1, 2026", 12, 108.72}, + {"Helvetica", document.WeightNormal, false, "Due: March 31, 2026", 12, 112.056}, + } + for _, c := range cases { + f := r.Resolve(c.family, c.weight, c.italic) + got := r.MeasureString(f, c.text, c.size) + if math.Abs(got-c.want) > 0.01 { + t.Errorf("MeasureString(%s, weight=%v, %q, %v) = %.3f, want %.3f", + c.family, c.weight, c.text, c.size, got, c.want) + } + } +} + +// TTF-registered fonts continue to use the TTF measurements — Standard14 +// fallback must not override a registered font. +func TestFontResolver_MeasureString_UnknownFontFallsBackToApproximation(t *testing.T) { + r := newBuiltinFontResolver(nil) + f := r.Resolve("NonStandardFont", document.WeightNormal, false) + + got := r.MeasureString(f, "hello", 12) + want := float64(len("hello")) * 12 * 0.5 + if got != want { + t.Errorf("unknown font fallback = %.3f, want %.3f (approximation)", got, want) + } +} + +// Resolve should return AFM metrics for Standard 14 font names so line +// heights match the viewer's rendering, not the 0.8/-0.2/1.2/0.7 fallback. +func TestFontResolver_Resolve_Standard14Metrics(t *testing.T) { + r := newBuiltinFontResolver(nil) + f := r.Resolve("Helvetica", document.WeightNormal, false) + + if f.ID != font.Helvetica { + t.Fatalf("resolved font ID = %q, want %q", f.ID, font.Helvetica) + } + // Adobe Helvetica AFM: Ascender 718, Descender -207 in 1000 em units. + // Scaled to em-relative units (divide by 1000). + if math.Abs(f.Metrics.Ascender-0.718) > 0.001 { + t.Errorf("Ascender = %.4f, want 0.718", f.Metrics.Ascender) + } + if math.Abs(f.Metrics.Descender-(-0.207)) > 0.001 { + t.Errorf("Descender = %.4f, want -0.207", f.Metrics.Descender) + } +} + +// Italic Helvetica must resolve to the same metrics as Helvetica-Oblique +// (buildFontVariantID emits "-Italic"; Standard14 data aliases it). +func TestFontResolver_Resolve_HelveticaItalicAliasedToOblique(t *testing.T) { + r := newBuiltinFontResolver(nil) + f := r.Resolve("Helvetica", document.WeightNormal, true) + + // The ID reflects the generic variant name used internally, but + // metrics must come from the AFM table via the alias. + if f.Metrics.Ascender == 0.8 { + t.Error("italic Helvetica returned the 0.8 fallback Ascender instead of AFM-derived value") + } + got := r.MeasureString(f, "INVOICE", 28) + // Helvetica-Oblique shares widths with Helvetica (obliquing preserves + // advance widths): I+N+V+O+I+C+E = 278+722+667+778+278+722+667 = 4112 + // em units; at 28pt → 115.136pt. + if math.Abs(got-115.136) > 0.01 { + t.Errorf("italic Helvetica INVOICE@28 = %.3f, want 115.136 (alias miss?)", got) + } +} diff --git a/template/template_test.go b/template/template_test.go index bfd824a..8aff3c6 100644 --- a/template/template_test.go +++ b/template/template_test.go @@ -2,6 +2,7 @@ package template import ( "bytes" + "math" "strings" "testing" @@ -1523,19 +1524,23 @@ func TestApproximateBreakEmptyString(t *testing.T) { func TestBuiltinFontResolverResolveDifferentWeightsAndStyles(t *testing.T) { r := newBuiltinFontResolver(nil) - // All these should fall back since no fonts are registered. - // The returned ID should reflect the weight/style variant. + // All these families are Adobe Standard 14 — the resolver returns + // AFM-derived metrics so that layout matches how PDF viewers render + // non-embedded Type1 text. The ID carries the generic variant name + // emitted by buildFontVariantID (aliased to the canonical Adobe name + // inside the Standard14 table). tests := []struct { - family string - weight document.FontWeight - italic bool - wantID string + family string + weight document.FontWeight + italic bool + wantID string + wantAscender float64 }{ - {"Helvetica", document.WeightNormal, false, "Helvetica"}, - {"Helvetica", document.WeightBold, false, "Helvetica-Bold"}, - {"Helvetica", document.WeightNormal, true, "Helvetica-Italic"}, - {"Helvetica", document.WeightBold, true, "Helvetica-BoldItalic"}, - {"Times", document.WeightNormal, false, "Times"}, + {"Helvetica", document.WeightNormal, false, "Helvetica", 0.718}, + {"Helvetica", document.WeightBold, false, "Helvetica-Bold", 0.718}, + {"Helvetica", document.WeightNormal, true, "Helvetica-Italic", 0.718}, + {"Helvetica", document.WeightBold, true, "Helvetica-BoldItalic", 0.718}, + {"Times", document.WeightNormal, false, "Times", 0.683}, } for _, tt := range tests { @@ -1544,8 +1549,9 @@ func TestBuiltinFontResolverResolveDifferentWeightsAndStyles(t *testing.T) { t.Errorf("Resolve(%q, %v, %v) ID: got %q, want %q", tt.family, tt.weight, tt.italic, f.ID, tt.wantID) } - if f.Metrics.Ascender != 0.8 { - t.Errorf("Resolve(%q) ascender: got %v, want 0.8", tt.family, f.Metrics.Ascender) + if math.Abs(f.Metrics.Ascender-tt.wantAscender) > 0.001 { + t.Errorf("Resolve(%q) ascender: got %v, want %v", + tt.family, f.Metrics.Ascender, tt.wantAscender) } } } From 83f132b95ea20bc36592a8f504a7ef7ac2a64cc0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 18:06:29 +0000 Subject: [PATCH 2/3] chore: bump version to v1.0.5 --- internal/buildinfo/version.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/buildinfo/version.go b/internal/buildinfo/version.go index 2d2139a..7d31087 100644 --- a/internal/buildinfo/version.go +++ b/internal/buildinfo/version.go @@ -3,4 +3,4 @@ package buildinfo // Version is the library version. It is the single source of truth used by // the public gpdf.Version constant and the default PDF Producer metadata. -const Version = "1.0.4" +const Version = "1.0.5" From e9f4501abd22c8be68b3236c484357b690c9abcf Mon Sep 17 00:00:00 2001 From: Taiki Noda Date: Sun, 19 Apr 2026 03:22:16 +0900 Subject: [PATCH 3/3] fix: use Adobe Standard 14 AFM widths for alignment --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- CHANGELOG.md | 16 +++++++++++++++- _benchmark/go.mod | 2 +- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 759f67d..818636f 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -63,7 +63,7 @@ body: id: gpdf-version attributes: label: gpdf Version - placeholder: "v1.0.4" + placeholder: "v1.0.5" validations: required: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f0d8d6..7f5ba8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +## [1.0.5] - 2026-04-19 + +### Fixed +- Text alignment precision for Standard 14 fonts — right/center alignment now lands at the true container edge instead of drifting up to ~17pt for large bold text + - `template/fontresolver.go`: `MeasureString` and `LineBreak` now use Adobe Core 14 AFM advance widths when no TTF is registered, instead of the `charCount × size × 0.5` approximation + - `Resolve` now normalizes an empty `FontFamily` to `Helvetica`, matching the PDF renderer's default + - Metrics (Ascender / Descender / CapHeight) from AFM are returned for Standard 14 fonts — previously hard-coded 0.8 / -0.2 / 0.7 fallback + +### Added +- `pdf/font/standard14.go` — Adobe Standard 14 font constants, `IsStandard14`, `Standard14Metrics`, `Standard14Width`, `NewStandard14Font` +- `pdf/font/standard14_data.go` — AFM-derived width tables and metrics for Helvetica / Times / Courier / Symbol / ZapfDingbats families (14 fonts, printable ASCII coverage) +- Tests: `pdf/font/standard14_test.go`, `template/fontresolver_test.go` + ## [1.0.4] - 2026-04-07 ### Added @@ -119,7 +132,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - Reed-Solomon coefficient order in QR code encoder - binary.Write return value handling for errcheck lint -[Unreleased]: https://github.com/gpdf-dev/gpdf/compare/v1.0.4...HEAD +[Unreleased]: https://github.com/gpdf-dev/gpdf/compare/v1.0.5...HEAD +[1.0.5]: https://github.com/gpdf-dev/gpdf/compare/v1.0.4...v1.0.5 [1.0.4]: https://github.com/gpdf-dev/gpdf/compare/v1.0.3...v1.0.4 [1.0.3]: https://github.com/gpdf-dev/gpdf/compare/v1.0.2...v1.0.3 [1.0.2]: https://github.com/gpdf-dev/gpdf/compare/v1.0.1...v1.0.2 diff --git a/_benchmark/go.mod b/_benchmark/go.mod index 1aa3a38..a74235c 100644 --- a/_benchmark/go.mod +++ b/_benchmark/go.mod @@ -7,7 +7,7 @@ replace github.com/gpdf-dev/gpdf => ../ require ( github.com/go-pdf/fpdf v1.0.4 github.com/johnfercher/maroto/v2 v2.3.3 - github.com/gpdf-dev/gpdf v1.0.4 + github.com/gpdf-dev/gpdf v1.0.5 github.com/signintech/gopdf v0.36.0 golang.org/x/image v0.18.0 )