figdown 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -121,7 +121,7 @@ const SHAPES = ['box','rounded','circle','ellipse','diamond','cylinder'];
121
121
  // input to that promise, and under core §13 a 0.x renderer may differ from
122
122
  // the next — which makes the recorded version the only thing that can
123
123
  // explain a diff between two renderings of one source.
124
- const FIGDOWN_VERSION = '0.1.6';
124
+ const FIGDOWN_VERSION = '0.1.8';
125
125
  // Retired shape VALUES keep a named diagnostic (PROCESS §5(d)), the same way
126
126
  // retired option keys do: `cloud` was the one value that named a domain
127
127
  // (the internet cloud) in an enum the language keeps purely geometric
@@ -2455,6 +2455,80 @@ function findComment(s){
2455
2455
  // 2. LAYOUT + RENDER (deterministic; no randomness, no Date)
2456
2456
  // ============================================================
2457
2457
  const FONT=13, CH=7.2, PADX=14, NH=36, GAPX=56, GAPY=44;
2458
+
2459
+ // ---- cw(): script-aware advance width --------------------------------
2460
+ // Every px-per-character constant in this file — CH here, and its siblings at
2461
+ // the other font sizes (8.6 title, 6.6 legend, 6.5 edge label, 6.3/6.2 bitfield
2462
+ // caption) — is calibrated on LATIN, where one character advances ~0.554 em.
2463
+ // That is a claim about SCRIPT, not about font: a CJK ideograph advances a full
2464
+ // em, so counting characters undersizes a Chinese label by 45% and the engine
2465
+ // draws the text outside the box it has just sized for it. cw() is the measure
2466
+ // those constants multiply — a string's advance in Latin units. Every codepoint
2467
+ // below U+0300 weighs exactly 1, so an all-ASCII string returns exactly its
2468
+ // .length and every figure that was already right stays byte-identical.
2469
+ //
2470
+ // The classes are Unicode East Asian Width (UAX #11), a fixed character
2471
+ // property: a lookup by codepoint range, deterministic, no font-metrics library
2472
+ // and no canvas measurement at render time. The WEIGHTS are measured, not
2473
+ // chosen — probe strings rendered through librsvg at font-size 13 in the
2474
+ // engine's own stack (font-family="system-ui,sans-serif" → Noto Sans + Noto
2475
+ // Sans CJK), advance recovered as the slope of ink extent over repeat count:
2476
+ // ideograph 封 一 㐀, kana あ ア, bopomofo ㄅ, fullwidth A ,, U+3000 13.000px = 1.000em
2477
+ // emoji U+1F600 U+1F680 (Noto Color Emoji) 16.188px = 1.245em
2478
+ // combining U+0301 standalone, U+200B, U+FEFF 0.000px = 0.000em
2479
+ // Latin — the calibration these constants already carry 7.200px = 0.554em
2480
+ // hence WIDE = 1.000/0.554 = 1.806, rounded UP to 1.81 so a wide glyph is never
2481
+ // UNDER-measured at the node-label site, and EMOJI = 1.245/0.554 = 2.25.
2482
+ // Halfwidth katakana (U+FF61..FF9F) measured 0.500 em: it stays in the default
2483
+ // class, which over-measures it by 11% — the safe direction.
2484
+ //
2485
+ // EAW *Ambiguous* — §, —, –, →, ←, ✓, ≤, •, the only non-ASCII this
2486
+ // repository's figures actually draw — is deliberately NARROW, the
2487
+ // wcwidth/POSIX reading for a non-East-Asian context. Measured, an em dash is
2488
+ // a full em in this font, so a label carrying one is still under-measured by
2489
+ // 0.45 unit; that is a different question from this defect (it needs a
2490
+ // context rule, not a table) and is not decided here.
2491
+ const CW_ZERO=[[0x0300,0x036F],[0x0483,0x0489],[0x0591,0x05BD],[0x0610,0x061A],
2492
+ [0x064B,0x065F],[0x0670,0x0670],[0x06D6,0x06DC],[0x0711,0x0711],[0x0730,0x074A],
2493
+ [0x07A6,0x07B0],[0x0816,0x0819],[0x093C,0x093C],[0x0951,0x0954],[0x0E31,0x0E31],
2494
+ [0x0E34,0x0E3A],[0x0E47,0x0E4E],[0x1AB0,0x1AFF],[0x1DC0,0x1DFF],[0x200B,0x200F],
2495
+ [0x2060,0x2064],[0x20D0,0x20F0],[0xFE00,0xFE0F],[0xFE20,0xFE2F],[0xFEFF,0xFEFF]];
2496
+ const CW_EMOJI=[[0x1F004,0x1F0CF],[0x1F18E,0x1F19A],[0x1F300,0x1F5FF],
2497
+ [0x1F600,0x1F64F],[0x1F680,0x1F6FF],[0x1F7E0,0x1F7EB],[0x1F900,0x1F9FF],
2498
+ [0x1FA70,0x1FAFF]];
2499
+ const CW_WIDE=[[0x1100,0x115F],[0x2E80,0x303E],[0x3041,0x33FF],[0x3400,0x4DBF],
2500
+ [0x4E00,0x9FFF],[0xA000,0xA4CF],[0xA960,0xA97F],[0xAC00,0xD7A3],[0xF900,0xFAFF],
2501
+ [0xFE10,0xFE19],[0xFE30,0xFE6F],[0xFF01,0xFF60],[0xFFE0,0xFFE6],
2502
+ [0x17000,0x18AFF],[0x1B000,0x1B2FF],[0x20000,0x3FFFD]];
2503
+ // ranges are sorted and disjoint, so the scan stops at the first range that
2504
+ // starts above the codepoint
2505
+ function cwIn(cp,rs){
2506
+ for(let i=0;i<rs.length;i++){ if(cp<rs[i][0]) return false; if(cp<=rs[i][1]) return true; }
2507
+ return false;
2508
+ }
2509
+ function cwOf(cp){
2510
+ if(cp<0x0300) return 1; // ASCII and Latin-1: the calibrated case
2511
+ if(cwIn(cp,CW_ZERO)) return 0;
2512
+ if(cwIn(cp,CW_EMOJI)) return 2.25;
2513
+ if(cwIn(cp,CW_WIDE)) return 1.81;
2514
+ return 1;
2515
+ }
2516
+ // Iterate CODEPOINTS, not UTF-16 units: an astral character is one advance,
2517
+ // not two. A ZWJ emoji sequence still counts each codepoint, so it is
2518
+ // over-measured — the safe direction, and the only way to avoid it is a
2519
+ // grapheme segmenter this engine will not carry.
2520
+ function cw(s){
2521
+ s=String(s); let w=0;
2522
+ for(let i=0;i<s.length;i++){
2523
+ const cp=s.codePointAt(i);
2524
+ if(cp>0xFFFF) i++;
2525
+ w+=cwOf(cp);
2526
+ }
2527
+ return w;
2528
+ }
2529
+ // the widest line of a multi-line string, in Latin units
2530
+ function cwMax(s){ return Math.max(...String(s).split('\n').map(cw)); }
2531
+
2458
2532
  // §5 style= → SVG dash pattern. `def` is the construct's conventional
2459
2533
  // default (the bundle ring and the threshold line are dashed by convention);
2460
2534
  // an explicit style= always wins.
@@ -2464,7 +2538,7 @@ function dashOf(style,def){
2464
2538
  }
2465
2539
  function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
2466
2540
  function tw(s){
2467
- const longest=Math.max(...String(s).split('\n').map(l=>l.length));
2541
+ const longest=cwMax(s);
2468
2542
  return Math.max(30,longest*CH+2*PADX);
2469
2543
  }
2470
2544
  // ---- shape geometry (one source of truth for size and for clipping) ----
@@ -2583,7 +2657,7 @@ function inscribedHalfW(g,v){
2583
2657
  const LBLPADX=6, LBLPADY=4;
2584
2658
  function labelBox(label){
2585
2659
  const ls=String(label).split('\n');
2586
- return [Math.max(...ls.map(l=>l.length))*CH+2*LBLPADX,
2660
+ return [Math.max(...ls.map(cw))*CH+2*LBLPADX,
2587
2661
  ((ls.length-1)*1.3+1.2)*FONT+2*LBLPADY];
2588
2662
  }
2589
2663
  // fitOutline: grow the box by the smallest factor that pulls an iw x ih box,
@@ -2675,7 +2749,7 @@ function render(doc,ropts){
2675
2749
  }
2676
2750
  const parts=[]; let y=0, maxW=0;
2677
2751
  if(doc.title && RO.title===true){ parts.push('<text x="0" y="16" font-size="15" font-weight="600">'+esc(doc.title)+'</text>'); y=30;
2678
- maxW=Math.max(maxW, doc.title.length*8.6); } // canvas must fit the title
2752
+ maxW=Math.max(maxW, cw(doc.title)*8.6); } // canvas must fit the title
2679
2753
  let sceneMeta=null;
2680
2754
  if(doc.nodes.length||doc.edges.length||(doc.boundaries||[]).length){
2681
2755
  const s=renderScene(doc,y); parts.push(s.svg); y=s.y; maxW=Math.max(maxW,s.w);
@@ -2713,7 +2787,7 @@ function render(doc,ropts){
2713
2787
  // `style`) is drawn, so "declared but not shown" is now unreachable.
2714
2788
  for(const c of legendCls){
2715
2789
  const paints=c.fill!==undefined||c.stroke!==undefined||c.style!==undefined;
2716
- const tw=String(c.label).length*6.6+(paints?30:9);
2790
+ const tw=cw(c.label)*6.6+(paints?30:9);
2717
2791
  if(lx>0 && lx+tw>wrapW){ lx=0; ly+=rowH; }
2718
2792
  if(paints){
2719
2793
  const dash=c.style==='dashed'?' stroke-dasharray="6 4"':(c.style==='dotted'?' stroke-dasharray="2 4"':'');
@@ -2826,7 +2900,7 @@ function renderScene(doc,y0){
2826
2900
  // ALL nodes participate in auto layout (pinned ones hold their slot so
2827
2901
  // that pinning X never reflows Y); pins override coordinates afterwards.
2828
2902
  const horiz=(doc.flow==='right'||doc.flow==='left');
2829
- const lblPx=s=>Math.max(...String(s).split('\n').map(l=>l.length))*6.5;
2903
+ const lblPx=s=>cwMax(s)*6.5;
2830
2904
  const lay=[...nodes]; // layout participants
2831
2905
  const chains=new Map(); // edge -> [A, ...waypoints, B]
2832
2906
  for(const e of doc.edges){
@@ -3209,6 +3283,45 @@ function renderScene(doc,y0){
3209
3283
  const halo=' paint-order="stroke" stroke="#fff" stroke-width="3"';
3210
3284
  const seg=(p,q,t,lbl,fs)=>reqLabel({p,q,t0:t,text:lbl,fs,col:ecol,halo,e,A,B,kind:'end'});
3211
3285
  if(isBack.has(e)&&!pinned(e.a)&&!pinned(e.b)){
3286
+ // ── ROUTING-CHANGE ARCHITECTURE NOTE ──────────
3287
+ // Edge labels are DEFERRED: every label is registered against its
3288
+ // FINAL segment geometry (reqLabel/lblReq above) and placed by ONE
3289
+ // greedy pass after all edges are drawn; arrowheads are computed from
3290
+ // final terminal points (arrowTri). Therefore a routing change made
3291
+ // HERE — upstream of reqLabel/noteSegs/arrowTri — gets label and
3292
+ // arrowhead re-placement for free. A change spliced into the emitted
3293
+ // SVG downstream, or made outside the engine, will strand both.
3294
+ // Two prototype rounds re-derived this the hard way (floating
3295
+ // arrowheads, orphaned labels): that was external splicing, not an
3296
+ // engine gap. Patch routing here; do not "fix" the label machinery.
3297
+ if(A===B){
3298
+ // Self-transition: a small side loop on the node, the
3299
+ // convention of every drawing tool — never a lap of the figure
3300
+ // through the back-edge channel. Side order r,l,b,t; first side
3301
+ // whose loop box overlaps no other node wins (deterministic).
3302
+ const scy=A.y+A.h/2, scx=A.x+A.w/2;
3303
+ const mkLoop=sd=>sd==='r'?[[A.x+A.w,scy-8],[A.x+A.w+20,scy-8],[A.x+A.w+20,scy+8],[A.x+A.w,scy+8]]
3304
+ :sd==='l'?[[A.x,scy-8],[A.x-20,scy-8],[A.x-20,scy+8],[A.x,scy+8]]
3305
+ :sd==='b'?[[scx-8,A.y+A.h],[scx-8,A.y+A.h+20],[scx+8,A.y+A.h+20],[scx+8,A.y+A.h]]
3306
+ :[[scx-8,A.y],[scx-8,A.y-20],[scx+8,A.y-20],[scx+8,A.y]];
3307
+ const loopHit=pp=>{
3308
+ const x0=Math.min(...pp.map(p=>p[0]))-2, x1=Math.max(...pp.map(p=>p[0]))+2;
3309
+ const z0=Math.min(...pp.map(p=>p[1]))-2, z1=Math.max(...pp.map(p=>p[1]))+2;
3310
+ return nodes.some(n=>n!==A&&!n.boundary&&n.x<x1&&n.x+n.w>x0&&n.y<z1&&n.y+n.h>z0);
3311
+ };
3312
+ let sp=null;
3313
+ for(const sd of ['r','l','b','t']){ const pp=mkLoop(sd); if(!loopHit(pp)){ sp=pp; break; } }
3314
+ if(!sp) sp=mkLoop('r');
3315
+ for(const p of sp){ W=Math.max(W,p[0]+4); Hh=Math.max(Hh,p[1]+16-y0-20); }
3316
+ esvg.push('<path data-edge="'+e.line+'" d="'+roundPath(sp)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
3317
+ noteSegs(e,sp);
3318
+ if(e.mid) reqLabel({p:sp[1],q:sp[2],text:e.mid,fs:11,col:lcol,halo,e,A,B,kind:'mid',first:false});
3319
+ if(e.tail) seg(sp[0],sp[1],0.5,e.tail,10);
3320
+ if(e.head) seg(sp[3],sp[2],0.5,e.head,10);
3321
+ if(wantsStart) arrowTri(sp[0],sp[1],col);
3322
+ if(wantsEnd) arrowTri(sp[sp.length-1],sp[sp.length-2],col);
3323
+ continue;
3324
+ }
3212
3325
  // back-edge (retry loop): polyline through a side channel beyond the
3213
3326
  // occupied lanes instead of a straight line hidden under the spine,
3214
3327
  // using the nested slot from the channel plan. Ring-eligible loops
@@ -3335,6 +3448,50 @@ function renderScene(doc,y0){
3335
3448
  // their neighbours turns a 40-point staircase into the 2–4 bends a
3336
3449
  // dummy-vertex chain should have, without moving the drawn line.
3337
3450
  simplifyPts(pts);
3451
+ // Waypoint prune: after collinear simplification a
3452
+ // chain can still carry a staircase of near-collinear jogs — the drift
3453
+ // clamp allows only a few px of sideways movement per rank, so a run
3454
+ // that wants to move 35px sideways alternates short diagonals and
3455
+ // verticals that Douglas-Peucker cannot collapse (each angle differs).
3456
+ // The rule: an interior waypoint SURVIVES only if removing it would
3457
+ // make the edge pierce something. Obstacles are the same set the
3458
+ // straight-edge router uses below: non-incident nodes, plus group
3459
+ // boxes when NEITHER endpoint lies inside (a run through a group
3460
+ // interior asserts membership the figure does not declare). Fixed
3461
+ // left-to-right scan to fixpoint — deterministic. Endpoints are then
3462
+ // re-derived so the edge leaves its node facing the first bend, and
3463
+ // each re-derived terminal segment is re-checked: if it would hit an
3464
+ // obstacle the old endpoint stays.
3465
+ {
3466
+ const pobs=nodes.filter(n=>n!==A&&n!==B&&!n.boundary).map(n=>({x:n.x,y:n.y,w:n.w,h:n.h}));
3467
+ const ep0=pts[0], ep1=pts[pts.length-1];
3468
+ for(const k in gBox){
3469
+ const b=gBox[k];
3470
+ const inG=(px,py)=>px>b.x0&&px<b.x1&&py>b.yA&&py<b.yB;
3471
+ if(!inG(ep0[0],ep0[1])&&!inG(ep1[0],ep1[1]))
3472
+ pobs.push({x:b.x0,y:b.yA,w:b.x1-b.x0,h:b.yB-b.yA});
3473
+ }
3474
+ let ch=true, pruned=false;
3475
+ while(ch){ ch=false;
3476
+ for(let i=1;i+1<pts.length;i++)
3477
+ if(!segHitsObs(pts[i-1],pts[i+1],pobs)){ pts.splice(i,1); ch=true; pruned=true; i--; }
3478
+ }
3479
+ // untouched chains stay byte-identical: endpoints and the label
3480
+ // segment are only re-derived when the prune removed a waypoint
3481
+ if(pruned){
3482
+ const nb0=borderPoint(A,pts[1][0],pts[1][1]);
3483
+ if(!segHitsObs(nb0,pts[1],pobs)) pts[0]=nb0;
3484
+ const nb1=borderPoint(B,pts[pts.length-2][0],pts[pts.length-2][1]);
3485
+ if(!segHitsObs(nb1,pts[pts.length-2],pobs)) pts[pts.length-1]=nb1;
3486
+ if(midSeg){ let bi=0,bl=-1;
3487
+ for(let i=0;i+1<pts.length;i++){
3488
+ const l=Math.hypot(pts[i+1][0]-pts[i][0],pts[i+1][1]-pts[i][1]);
3489
+ if(l>bl){ bl=l; bi=i; }
3490
+ }
3491
+ midSeg=[pts[bi],pts[bi+1]];
3492
+ }
3493
+ }
3494
+ }
3338
3495
  esvg.push('<path data-edge="'+e.line+'" d="'+roundPath(pts)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
3339
3496
  noteSegs(e,pts);
3340
3497
  if(midSeg) reqLabel({p:midSeg[0],q:midSeg[1],text:e.mid,fs:11,col:lcol,halo,e,A,B,kind:'mid',first:false});
@@ -3427,7 +3584,7 @@ function renderScene(doc,y0){
3427
3584
  const CLAMP=t=>Math.max(0.06,Math.min(0.94,t));
3428
3585
  const cand=(r,t,side)=>{
3429
3586
  const lines=String(r.text).split('\n'), n=lines.length;
3430
- const w=Math.max(...lines.map(l=>l.length))*6.5*r.fs/11;
3587
+ const w=Math.max(...lines.map(cw))*6.5*r.fs/11;
3431
3588
  const lh=r.fs*1.3, h=(n-1)*lh+r.fs*1.1;
3432
3589
  const up=(n-1)*lh/2+r.fs*0.85; // baseline y = box top + up
3433
3590
  const mx=r.p[0]+(r.q[0]-r.p[0])*t, my=r.p[1]+(r.q[1]-r.p[1])*t;
@@ -3533,7 +3690,7 @@ function renderScene(doc,y0){
3533
3690
  // label with shrink-to-fit when size is rigid (`UNDECLARED-ATTRIBUTE-BEHAVIOUR`); multi-line via "\n"
3534
3691
  let fs=FONT;
3535
3692
  const nl=String(n.label).split('\n');
3536
- const need=Math.max(...nl.map(l=>l.length))*CH;
3693
+ const need=Math.max(...nl.map(cw))*CH;
3537
3694
  // budget = the width the OUTLINE offers at the label's own height, minus
3538
3695
  // 8 px clearance each side. For a box that is n.w-16, exactly as before;
3539
3696
  // for a rhombus or an ellipse it is the inscribed width, so a rigid
@@ -4077,7 +4234,7 @@ function renderBitfield(b,y0){
4077
4234
  const cap=bi===0?String(f.name)+suffix:CONT;
4078
4235
  let lead=bx[0]; for(const s of bx) if(s.w>lead.w) lead=s;
4079
4236
  const ly=flat ? bx[0].y+bx.length*rh/2 : lead.y+rh/2;
4080
- let fs=11; const need=cap.length*6.2;
4237
+ let fs=11; const need=cw(cap)*6.2;
4081
4238
  // The font shrinks to fit the segment that carries it, floor 7px — the
4082
4239
  // rule a too-narrow one-row field has always had. Below the floor the
4083
4240
  // caption overflows its box rather than vanishing: a name half outside
@@ -4171,7 +4328,7 @@ function renderBitfield(b,y0){
4171
4328
  // job.
4172
4329
  svg.push('<line x1="'+e.x+'" y1="'+e.y+'" x2="'+e.x+'" y2="'+(e.y+EL_H)+'" stroke="'+st+'" stroke-dasharray="2 3"/>');
4173
4330
  svg.push('<line x1="'+(e.x+e.w)+'" y1="'+e.y+'" x2="'+(e.x+e.w)+'" y2="'+(e.y+EL_H)+'" stroke="'+st+'" stroke-dasharray="2 3"/>');
4174
- let fs=10.5; const need=e.text.length*6.2;
4331
+ let fs=10.5; const need=cw(e.text)*6.2;
4175
4332
  if(need>e.w-6) fs=Math.max(7,10.5*(e.w-6)/need);
4176
4333
  svg.push('<text x="'+(e.x+e.w/2)+'" y="'+(e.y+EL_H/2+fs*0.35)+'" font-size="'+fs+'" text-anchor="middle" fill="#6f6e69">'+esc(e.text)+'</text>');
4177
4334
  }
@@ -4191,7 +4348,7 @@ function renderBitfield(b,y0){
4191
4348
  for(const f of capt){
4192
4349
  const s=f.name+' — present: '+f.present;
4193
4350
  svg.push('<text x="0" y="'+(yb+9)+'" font-size="10.5" fill="#6f6e69">'+esc(s)+'</text>');
4194
- wb=Math.max(wb, s.length*6.3+2); yb+=14;
4351
+ wb=Math.max(wb, cw(s)*6.3+2); yb+=14;
4195
4352
  }
4196
4353
  yb+=2;
4197
4354
  }