From 63330a5013aae8a4924bb72a55ea54a75f2c145a Mon Sep 17 00:00:00 2001 From: Vladimir Agafonkin Date: Mon, 4 May 2026 12:56:13 +0300 Subject: [PATCH] fix edge cases in glyph rasterization --- index.js | 9 +++++++-- test/fixtures/1-metrics.json | 10 +++++----- test/fixtures/1-out.json | 8 ++++---- test/fixtures/1-raw.png | Bin 892 -> 890 bytes test/fixtures/1-sdf.png | Bin 3382 -> 3560 bytes test/test.js | 2 +- 6 files changed, 17 insertions(+), 12 deletions(-) diff --git a/index.js b/index.js index 17e8be4..0183c06 100644 --- a/index.js +++ b/index.js @@ -66,7 +66,9 @@ export default class TinySDF { // The integer/pixel part of the alignment is encoded in metrics.glyphTop/glyphLeft // The remainder is implicitly encoded in the rasterization const glyphTop = Math.ceil(actualBoundingBoxAscent); - const glyphLeft = Math.floor(actualBoundingBoxLeft); + // actualBoundingBoxLeft is positive when ink extends LEFT of the origin (per spec), + // so negate to get the ink's left edge in canvas x-coords (positive = right of origin) + const glyphLeft = Math.floor(-actualBoundingBoxLeft); // If the glyph overflows the canvas size, it will be clipped at the bottom/right const glyphWidth = Math.max(0, Math.min(this.size - this.buffer, Math.ceil(actualBoundingBoxRight) - glyphLeft)); @@ -106,7 +108,10 @@ export default class TinySDF { } edt(gridOuter, 0, 0, width, height, width, this.f, this.v, this.z); - edt(gridInner, buffer, buffer, glyphWidth, glyphHeight, width, this.f, this.v, this.z); + // Pad the inner EDT region by 1 px so ink pixels touching the bbox edge can see the + // outside-ink seeds in the buffer region; clamp to buffer so we don't underflow when buffer=0 + const pad = Math.min(buffer, 1); + edt(gridInner, buffer - pad, buffer - pad, glyphWidth + 2 * pad, glyphHeight + 2 * pad, width, this.f, this.v, this.z); // encode signed distance as a byte: inside the glyph maps to high values, outside to low, // with the edge gradient spanning [-radius * cutoff, radius * (1 - cutoff)] pixels around the edge; diff --git a/test/fixtures/1-metrics.json b/test/fixtures/1-metrics.json index 09dbeec..150a6cc 100644 --- a/test/fixtures/1-metrics.json +++ b/test/fixtures/1-metrics.json @@ -1,9 +1,9 @@ { - "width": 48, - "actualBoundingBoxLeft": 1.82421875, - "actualBoundingBoxRight": 45.2646484375, - "actualBoundingBoxAscent": 39.2158203125, - "actualBoundingBoxDescent": 4.6083984375, + "width": 48.2197265625, + "actualBoundingBoxLeft": 1.8720703125, + "actualBoundingBoxRight": 45.263671875, + "actualBoundingBoxAscent": 39.16796875, + "actualBoundingBoxDescent": 4.65625, "emHeightAscent": 51, "emHeightDescent": 17, "alphabeticBaseline": 14.0390625 diff --git a/test/fixtures/1-out.json b/test/fixtures/1-out.json index afd3b5e..7bdeb29 100644 --- a/test/fixtures/1-out.json +++ b/test/fixtures/1-out.json @@ -1,9 +1,9 @@ { - "width": 51, + "width": 54, "height": 51, - "glyphWidth": 45, + "glyphWidth": 48, "glyphHeight": 45, "glyphTop": 40, - "glyphLeft": 1, - "glyphAdvance": 48 + "glyphLeft": -2, + "glyphAdvance": 48.2197265625 } \ No newline at end of file diff --git a/test/fixtures/1-raw.png b/test/fixtures/1-raw.png index 9b673789d8509e92643bc694539c4c068bbf39dc..12891f0cb7878fdfa04590f7533183ead0148d4c 100644 GIT binary patch delta 867 zcmV-p1DyQ)2Kok&B!59kL_t(|0qwzCh*V_&0N`)_JvtQ9&C41R`4W*xL_#2&FL~>s z3-pi_nM$Cg(Zx!PGOb8h(-gXBNOU2$2r2TlvXI&cgh=aQBgLXs(Qc&VZtl80{11m^ zX2+R1GqX!G->0SJ|0O4zDVQMY!%_7y1rtPlII2pCY&K)DsDB$B>SHVJ7JZ31suVPt zpveSHCTKE2lL?wk&}4!plT+GUbQ;-oOcb3)kK!IeH#VR{tKwQ|K~f2%1cyv>tRSZX8xi_2ON{9Tt6plghUQdBYvY9))_9 zAyL{vtX13`tbdmJ8EcicUHN8Wxo8ME(N9>RNV41cNiN*0q9g#s|?Dz%)$Rm$Qiu+Io!Ps#c2aN1rtix9_NM!Rl&WH}lZYkPv tA@Wkc;XC}HrKP2%rKP2%rKM$5{0GUtqsX8FH>&^u002ovPDHLkV1gzRlz#vK delta 869 zcmV-r1DgE$2K)w)B!5FmL_t(|0qwzEXq9CE0N`iuzM-~qWhv-Iq3A__kd0Uu5s6w^ zNK_yTr9TS0C^AVh@ybX_2(v)LMD2%)(I1gv84?La2@^(0h%VBmn3XuiMsu@^_u_E) z&Y9P%2)f;tn_nV`-Db*4$$b=a=BshB1j#y*9u#!f88!^(F9t`Pl&ZfVbiP(u*N_R0{ z6b)d533rsY! z>#nYvmll9XO$e64f^qKjLyZM{pZ@ z)mTt{$Iv6E7jw{S3<$FEBnHugTXD!3k;uk=JdTfW#u$>w&RZ%oSq2idbtxub0H-jF zfL?phF6WFYNz~SSbjcaQAjZqrqe>FB^__gpXqN9RwyKh#w)*g^;tpUTeo-Zf@^xdg vXi(*D$0gEEqE|yhLqkJDLqkJD!X?=( diff --git a/test/fixtures/1-sdf.png b/test/fixtures/1-sdf.png index bbb6da20dd3281b44509e1001202b025d4acf04a..a21bad39411e7f142b28a0f4ef40c6bca0692f59 100644 GIT binary patch literal 3560 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 diff --git a/test/test.js b/test/test.js index 490651c..8ccbcfe 100644 --- a/test/test.js +++ b/test/test.js @@ -93,7 +93,7 @@ test('does not return negative-width glyphs', () => { // stub these because they vary across environments sdf.ctx.measureText = () => ({ width: 0, - actualBoundingBoxLeft: 23.3759765625, + actualBoundingBoxLeft: -23.3759765625, actualBoundingBoxRight: -17.6162109375, actualBoundingBoxAscent: 20.2080078125, actualBoundingBoxDescent: -14.51953125,