@@ -43,16 +43,13 @@ export default class TextToPath {
4343 static #DEBUG = false ;
4444
4545 static #rasterScale( bounds , nLines ) {
46- const lineHeight = bounds . height / Math . max ( nLines , 1 ) ;
47- const maxDim = Math . max ( bounds . width , bounds . height ) ;
46+ const lineHeight = bounds . height / Math . max ( nLines , 1 ) ;
47+ const maxDim = Math . max ( bounds . width , bounds . height ) ;
4848 // Take the BETTER (max) of the two quality targets:
4949 // • overall: 2400px on the longest side — protects single-line / small text
5050 // • per-line: 800px per line height — protects wide multi-line paragraphs
5151 // Then clamp to the hard canvas cap.
52- const scaleQuality = Math . max (
53- this . #TARGET_PX / maxDim ,
54- this . #TARGET_PX_PER_LINE / lineHeight ,
55- ) ;
52+ const scaleQuality = Math . max ( this . #TARGET_PX / maxDim , this . #TARGET_PX_PER_LINE / lineHeight ) ;
5653 return Math . min ( scaleQuality , this . #MAX_CANVAS_PX / maxDim ) ;
5754 }
5855
@@ -61,23 +58,44 @@ export default class TextToPath {
6158 // Edges: 0 = top, 1 = right, 2 = bottom, 3 = left
6259 // Value: [edgeA, edgeB] pair, "S5"/"S10" for saddle cases, or null.
6360 static #MS = [
64- null , [ 3 , 2 ] , [ 2 , 1 ] , [ 3 , 1 ] , // 0–3
65- [ 0 , 1 ] , "S5" , [ 0 , 2 ] , [ 3 , 0 ] , // 4–7
66- [ 0 , 3 ] , [ 0 , 2 ] , "S10" , [ 0 , 1 ] , // 8–11
67- [ 1 , 3 ] , [ 1 , 2 ] , [ 2 , 3 ] , null , // 12–15
61+ null ,
62+ [ 3 , 2 ] ,
63+ [ 2 , 1 ] ,
64+ [ 3 , 1 ] , // 0–3
65+ [ 0 , 1 ] ,
66+ "S5" ,
67+ [ 0 , 2 ] ,
68+ [ 3 , 0 ] , // 4–7
69+ [ 0 , 3 ] ,
70+ [ 0 , 2 ] ,
71+ "S10" ,
72+ [ 0 , 1 ] , // 8–11
73+ [ 1 , 3 ] ,
74+ [ 1 , 2 ] ,
75+ [ 2 , 3 ] ,
76+ null , // 12–15
6877 ] ;
6978
7079 // Each saddle case produces two independent segments.
7180 // S5 = 0101: TR+BL inside → [top↔right] and [bottom↔left]
7281 // S10 = 1010: TL+BR inside → [top↔left] and [bottom↔right]
73- static #SAD = { S5 : [ [ 0 , 1 ] , [ 2 , 3 ] ] , S10 : [ [ 0 , 3 ] , [ 2 , 1 ] ] } ;
82+ static #SAD = {
83+ S5 : [
84+ [ 0 , 1 ] ,
85+ [ 2 , 3 ] ,
86+ ] ,
87+ S10 : [
88+ [ 0 , 3 ] ,
89+ [ 2 , 1 ] ,
90+ ] ,
91+ } ;
7492
7593 // Per exit-edge: [Δrow, Δcol, entry-edge-in-next-cell]
7694 static #MOV = [
77- [ - 1 , 0 , 2 ] , // 0 TOP → move up, enter via BOTTOM
78- [ 0 , 1 , 3 ] , // 1 RIGHT → move right, enter via LEFT
79- [ 1 , 0 , 0 ] , // 2 BOTTOM → move down, enter via TOP
80- [ 0 , - 1 , 1 ] , // 3 LEFT → move left, enter via RIGHT
95+ [ - 1 , 0 , 2 ] , // 0 TOP → move up, enter via BOTTOM
96+ [ 0 , 1 , 3 ] , // 1 RIGHT → move right, enter via LEFT
97+ [ 1 , 0 , 0 ] , // 2 BOTTOM → move down, enter via TOP
98+ [ 0 , - 1 , 1 ] , // 3 LEFT → move left, enter via RIGHT
8199 ] ;
82100
83101 /**
@@ -123,8 +141,10 @@ export default class TextToPath {
123141
124142 // Coordinate mapping: pixel (px, py) → paper (ox + px/sx, oy + py/sy)
125143 // The canvas spans [bounds.x, bounds.x + bounds.width] × [...height]
126- const ox = bounds . x , oy = bounds . y ;
127- const sx = cw / bounds . width , sy = ch / bounds . height ;
144+ const ox = bounds . x ,
145+ oy = bounds . y ;
146+ const sx = cw / bounds . width ,
147+ sy = ch / bounds . height ;
128148
129149 // 2. Binary grid: 1 = inside (alpha above threshold) ─────────────────
130150 // Optional: pre-blur alpha with a separable 3×3 Gaussian (σ≈0.7px) before
@@ -149,7 +169,8 @@ export default class TextToPath {
149169 // Vertical pass: [1,2,1]/4 → combined σ≈0.7px
150170 blurredA = new Float32Array ( cw * ch ) ;
151171 for ( let r = 0 ; r < ch ; r ++ ) {
152- const rt = Math . max ( r - 1 , 0 ) , rb = Math . min ( r + 1 , ch - 1 ) ;
172+ const rt = Math . max ( r - 1 , 0 ) ,
173+ rb = Math . min ( r + 1 , ch - 1 ) ;
153174 for ( let c = 0 ; c < cw ; c ++ ) {
154175 blurredA [ r * cw + c ] = ( tmpA [ rt * cw + c ] + 2 * tmpA [ r * cw + c ] + tmpA [ rb * cw + c ] ) * 0.25 ;
155176 }
@@ -226,9 +247,12 @@ export default class TextToPath {
226247 const p0 = pts [ ( i - CORNER_SPAN + n ) % n ] ;
227248 const p1 = pts [ i ] ;
228249 const p2 = pts [ ( i + CORNER_SPAN ) % n ] ;
229- const inDx = p1 [ 0 ] - p0 [ 0 ] , inDy = p1 [ 1 ] - p0 [ 1 ] ;
230- const outDx = p2 [ 0 ] - p1 [ 0 ] , outDy = p2 [ 1 ] - p1 [ 1 ] ;
231- const li = Math . hypot ( inDx , inDy ) , lo = Math . hypot ( outDx , outDy ) ;
250+ const inDx = p1 [ 0 ] - p0 [ 0 ] ,
251+ inDy = p1 [ 1 ] - p0 [ 1 ] ;
252+ const outDx = p2 [ 0 ] - p1 [ 0 ] ,
253+ outDy = p2 [ 1 ] - p1 [ 1 ] ;
254+ const li = Math . hypot ( inDx , inDy ) ,
255+ lo = Math . hypot ( outDx , outDy ) ;
232256 if ( li < 0.5 || lo < 0.5 ) continue ;
233257 cornerDot [ i ] = ( inDx * outDx + inDy * outDy ) / ( li * lo ) ;
234258 }
@@ -256,7 +280,9 @@ export default class TextToPath {
256280 // Also check wrap-around: if last and first candidate are close
257281 if ( cj === candidates . length && ci > 0 ) {
258282 const wrapGap = ( candidates [ 0 ] + n - candidates [ candidates . length - 1 ] ) % n ;
259- if ( wrapGap <= clusterGap ) { break ; } // all one cluster — no corners
283+ if ( wrapGap <= clusterGap ) {
284+ break ;
285+ } // all one cluster — no corners
260286 }
261287 // Pick sharpest in this cluster
262288 let best = candidates [ ci ] ;
@@ -278,10 +304,12 @@ export default class TextToPath {
278304 }
279305 const denoised = pts . map ( ( p , i ) => {
280306 if ( isProtected [ i ] ) return p ; // preserve original position near corners
281- let ax = 0 , ay = 0 ;
307+ let ax = 0 ,
308+ ay = 0 ;
282309 for ( let j = - half ; j <= half ; j ++ ) {
283310 const [ x , y ] = pts [ ( i + j + n ) % n ] ;
284- ax += x ; ay += y ;
311+ ax += x ;
312+ ay += y ;
285313 }
286314 return [ ax / SMOOTH_WIN , ay / SMOOTH_WIN ] ;
287315 } ) ;
@@ -293,9 +321,14 @@ export default class TextToPath {
293321 if ( this . #DEBUG) {
294322 // Compact corner summary: world-space positions so we can match against SVG coordinates.
295323 const contourIdx = contours . indexOf ( pts ) ;
296- const cornerRows = cornerIdxs . map ( ci => {
324+ const cornerRows = cornerIdxs . map ( ( ci ) => {
297325 const [ rx , ry ] = pts [ ci ] ;
298- return { idx : ci , wx : ( ox + rx / sx ) . toFixed ( 3 ) , wy : ( oy + ry / sy ) . toFixed ( 3 ) , dot : cornerDot [ ci ] . toFixed ( 3 ) } ;
326+ return {
327+ idx : ci ,
328+ wx : ( ox + rx / sx ) . toFixed ( 3 ) ,
329+ wy : ( oy + ry / sy ) . toFixed ( 3 ) ,
330+ dot : cornerDot [ ci ] . toFixed ( 3 ) ,
331+ } ;
299332 } ) ;
300333 console . log ( `[TextToPath] contour ${ contourIdx } : ${ cornerRows . length } corners (world-space):` ) ;
301334 console . table ( cornerRows ) ;
@@ -322,21 +355,23 @@ export default class TextToPath {
322355 // and the dilation zone) and measure direction over WINDOW indices.
323356 // SAMPLE must exceed half (the dilation half-width) — otherwise we
324357 // would sample unsmoothed protected points and get noisy directions.
325- const SAMPLE = half + 4 ; // half + a few extra pts clear of dilation
358+ const SAMPLE = half + 4 ; // half + a few extra pts clear of dilation
326359 const WINDOW = 4 ;
327360 const correctedCorner = cornerIdxs . map ( ( ics ) => {
328- const outPts = [ ] , inPts = [ ] ;
329- for ( let d = SAMPLE ; d < SAMPLE + WINDOW ; d ++ )
330- outPts . push ( denoised [ ( ics + d + n ) % n ] ) ;
331- for ( let d = - ( SAMPLE + WINDOW ) ; d < - SAMPLE ; d ++ )
332- inPts . push ( denoised [ ( ics + d + n ) % n ] ) ;
361+ const outPts = [ ] ,
362+ inPts = [ ] ;
363+ for ( let d = SAMPLE ; d < SAMPLE + WINDOW ; d ++ ) outPts . push ( denoised [ ( ics + d + n ) % n ] ) ;
364+ for ( let d = - ( SAMPLE + WINDOW ) ; d < - SAMPLE ; d ++ ) inPts . push ( denoised [ ( ics + d + n ) % n ] ) ;
333365
334366 // Direction vectors (endpoint→endpoint over the window)
335- const odx = outPts [ WINDOW - 1 ] [ 0 ] - outPts [ 0 ] [ 0 ] , ody = outPts [ WINDOW - 1 ] [ 1 ] - outPts [ 0 ] [ 1 ] ;
336- const idx = inPts [ WINDOW - 1 ] [ 0 ] - inPts [ 0 ] [ 0 ] , idy = inPts [ WINDOW - 1 ] [ 1 ] - inPts [ 0 ] [ 1 ] ;
367+ const odx = outPts [ WINDOW - 1 ] [ 0 ] - outPts [ 0 ] [ 0 ] ,
368+ ody = outPts [ WINDOW - 1 ] [ 1 ] - outPts [ 0 ] [ 1 ] ;
369+ const idx = inPts [ WINDOW - 1 ] [ 0 ] - inPts [ 0 ] [ 0 ] ,
370+ idy = inPts [ WINDOW - 1 ] [ 1 ] - inPts [ 0 ] [ 1 ] ;
337371 const cross = idx * ody - idy * odx ;
338372 if ( Math . abs ( cross ) < 0.01 ) return null ; // parallel → keep original
339- const dx = outPts [ 0 ] [ 0 ] - inPts [ 0 ] [ 0 ] , dy = outPts [ 0 ] [ 1 ] - inPts [ 0 ] [ 1 ] ;
373+ const dx = outPts [ 0 ] [ 0 ] - inPts [ 0 ] [ 0 ] ,
374+ dy = outPts [ 0 ] [ 1 ] - inPts [ 0 ] [ 1 ] ;
340375 const t = ( dx * ody - dy * odx ) / cross ;
341376 return [ inPts [ 0 ] [ 0 ] + idx * t , inPts [ 0 ] [ 1 ] + idy * t ] ;
342377 } ) ;
@@ -350,8 +385,8 @@ export default class TextToPath {
350385 const CORNER_SKIP_PX = 1.5 ;
351386 const arcPaths = cornerIdxs . map ( ( iStart , ci ) => {
352387 const iEnd = cornerIdxs [ ( ci + 1 ) % nc3 ] ;
353- const cs = correctedCorner [ ci ] ?? denoised [ iStart ] ;
354- const ce = correctedCorner [ ( ci + 1 ) % nc3 ] ?? denoised [ iEnd ] ;
388+ const cs = correctedCorner [ ci ] ?? denoised [ iStart ] ;
389+ const ce = correctedCorner [ ( ci + 1 ) % nc3 ] ?? denoised [ iEnd ] ;
355390
356391 const arcPts = [ cs ] ;
357392 let k = ( iStart + 1 ) % n ;
@@ -373,14 +408,14 @@ export default class TextToPath {
373408 // If all interior points are within tolerance of the start→end line,
374409 // this arc is straight — bypass simplify() entirely (yields exactly
375410 // 2 segments, zero handles, guaranteed straight line after offset).
376- const [ ax , ay ] = cs , [ bx , by ] = ce ;
411+ const [ ax , ay ] = cs ,
412+ [ bx , by ] = ce ;
377413 const abLen = Math . hypot ( bx - ax , by - ay ) ;
378414 let maxDev = 0 ;
379415 if ( abLen > 0.01 ) {
380416 for ( let j = 1 ; j < arcPts . length - 1 ; j ++ ) {
381417 const [ px , py ] = arcPts [ j ] ;
382- maxDev = Math . max ( maxDev ,
383- Math . abs ( ( px - ax ) * ( by - ay ) - ( py - ay ) * ( bx - ax ) ) / abLen ) ;
418+ maxDev = Math . max ( maxDev , Math . abs ( ( px - ax ) * ( by - ay ) - ( py - ay ) * ( bx - ax ) ) / abLen ) ;
384419 }
385420 }
386421 let arc ;
@@ -407,24 +442,25 @@ export default class TextToPath {
407442 // to arcSegs[last-1].handleOut approaching the next cusp.
408443 for ( let s = 1 ; s < last ; s ++ ) {
409444 const seg = arcSegs [ s ] ;
410- const hi = s === 1 ? new paper . Point ( 0 , 0 ) : seg . handleIn . clone ( ) ;
411- const ho = s === last - 1 ? new paper . Point ( 0 , 0 ) : seg . handleOut . clone ( ) ;
445+ const hi = s === 1 ? new paper . Point ( 0 , 0 ) : seg . handleIn . clone ( ) ;
446+ const ho = s === last - 1 ? new paper . Point ( 0 , 0 ) : seg . handleOut . clone ( ) ;
412447 path . add ( new paper . Segment ( seg . point . clone ( ) , hi , ho ) ) ;
413448 }
414449
415450 if ( this . #DEBUG) {
416451 // Show what simplify() produced for this arc before we override handles.
417452 console . groupCollapsed (
418- ` arc ${ ci } (corner idx=${ cornerIdxs [ ci ] } →${ cornerIdxs [ ( ci + 1 ) % nc3 ] } ): ` +
419- `${ arcPaths [ ci ] . segments . length } raw simplify segs → ${ last } used`
453+ ` arc ${ ci } (corner idx=${ cornerIdxs [ ci ] } →${ cornerIdxs [ ( ci + 1 ) % nc3 ] } ): ` +
454+ `${ arcPaths [ ci ] . segments . length } raw simplify segs → ${ last } used`
420455 ) ;
421456 arcPaths [ ci ] . segments . forEach ( ( seg , s ) => {
422- const hi = seg . handleIn , ho = seg . handleOut ;
457+ const hi = seg . handleIn ,
458+ ho = seg . handleOut ;
423459 console . log (
424460 ` seg[${ s } ] pt=(${ seg . point . x . toFixed ( 2 ) } ,${ seg . point . y . toFixed ( 2 ) } )` +
425- ` hIn=(${ hi . x . toFixed ( 2 ) } ,${ hi . y . toFixed ( 2 ) } )` +
426- ` hOut=(${ ho . x . toFixed ( 2 ) } ,${ ho . y . toFixed ( 2 ) } )` +
427- ( s === 0 || s === last ? " ← endpoint (handles zeroed)" : "" )
461+ ` hIn=(${ hi . x . toFixed ( 2 ) } ,${ hi . y . toFixed ( 2 ) } )` +
462+ ` hOut=(${ ho . x . toFixed ( 2 ) } ,${ ho . y . toFixed ( 2 ) } )` +
463+ ( s === 0 || s === last ? " ← endpoint (handles zeroed)" : "" )
428464 ) ;
429465 } ) ;
430466 console . groupEnd ( ) ;
@@ -442,14 +478,14 @@ export default class TextToPath {
442478 } else {
443479 const simp = this . #rdp( denoised , this . #EPS) ;
444480 if ( simp . length < 3 ) continue ;
445- path = new paper . Path (
446- simp . map ( ( [ px , py ] ) => new paper . Point ( ox + px / sx , oy + py / sy ) )
447- ) ;
481+ path = new paper . Path ( simp . map ( ( [ px , py ] ) => new paper . Point ( ox + px / sx , oy + py / sy ) ) ) ;
448482 path . closed = true ;
449483 }
450484
451- if ( path . segments . length >= 3 ) { paths . push ( path ) ; cornerCounts . push ( nCorners ) ; }
452- else path . remove ( ) ;
485+ if ( path . segments . length >= 3 ) {
486+ paths . push ( path ) ;
487+ cornerCounts . push ( nCorners ) ;
488+ } else path . remove ( ) ;
453489
454490 if ( this . #DEBUG && path . segments . length >= 3 ) {
455491 // Final assembled path: print every segment's handle lengths.
@@ -460,8 +496,8 @@ export default class TextToPath {
460496 s,
461497 x : seg . point . x . toFixed ( 3 ) ,
462498 y : seg . point . y . toFixed ( 3 ) ,
463- " hIn" : seg . handleIn . length . toFixed ( 4 ) ,
464- " hOut" : seg . handleOut . length . toFixed ( 4 ) ,
499+ hIn : seg . handleIn . length . toFixed ( 4 ) ,
500+ hOut : seg . handleOut . length . toFixed ( 4 ) ,
465501 } ) ) ;
466502 console . log ( `[TextToPath] contour ${ contours . indexOf ( pts ) } FINAL path (${ segs . length } segs, world-space):` ) ;
467503 console . table ( segRows ) ;
@@ -489,10 +525,10 @@ export default class TextToPath {
489525
490526 // 4-bit case for cell (r, c): corners TL, TR, BR, BL
491527 const cellCase = ( r , c ) =>
492- ( grid [ r * w + c ] << 3 ) |
493- ( grid [ r * w + ( c + 1 ) ] << 2 ) |
494- ( grid [ ( r + 1 ) * w + ( c + 1 ) ] << 1 ) |
495- grid [ ( r + 1 ) * w + c ] ;
528+ ( grid [ r * w + c ] << 3 ) |
529+ ( grid [ r * w + ( c + 1 ) ] << 2 ) |
530+ ( grid [ ( r + 1 ) * w + ( c + 1 ) ] << 1 ) |
531+ grid [ ( r + 1 ) * w + c ] ;
496532
497533 // Segment list for a cell case
498534 const segsOf = ( ci ) => {
@@ -504,10 +540,10 @@ export default class TextToPath {
504540
505541 // Floating-point edge midpoint in raster-pixel space
506542 const edgePt = ( r , c , e ) => {
507- if ( e === 0 ) return [ c + 0.5 , r ] ; // top
508- if ( e === 1 ) return [ c + 1 , r + 0.5 ] ; // right
509- if ( e === 2 ) return [ c + 0.5 , r + 1 ] ; // bottom
510- /* e===3 */ return [ c , r + 0.5 ] ; // left
543+ if ( e === 0 ) return [ c + 0.5 , r ] ; // top
544+ if ( e === 1 ) return [ c + 1 , r + 0.5 ] ; // right
545+ if ( e === 2 ) return [ c + 0.5 , r + 1 ] ; // bottom
546+ /* e===3 */ return [ c , r + 0.5 ] ; // left
511547 } ;
512548
513549 for ( let r0 = 0 ; r0 < gh ; r0 ++ ) {
@@ -523,23 +559,28 @@ export default class TextToPath {
523559 // edge), so the first exit will naturally be segs[si][0].
524560 const [ startE0 , startE1 ] = segs [ si ] ;
525561 const pts = [ ] ;
526- let r = r0 , c = c0 , entry = startE1 ;
562+ let r = r0 ,
563+ c = c0 ,
564+ entry = startE1 ;
527565 const MAX = ( gw * gh + 4 ) * 2 ; // safety upper bound
528566
529567 for ( let iter = 0 ; iter < MAX ; iter ++ ) {
530568 const vi = r * gw + c ;
531569 const cs = segsOf ( cellCase ( r , c ) ) ;
532570
533571 // Find the segment that contains the entry edge
534- let seg = null , bit = 0 ;
572+ let seg = null ,
573+ bit = 0 ;
535574 for ( let bi = 0 ; bi < cs . length ; bi ++ ) {
536575 if ( cs [ bi ] [ 0 ] === entry || cs [ bi ] [ 1 ] === entry ) {
537- seg = cs [ bi ] ; bit = bi ; break ;
576+ seg = cs [ bi ] ;
577+ bit = bi ;
578+ break ;
538579 }
539580 }
540581 if ( ! seg ) break ; // degenerate / disconnected
541582
542- vis [ vi ] |= ( 1 << bit ) ;
583+ vis [ vi ] |= 1 << bit ;
543584
544585 // Exit via the other edge of this segment
545586 const exit = seg [ 0 ] === entry ? seg [ 1 ] : seg [ 0 ] ;
@@ -554,16 +595,18 @@ export default class TextToPath {
554595 if ( ( entry + exit ) % 2 === 1 ) {
555596 // The shared corner's X is c+1 if either edge is the right edge (1).
556597 // The shared corner's Y is r+1 if either edge is the bottom edge (2).
557- const cx = ( entry === 1 || exit === 1 ) ? c + 1 : c ;
558- const cy = ( entry === 2 || exit === 2 ) ? r + 1 : r ;
598+ const cx = entry === 1 || exit === 1 ? c + 1 : c ;
599+ const cy = entry === 2 || exit === 2 ? r + 1 : r ;
559600 pts . push ( [ cx , cy ] ) ;
560601 }
561602
562603 pts . push ( edgePt ( r , c , exit ) ) ;
563604
564605 // Step to the adjacent cell
565606 const [ dr , dc , nextEntry ] = this . #MOV[ exit ] ;
566- r += dr ; c += dc ; entry = nextEntry ;
607+ r += dr ;
608+ c += dc ;
609+ entry = nextEntry ;
567610
568611 // Off-grid → open contour, stop here
569612 if ( r < 0 || r >= gh || c < 0 || c >= gw ) break ;
@@ -588,14 +631,18 @@ export default class TextToPath {
588631 const [ x1 , y1 ] = pts [ 0 ] ;
589632 const [ x2 , y2 ] = pts [ pts . length - 1 ] ;
590633 const len = Math . hypot ( x2 - x1 , y2 - y1 ) ;
591- let maxD = 0 , maxI = 0 ;
634+ let maxD = 0 ,
635+ maxI = 0 ;
592636 for ( let i = 1 ; i < pts . length - 1 ; i ++ ) {
593637 const [ px , py ] = pts [ i ] ;
594638 const d =
595639 len < 1e-10
596640 ? Math . hypot ( px - x1 , py - y1 )
597641 : Math . abs ( ( y2 - y1 ) * px - ( x2 - x1 ) * py + x2 * y1 - y2 * x1 ) / len ;
598- if ( d > maxD ) { maxD = d ; maxI = i ; }
642+ if ( d > maxD ) {
643+ maxD = d ;
644+ maxI = i ;
645+ }
599646 }
600647 if ( maxD > eps ) {
601648 const L = this . #rdp( pts . slice ( 0 , maxI + 1 ) , eps ) ;
0 commit comments