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.
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "figdown",
4
4
  "displayName": "FigDown",
5
- "version": "0.1.6",
5
+ "version": "0.1.8",
6
6
  "description": "Figures as text: create and maintain documentation figures as FigDown .fd sources with deterministic SVG artifacts embedded in Markdown — block diagrams, topologies, flowcharts, bit-level layouts, tables, timing waveforms.",
7
7
  "author": {
8
8
  "name": "FigDown",
package/dist/figdown.js CHANGED
@@ -1,4 +1,4 @@
1
- // figdown.js — FigDown embeddable library (0.1.6)
1
+ // figdown.js — FigDown embeddable library (0.1.8)
2
2
  // GENERATED FILE, DO NOT EDIT. Built from editor/figdown.html.
3
3
  // Regenerate with: node tools/make-lib.js
4
4
  (function (root, factory) {
@@ -10,7 +10,7 @@
10
10
  }
11
11
  }(typeof globalThis !== 'undefined' ? globalThis : this, function () {
12
12
  'use strict';
13
- var VERSION = "0.1.6";
13
+ var VERSION = "0.1.8";
14
14
 
15
15
  // ---- engine (extracted verbatim from editor/figdown.html) ----
16
16
  var __engine = (function () {
@@ -24,7 +24,7 @@ const SHAPES = ['box','rounded','circle','ellipse','diamond','cylinder'];
24
24
  // input to that promise, and under core §13 a 0.x renderer may differ from
25
25
  // the next — which makes the recorded version the only thing that can
26
26
  // explain a diff between two renderings of one source.
27
- const FIGDOWN_VERSION = '0.1.6';
27
+ const FIGDOWN_VERSION = '0.1.8';
28
28
  // Retired shape VALUES keep a named diagnostic (PROCESS §5(d)), the same way
29
29
  // retired option keys do: `cloud` was the one value that named a domain
30
30
  // (the internet cloud) in an enum the language keeps purely geometric
@@ -2358,6 +2358,80 @@ function findComment(s){
2358
2358
  // 2. LAYOUT + RENDER (deterministic; no randomness, no Date)
2359
2359
  // ============================================================
2360
2360
  const FONT=13, CH=7.2, PADX=14, NH=36, GAPX=56, GAPY=44;
2361
+
2362
+ // ---- cw(): script-aware advance width --------------------------------
2363
+ // Every px-per-character constant in this file — CH here, and its siblings at
2364
+ // the other font sizes (8.6 title, 6.6 legend, 6.5 edge label, 6.3/6.2 bitfield
2365
+ // caption) — is calibrated on LATIN, where one character advances ~0.554 em.
2366
+ // That is a claim about SCRIPT, not about font: a CJK ideograph advances a full
2367
+ // em, so counting characters undersizes a Chinese label by 45% and the engine
2368
+ // draws the text outside the box it has just sized for it. cw() is the measure
2369
+ // those constants multiply — a string's advance in Latin units. Every codepoint
2370
+ // below U+0300 weighs exactly 1, so an all-ASCII string returns exactly its
2371
+ // .length and every figure that was already right stays byte-identical.
2372
+ //
2373
+ // The classes are Unicode East Asian Width (UAX #11), a fixed character
2374
+ // property: a lookup by codepoint range, deterministic, no font-metrics library
2375
+ // and no canvas measurement at render time. The WEIGHTS are measured, not
2376
+ // chosen — probe strings rendered through librsvg at font-size 13 in the
2377
+ // engine's own stack (font-family="system-ui,sans-serif" → Noto Sans + Noto
2378
+ // Sans CJK), advance recovered as the slope of ink extent over repeat count:
2379
+ // ideograph 封 一 㐀, kana あ ア, bopomofo ㄅ, fullwidth A ,, U+3000 13.000px = 1.000em
2380
+ // emoji U+1F600 U+1F680 (Noto Color Emoji) 16.188px = 1.245em
2381
+ // combining U+0301 standalone, U+200B, U+FEFF 0.000px = 0.000em
2382
+ // Latin — the calibration these constants already carry 7.200px = 0.554em
2383
+ // hence WIDE = 1.000/0.554 = 1.806, rounded UP to 1.81 so a wide glyph is never
2384
+ // UNDER-measured at the node-label site, and EMOJI = 1.245/0.554 = 2.25.
2385
+ // Halfwidth katakana (U+FF61..FF9F) measured 0.500 em: it stays in the default
2386
+ // class, which over-measures it by 11% — the safe direction.
2387
+ //
2388
+ // EAW *Ambiguous* — §, —, –, →, ←, ✓, ≤, •, the only non-ASCII this
2389
+ // repository's figures actually draw — is deliberately NARROW, the
2390
+ // wcwidth/POSIX reading for a non-East-Asian context. Measured, an em dash is
2391
+ // a full em in this font, so a label carrying one is still under-measured by
2392
+ // 0.45 unit; that is a different question from this defect (it needs a
2393
+ // context rule, not a table) and is not decided here.
2394
+ const CW_ZERO=[[0x0300,0x036F],[0x0483,0x0489],[0x0591,0x05BD],[0x0610,0x061A],
2395
+ [0x064B,0x065F],[0x0670,0x0670],[0x06D6,0x06DC],[0x0711,0x0711],[0x0730,0x074A],
2396
+ [0x07A6,0x07B0],[0x0816,0x0819],[0x093C,0x093C],[0x0951,0x0954],[0x0E31,0x0E31],
2397
+ [0x0E34,0x0E3A],[0x0E47,0x0E4E],[0x1AB0,0x1AFF],[0x1DC0,0x1DFF],[0x200B,0x200F],
2398
+ [0x2060,0x2064],[0x20D0,0x20F0],[0xFE00,0xFE0F],[0xFE20,0xFE2F],[0xFEFF,0xFEFF]];
2399
+ const CW_EMOJI=[[0x1F004,0x1F0CF],[0x1F18E,0x1F19A],[0x1F300,0x1F5FF],
2400
+ [0x1F600,0x1F64F],[0x1F680,0x1F6FF],[0x1F7E0,0x1F7EB],[0x1F900,0x1F9FF],
2401
+ [0x1FA70,0x1FAFF]];
2402
+ const CW_WIDE=[[0x1100,0x115F],[0x2E80,0x303E],[0x3041,0x33FF],[0x3400,0x4DBF],
2403
+ [0x4E00,0x9FFF],[0xA000,0xA4CF],[0xA960,0xA97F],[0xAC00,0xD7A3],[0xF900,0xFAFF],
2404
+ [0xFE10,0xFE19],[0xFE30,0xFE6F],[0xFF01,0xFF60],[0xFFE0,0xFFE6],
2405
+ [0x17000,0x18AFF],[0x1B000,0x1B2FF],[0x20000,0x3FFFD]];
2406
+ // ranges are sorted and disjoint, so the scan stops at the first range that
2407
+ // starts above the codepoint
2408
+ function cwIn(cp,rs){
2409
+ for(let i=0;i<rs.length;i++){ if(cp<rs[i][0]) return false; if(cp<=rs[i][1]) return true; }
2410
+ return false;
2411
+ }
2412
+ function cwOf(cp){
2413
+ if(cp<0x0300) return 1; // ASCII and Latin-1: the calibrated case
2414
+ if(cwIn(cp,CW_ZERO)) return 0;
2415
+ if(cwIn(cp,CW_EMOJI)) return 2.25;
2416
+ if(cwIn(cp,CW_WIDE)) return 1.81;
2417
+ return 1;
2418
+ }
2419
+ // Iterate CODEPOINTS, not UTF-16 units: an astral character is one advance,
2420
+ // not two. A ZWJ emoji sequence still counts each codepoint, so it is
2421
+ // over-measured — the safe direction, and the only way to avoid it is a
2422
+ // grapheme segmenter this engine will not carry.
2423
+ function cw(s){
2424
+ s=String(s); let w=0;
2425
+ for(let i=0;i<s.length;i++){
2426
+ const cp=s.codePointAt(i);
2427
+ if(cp>0xFFFF) i++;
2428
+ w+=cwOf(cp);
2429
+ }
2430
+ return w;
2431
+ }
2432
+ // the widest line of a multi-line string, in Latin units
2433
+ function cwMax(s){ return Math.max(...String(s).split('\n').map(cw)); }
2434
+
2361
2435
  // §5 style= → SVG dash pattern. `def` is the construct's conventional
2362
2436
  // default (the bundle ring and the threshold line are dashed by convention);
2363
2437
  // an explicit style= always wins.
@@ -2367,7 +2441,7 @@ function dashOf(style,def){
2367
2441
  }
2368
2442
  function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
2369
2443
  function tw(s){
2370
- const longest=Math.max(...String(s).split('\n').map(l=>l.length));
2444
+ const longest=cwMax(s);
2371
2445
  return Math.max(30,longest*CH+2*PADX);
2372
2446
  }
2373
2447
  // ---- shape geometry (one source of truth for size and for clipping) ----
@@ -2486,7 +2560,7 @@ function inscribedHalfW(g,v){
2486
2560
  const LBLPADX=6, LBLPADY=4;
2487
2561
  function labelBox(label){
2488
2562
  const ls=String(label).split('\n');
2489
- return [Math.max(...ls.map(l=>l.length))*CH+2*LBLPADX,
2563
+ return [Math.max(...ls.map(cw))*CH+2*LBLPADX,
2490
2564
  ((ls.length-1)*1.3+1.2)*FONT+2*LBLPADY];
2491
2565
  }
2492
2566
  // fitOutline: grow the box by the smallest factor that pulls an iw x ih box,
@@ -2578,7 +2652,7 @@ function render(doc,ropts){
2578
2652
  }
2579
2653
  const parts=[]; let y=0, maxW=0;
2580
2654
  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;
2581
- maxW=Math.max(maxW, doc.title.length*8.6); } // canvas must fit the title
2655
+ maxW=Math.max(maxW, cw(doc.title)*8.6); } // canvas must fit the title
2582
2656
  let sceneMeta=null;
2583
2657
  if(doc.nodes.length||doc.edges.length||(doc.boundaries||[]).length){
2584
2658
  const s=renderScene(doc,y); parts.push(s.svg); y=s.y; maxW=Math.max(maxW,s.w);
@@ -2616,7 +2690,7 @@ function render(doc,ropts){
2616
2690
  // `style`) is drawn, so "declared but not shown" is now unreachable.
2617
2691
  for(const c of legendCls){
2618
2692
  const paints=c.fill!==undefined||c.stroke!==undefined||c.style!==undefined;
2619
- const tw=String(c.label).length*6.6+(paints?30:9);
2693
+ const tw=cw(c.label)*6.6+(paints?30:9);
2620
2694
  if(lx>0 && lx+tw>wrapW){ lx=0; ly+=rowH; }
2621
2695
  if(paints){
2622
2696
  const dash=c.style==='dashed'?' stroke-dasharray="6 4"':(c.style==='dotted'?' stroke-dasharray="2 4"':'');
@@ -2729,7 +2803,7 @@ function renderScene(doc,y0){
2729
2803
  // ALL nodes participate in auto layout (pinned ones hold their slot so
2730
2804
  // that pinning X never reflows Y); pins override coordinates afterwards.
2731
2805
  const horiz=(doc.flow==='right'||doc.flow==='left');
2732
- const lblPx=s=>Math.max(...String(s).split('\n').map(l=>l.length))*6.5;
2806
+ const lblPx=s=>cwMax(s)*6.5;
2733
2807
  const lay=[...nodes]; // layout participants
2734
2808
  const chains=new Map(); // edge -> [A, ...waypoints, B]
2735
2809
  for(const e of doc.edges){
@@ -3112,6 +3186,45 @@ function renderScene(doc,y0){
3112
3186
  const halo=' paint-order="stroke" stroke="#fff" stroke-width="3"';
3113
3187
  const seg=(p,q,t,lbl,fs)=>reqLabel({p,q,t0:t,text:lbl,fs,col:ecol,halo,e,A,B,kind:'end'});
3114
3188
  if(isBack.has(e)&&!pinned(e.a)&&!pinned(e.b)){
3189
+ // ── ROUTING-CHANGE ARCHITECTURE NOTE ──────────
3190
+ // Edge labels are DEFERRED: every label is registered against its
3191
+ // FINAL segment geometry (reqLabel/lblReq above) and placed by ONE
3192
+ // greedy pass after all edges are drawn; arrowheads are computed from
3193
+ // final terminal points (arrowTri). Therefore a routing change made
3194
+ // HERE — upstream of reqLabel/noteSegs/arrowTri — gets label and
3195
+ // arrowhead re-placement for free. A change spliced into the emitted
3196
+ // SVG downstream, or made outside the engine, will strand both.
3197
+ // Two prototype rounds re-derived this the hard way (floating
3198
+ // arrowheads, orphaned labels): that was external splicing, not an
3199
+ // engine gap. Patch routing here; do not "fix" the label machinery.
3200
+ if(A===B){
3201
+ // Self-transition: a small side loop on the node, the
3202
+ // convention of every drawing tool — never a lap of the figure
3203
+ // through the back-edge channel. Side order r,l,b,t; first side
3204
+ // whose loop box overlaps no other node wins (deterministic).
3205
+ const scy=A.y+A.h/2, scx=A.x+A.w/2;
3206
+ 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]]
3207
+ :sd==='l'?[[A.x,scy-8],[A.x-20,scy-8],[A.x-20,scy+8],[A.x,scy+8]]
3208
+ :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]]
3209
+ :[[scx-8,A.y],[scx-8,A.y-20],[scx+8,A.y-20],[scx+8,A.y]];
3210
+ const loopHit=pp=>{
3211
+ const x0=Math.min(...pp.map(p=>p[0]))-2, x1=Math.max(...pp.map(p=>p[0]))+2;
3212
+ const z0=Math.min(...pp.map(p=>p[1]))-2, z1=Math.max(...pp.map(p=>p[1]))+2;
3213
+ return nodes.some(n=>n!==A&&!n.boundary&&n.x<x1&&n.x+n.w>x0&&n.y<z1&&n.y+n.h>z0);
3214
+ };
3215
+ let sp=null;
3216
+ for(const sd of ['r','l','b','t']){ const pp=mkLoop(sd); if(!loopHit(pp)){ sp=pp; break; } }
3217
+ if(!sp) sp=mkLoop('r');
3218
+ for(const p of sp){ W=Math.max(W,p[0]+4); Hh=Math.max(Hh,p[1]+16-y0-20); }
3219
+ esvg.push('<path data-edge="'+e.line+'" d="'+roundPath(sp)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
3220
+ noteSegs(e,sp);
3221
+ 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});
3222
+ if(e.tail) seg(sp[0],sp[1],0.5,e.tail,10);
3223
+ if(e.head) seg(sp[3],sp[2],0.5,e.head,10);
3224
+ if(wantsStart) arrowTri(sp[0],sp[1],col);
3225
+ if(wantsEnd) arrowTri(sp[sp.length-1],sp[sp.length-2],col);
3226
+ continue;
3227
+ }
3115
3228
  // back-edge (retry loop): polyline through a side channel beyond the
3116
3229
  // occupied lanes instead of a straight line hidden under the spine,
3117
3230
  // using the nested slot from the channel plan. Ring-eligible loops
@@ -3238,6 +3351,50 @@ function renderScene(doc,y0){
3238
3351
  // their neighbours turns a 40-point staircase into the 2–4 bends a
3239
3352
  // dummy-vertex chain should have, without moving the drawn line.
3240
3353
  simplifyPts(pts);
3354
+ // Waypoint prune: after collinear simplification a
3355
+ // chain can still carry a staircase of near-collinear jogs — the drift
3356
+ // clamp allows only a few px of sideways movement per rank, so a run
3357
+ // that wants to move 35px sideways alternates short diagonals and
3358
+ // verticals that Douglas-Peucker cannot collapse (each angle differs).
3359
+ // The rule: an interior waypoint SURVIVES only if removing it would
3360
+ // make the edge pierce something. Obstacles are the same set the
3361
+ // straight-edge router uses below: non-incident nodes, plus group
3362
+ // boxes when NEITHER endpoint lies inside (a run through a group
3363
+ // interior asserts membership the figure does not declare). Fixed
3364
+ // left-to-right scan to fixpoint — deterministic. Endpoints are then
3365
+ // re-derived so the edge leaves its node facing the first bend, and
3366
+ // each re-derived terminal segment is re-checked: if it would hit an
3367
+ // obstacle the old endpoint stays.
3368
+ {
3369
+ 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}));
3370
+ const ep0=pts[0], ep1=pts[pts.length-1];
3371
+ for(const k in gBox){
3372
+ const b=gBox[k];
3373
+ const inG=(px,py)=>px>b.x0&&px<b.x1&&py>b.yA&&py<b.yB;
3374
+ if(!inG(ep0[0],ep0[1])&&!inG(ep1[0],ep1[1]))
3375
+ pobs.push({x:b.x0,y:b.yA,w:b.x1-b.x0,h:b.yB-b.yA});
3376
+ }
3377
+ let ch=true, pruned=false;
3378
+ while(ch){ ch=false;
3379
+ for(let i=1;i+1<pts.length;i++)
3380
+ if(!segHitsObs(pts[i-1],pts[i+1],pobs)){ pts.splice(i,1); ch=true; pruned=true; i--; }
3381
+ }
3382
+ // untouched chains stay byte-identical: endpoints and the label
3383
+ // segment are only re-derived when the prune removed a waypoint
3384
+ if(pruned){
3385
+ const nb0=borderPoint(A,pts[1][0],pts[1][1]);
3386
+ if(!segHitsObs(nb0,pts[1],pobs)) pts[0]=nb0;
3387
+ const nb1=borderPoint(B,pts[pts.length-2][0],pts[pts.length-2][1]);
3388
+ if(!segHitsObs(nb1,pts[pts.length-2],pobs)) pts[pts.length-1]=nb1;
3389
+ if(midSeg){ let bi=0,bl=-1;
3390
+ for(let i=0;i+1<pts.length;i++){
3391
+ const l=Math.hypot(pts[i+1][0]-pts[i][0],pts[i+1][1]-pts[i][1]);
3392
+ if(l>bl){ bl=l; bi=i; }
3393
+ }
3394
+ midSeg=[pts[bi],pts[bi+1]];
3395
+ }
3396
+ }
3397
+ }
3241
3398
  esvg.push('<path data-edge="'+e.line+'" d="'+roundPath(pts)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
3242
3399
  noteSegs(e,pts);
3243
3400
  if(midSeg) reqLabel({p:midSeg[0],q:midSeg[1],text:e.mid,fs:11,col:lcol,halo,e,A,B,kind:'mid',first:false});
@@ -3330,7 +3487,7 @@ function renderScene(doc,y0){
3330
3487
  const CLAMP=t=>Math.max(0.06,Math.min(0.94,t));
3331
3488
  const cand=(r,t,side)=>{
3332
3489
  const lines=String(r.text).split('\n'), n=lines.length;
3333
- const w=Math.max(...lines.map(l=>l.length))*6.5*r.fs/11;
3490
+ const w=Math.max(...lines.map(cw))*6.5*r.fs/11;
3334
3491
  const lh=r.fs*1.3, h=(n-1)*lh+r.fs*1.1;
3335
3492
  const up=(n-1)*lh/2+r.fs*0.85; // baseline y = box top + up
3336
3493
  const mx=r.p[0]+(r.q[0]-r.p[0])*t, my=r.p[1]+(r.q[1]-r.p[1])*t;
@@ -3436,7 +3593,7 @@ function renderScene(doc,y0){
3436
3593
  // label with shrink-to-fit when size is rigid (`UNDECLARED-ATTRIBUTE-BEHAVIOUR`); multi-line via "\n"
3437
3594
  let fs=FONT;
3438
3595
  const nl=String(n.label).split('\n');
3439
- const need=Math.max(...nl.map(l=>l.length))*CH;
3596
+ const need=Math.max(...nl.map(cw))*CH;
3440
3597
  // budget = the width the OUTLINE offers at the label's own height, minus
3441
3598
  // 8 px clearance each side. For a box that is n.w-16, exactly as before;
3442
3599
  // for a rhombus or an ellipse it is the inscribed width, so a rigid
@@ -3980,7 +4137,7 @@ function renderBitfield(b,y0){
3980
4137
  const cap=bi===0?String(f.name)+suffix:CONT;
3981
4138
  let lead=bx[0]; for(const s of bx) if(s.w>lead.w) lead=s;
3982
4139
  const ly=flat ? bx[0].y+bx.length*rh/2 : lead.y+rh/2;
3983
- let fs=11; const need=cap.length*6.2;
4140
+ let fs=11; const need=cw(cap)*6.2;
3984
4141
  // The font shrinks to fit the segment that carries it, floor 7px — the
3985
4142
  // rule a too-narrow one-row field has always had. Below the floor the
3986
4143
  // caption overflows its box rather than vanishing: a name half outside
@@ -4074,7 +4231,7 @@ function renderBitfield(b,y0){
4074
4231
  // job.
4075
4232
  svg.push('<line x1="'+e.x+'" y1="'+e.y+'" x2="'+e.x+'" y2="'+(e.y+EL_H)+'" stroke="'+st+'" stroke-dasharray="2 3"/>');
4076
4233
  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"/>');
4077
- let fs=10.5; const need=e.text.length*6.2;
4234
+ let fs=10.5; const need=cw(e.text)*6.2;
4078
4235
  if(need>e.w-6) fs=Math.max(7,10.5*(e.w-6)/need);
4079
4236
  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>');
4080
4237
  }
@@ -4094,7 +4251,7 @@ function renderBitfield(b,y0){
4094
4251
  for(const f of capt){
4095
4252
  const s=f.name+' — present: '+f.present;
4096
4253
  svg.push('<text x="0" y="'+(yb+9)+'" font-size="10.5" fill="#6f6e69">'+esc(s)+'</text>');
4097
- wb=Math.max(wb, s.length*6.3+2); yb+=14;
4254
+ wb=Math.max(wb, cw(s)*6.3+2); yb+=14;
4098
4255
  }
4099
4256
  yb+=2;
4100
4257
  }
package/dist/figdown.mjs CHANGED
@@ -1,8 +1,8 @@
1
- // figdown.mjs — FigDown embeddable library (0.1.6)
1
+ // figdown.mjs — FigDown embeddable library (0.1.8)
2
2
  // GENERATED FILE, DO NOT EDIT. Built from editor/figdown.html.
3
3
  // Regenerate with: node tools/make-lib.js
4
4
  'use strict';
5
- var VERSION = "0.1.6";
5
+ var VERSION = "0.1.8";
6
6
 
7
7
  // ---- engine (extracted verbatim from editor/figdown.html) ----
8
8
  var __engine = (function () {
@@ -16,7 +16,7 @@ const SHAPES = ['box','rounded','circle','ellipse','diamond','cylinder'];
16
16
  // input to that promise, and under core §13 a 0.x renderer may differ from
17
17
  // the next — which makes the recorded version the only thing that can
18
18
  // explain a diff between two renderings of one source.
19
- const FIGDOWN_VERSION = '0.1.6';
19
+ const FIGDOWN_VERSION = '0.1.8';
20
20
  // Retired shape VALUES keep a named diagnostic (PROCESS §5(d)), the same way
21
21
  // retired option keys do: `cloud` was the one value that named a domain
22
22
  // (the internet cloud) in an enum the language keeps purely geometric
@@ -2350,6 +2350,80 @@ function findComment(s){
2350
2350
  // 2. LAYOUT + RENDER (deterministic; no randomness, no Date)
2351
2351
  // ============================================================
2352
2352
  const FONT=13, CH=7.2, PADX=14, NH=36, GAPX=56, GAPY=44;
2353
+
2354
+ // ---- cw(): script-aware advance width --------------------------------
2355
+ // Every px-per-character constant in this file — CH here, and its siblings at
2356
+ // the other font sizes (8.6 title, 6.6 legend, 6.5 edge label, 6.3/6.2 bitfield
2357
+ // caption) — is calibrated on LATIN, where one character advances ~0.554 em.
2358
+ // That is a claim about SCRIPT, not about font: a CJK ideograph advances a full
2359
+ // em, so counting characters undersizes a Chinese label by 45% and the engine
2360
+ // draws the text outside the box it has just sized for it. cw() is the measure
2361
+ // those constants multiply — a string's advance in Latin units. Every codepoint
2362
+ // below U+0300 weighs exactly 1, so an all-ASCII string returns exactly its
2363
+ // .length and every figure that was already right stays byte-identical.
2364
+ //
2365
+ // The classes are Unicode East Asian Width (UAX #11), a fixed character
2366
+ // property: a lookup by codepoint range, deterministic, no font-metrics library
2367
+ // and no canvas measurement at render time. The WEIGHTS are measured, not
2368
+ // chosen — probe strings rendered through librsvg at font-size 13 in the
2369
+ // engine's own stack (font-family="system-ui,sans-serif" → Noto Sans + Noto
2370
+ // Sans CJK), advance recovered as the slope of ink extent over repeat count:
2371
+ // ideograph 封 一 㐀, kana あ ア, bopomofo ㄅ, fullwidth A ,, U+3000 13.000px = 1.000em
2372
+ // emoji U+1F600 U+1F680 (Noto Color Emoji) 16.188px = 1.245em
2373
+ // combining U+0301 standalone, U+200B, U+FEFF 0.000px = 0.000em
2374
+ // Latin — the calibration these constants already carry 7.200px = 0.554em
2375
+ // hence WIDE = 1.000/0.554 = 1.806, rounded UP to 1.81 so a wide glyph is never
2376
+ // UNDER-measured at the node-label site, and EMOJI = 1.245/0.554 = 2.25.
2377
+ // Halfwidth katakana (U+FF61..FF9F) measured 0.500 em: it stays in the default
2378
+ // class, which over-measures it by 11% — the safe direction.
2379
+ //
2380
+ // EAW *Ambiguous* — §, —, –, →, ←, ✓, ≤, •, the only non-ASCII this
2381
+ // repository's figures actually draw — is deliberately NARROW, the
2382
+ // wcwidth/POSIX reading for a non-East-Asian context. Measured, an em dash is
2383
+ // a full em in this font, so a label carrying one is still under-measured by
2384
+ // 0.45 unit; that is a different question from this defect (it needs a
2385
+ // context rule, not a table) and is not decided here.
2386
+ const CW_ZERO=[[0x0300,0x036F],[0x0483,0x0489],[0x0591,0x05BD],[0x0610,0x061A],
2387
+ [0x064B,0x065F],[0x0670,0x0670],[0x06D6,0x06DC],[0x0711,0x0711],[0x0730,0x074A],
2388
+ [0x07A6,0x07B0],[0x0816,0x0819],[0x093C,0x093C],[0x0951,0x0954],[0x0E31,0x0E31],
2389
+ [0x0E34,0x0E3A],[0x0E47,0x0E4E],[0x1AB0,0x1AFF],[0x1DC0,0x1DFF],[0x200B,0x200F],
2390
+ [0x2060,0x2064],[0x20D0,0x20F0],[0xFE00,0xFE0F],[0xFE20,0xFE2F],[0xFEFF,0xFEFF]];
2391
+ const CW_EMOJI=[[0x1F004,0x1F0CF],[0x1F18E,0x1F19A],[0x1F300,0x1F5FF],
2392
+ [0x1F600,0x1F64F],[0x1F680,0x1F6FF],[0x1F7E0,0x1F7EB],[0x1F900,0x1F9FF],
2393
+ [0x1FA70,0x1FAFF]];
2394
+ const CW_WIDE=[[0x1100,0x115F],[0x2E80,0x303E],[0x3041,0x33FF],[0x3400,0x4DBF],
2395
+ [0x4E00,0x9FFF],[0xA000,0xA4CF],[0xA960,0xA97F],[0xAC00,0xD7A3],[0xF900,0xFAFF],
2396
+ [0xFE10,0xFE19],[0xFE30,0xFE6F],[0xFF01,0xFF60],[0xFFE0,0xFFE6],
2397
+ [0x17000,0x18AFF],[0x1B000,0x1B2FF],[0x20000,0x3FFFD]];
2398
+ // ranges are sorted and disjoint, so the scan stops at the first range that
2399
+ // starts above the codepoint
2400
+ function cwIn(cp,rs){
2401
+ for(let i=0;i<rs.length;i++){ if(cp<rs[i][0]) return false; if(cp<=rs[i][1]) return true; }
2402
+ return false;
2403
+ }
2404
+ function cwOf(cp){
2405
+ if(cp<0x0300) return 1; // ASCII and Latin-1: the calibrated case
2406
+ if(cwIn(cp,CW_ZERO)) return 0;
2407
+ if(cwIn(cp,CW_EMOJI)) return 2.25;
2408
+ if(cwIn(cp,CW_WIDE)) return 1.81;
2409
+ return 1;
2410
+ }
2411
+ // Iterate CODEPOINTS, not UTF-16 units: an astral character is one advance,
2412
+ // not two. A ZWJ emoji sequence still counts each codepoint, so it is
2413
+ // over-measured — the safe direction, and the only way to avoid it is a
2414
+ // grapheme segmenter this engine will not carry.
2415
+ function cw(s){
2416
+ s=String(s); let w=0;
2417
+ for(let i=0;i<s.length;i++){
2418
+ const cp=s.codePointAt(i);
2419
+ if(cp>0xFFFF) i++;
2420
+ w+=cwOf(cp);
2421
+ }
2422
+ return w;
2423
+ }
2424
+ // the widest line of a multi-line string, in Latin units
2425
+ function cwMax(s){ return Math.max(...String(s).split('\n').map(cw)); }
2426
+
2353
2427
  // §5 style= → SVG dash pattern. `def` is the construct's conventional
2354
2428
  // default (the bundle ring and the threshold line are dashed by convention);
2355
2429
  // an explicit style= always wins.
@@ -2359,7 +2433,7 @@ function dashOf(style,def){
2359
2433
  }
2360
2434
  function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
2361
2435
  function tw(s){
2362
- const longest=Math.max(...String(s).split('\n').map(l=>l.length));
2436
+ const longest=cwMax(s);
2363
2437
  return Math.max(30,longest*CH+2*PADX);
2364
2438
  }
2365
2439
  // ---- shape geometry (one source of truth for size and for clipping) ----
@@ -2478,7 +2552,7 @@ function inscribedHalfW(g,v){
2478
2552
  const LBLPADX=6, LBLPADY=4;
2479
2553
  function labelBox(label){
2480
2554
  const ls=String(label).split('\n');
2481
- return [Math.max(...ls.map(l=>l.length))*CH+2*LBLPADX,
2555
+ return [Math.max(...ls.map(cw))*CH+2*LBLPADX,
2482
2556
  ((ls.length-1)*1.3+1.2)*FONT+2*LBLPADY];
2483
2557
  }
2484
2558
  // fitOutline: grow the box by the smallest factor that pulls an iw x ih box,
@@ -2570,7 +2644,7 @@ function render(doc,ropts){
2570
2644
  }
2571
2645
  const parts=[]; let y=0, maxW=0;
2572
2646
  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;
2573
- maxW=Math.max(maxW, doc.title.length*8.6); } // canvas must fit the title
2647
+ maxW=Math.max(maxW, cw(doc.title)*8.6); } // canvas must fit the title
2574
2648
  let sceneMeta=null;
2575
2649
  if(doc.nodes.length||doc.edges.length||(doc.boundaries||[]).length){
2576
2650
  const s=renderScene(doc,y); parts.push(s.svg); y=s.y; maxW=Math.max(maxW,s.w);
@@ -2608,7 +2682,7 @@ function render(doc,ropts){
2608
2682
  // `style`) is drawn, so "declared but not shown" is now unreachable.
2609
2683
  for(const c of legendCls){
2610
2684
  const paints=c.fill!==undefined||c.stroke!==undefined||c.style!==undefined;
2611
- const tw=String(c.label).length*6.6+(paints?30:9);
2685
+ const tw=cw(c.label)*6.6+(paints?30:9);
2612
2686
  if(lx>0 && lx+tw>wrapW){ lx=0; ly+=rowH; }
2613
2687
  if(paints){
2614
2688
  const dash=c.style==='dashed'?' stroke-dasharray="6 4"':(c.style==='dotted'?' stroke-dasharray="2 4"':'');
@@ -2721,7 +2795,7 @@ function renderScene(doc,y0){
2721
2795
  // ALL nodes participate in auto layout (pinned ones hold their slot so
2722
2796
  // that pinning X never reflows Y); pins override coordinates afterwards.
2723
2797
  const horiz=(doc.flow==='right'||doc.flow==='left');
2724
- const lblPx=s=>Math.max(...String(s).split('\n').map(l=>l.length))*6.5;
2798
+ const lblPx=s=>cwMax(s)*6.5;
2725
2799
  const lay=[...nodes]; // layout participants
2726
2800
  const chains=new Map(); // edge -> [A, ...waypoints, B]
2727
2801
  for(const e of doc.edges){
@@ -3104,6 +3178,45 @@ function renderScene(doc,y0){
3104
3178
  const halo=' paint-order="stroke" stroke="#fff" stroke-width="3"';
3105
3179
  const seg=(p,q,t,lbl,fs)=>reqLabel({p,q,t0:t,text:lbl,fs,col:ecol,halo,e,A,B,kind:'end'});
3106
3180
  if(isBack.has(e)&&!pinned(e.a)&&!pinned(e.b)){
3181
+ // ── ROUTING-CHANGE ARCHITECTURE NOTE ──────────
3182
+ // Edge labels are DEFERRED: every label is registered against its
3183
+ // FINAL segment geometry (reqLabel/lblReq above) and placed by ONE
3184
+ // greedy pass after all edges are drawn; arrowheads are computed from
3185
+ // final terminal points (arrowTri). Therefore a routing change made
3186
+ // HERE — upstream of reqLabel/noteSegs/arrowTri — gets label and
3187
+ // arrowhead re-placement for free. A change spliced into the emitted
3188
+ // SVG downstream, or made outside the engine, will strand both.
3189
+ // Two prototype rounds re-derived this the hard way (floating
3190
+ // arrowheads, orphaned labels): that was external splicing, not an
3191
+ // engine gap. Patch routing here; do not "fix" the label machinery.
3192
+ if(A===B){
3193
+ // Self-transition: a small side loop on the node, the
3194
+ // convention of every drawing tool — never a lap of the figure
3195
+ // through the back-edge channel. Side order r,l,b,t; first side
3196
+ // whose loop box overlaps no other node wins (deterministic).
3197
+ const scy=A.y+A.h/2, scx=A.x+A.w/2;
3198
+ 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]]
3199
+ :sd==='l'?[[A.x,scy-8],[A.x-20,scy-8],[A.x-20,scy+8],[A.x,scy+8]]
3200
+ :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]]
3201
+ :[[scx-8,A.y],[scx-8,A.y-20],[scx+8,A.y-20],[scx+8,A.y]];
3202
+ const loopHit=pp=>{
3203
+ const x0=Math.min(...pp.map(p=>p[0]))-2, x1=Math.max(...pp.map(p=>p[0]))+2;
3204
+ const z0=Math.min(...pp.map(p=>p[1]))-2, z1=Math.max(...pp.map(p=>p[1]))+2;
3205
+ return nodes.some(n=>n!==A&&!n.boundary&&n.x<x1&&n.x+n.w>x0&&n.y<z1&&n.y+n.h>z0);
3206
+ };
3207
+ let sp=null;
3208
+ for(const sd of ['r','l','b','t']){ const pp=mkLoop(sd); if(!loopHit(pp)){ sp=pp; break; } }
3209
+ if(!sp) sp=mkLoop('r');
3210
+ for(const p of sp){ W=Math.max(W,p[0]+4); Hh=Math.max(Hh,p[1]+16-y0-20); }
3211
+ esvg.push('<path data-edge="'+e.line+'" d="'+roundPath(sp)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
3212
+ noteSegs(e,sp);
3213
+ 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});
3214
+ if(e.tail) seg(sp[0],sp[1],0.5,e.tail,10);
3215
+ if(e.head) seg(sp[3],sp[2],0.5,e.head,10);
3216
+ if(wantsStart) arrowTri(sp[0],sp[1],col);
3217
+ if(wantsEnd) arrowTri(sp[sp.length-1],sp[sp.length-2],col);
3218
+ continue;
3219
+ }
3107
3220
  // back-edge (retry loop): polyline through a side channel beyond the
3108
3221
  // occupied lanes instead of a straight line hidden under the spine,
3109
3222
  // using the nested slot from the channel plan. Ring-eligible loops
@@ -3230,6 +3343,50 @@ function renderScene(doc,y0){
3230
3343
  // their neighbours turns a 40-point staircase into the 2–4 bends a
3231
3344
  // dummy-vertex chain should have, without moving the drawn line.
3232
3345
  simplifyPts(pts);
3346
+ // Waypoint prune: after collinear simplification a
3347
+ // chain can still carry a staircase of near-collinear jogs — the drift
3348
+ // clamp allows only a few px of sideways movement per rank, so a run
3349
+ // that wants to move 35px sideways alternates short diagonals and
3350
+ // verticals that Douglas-Peucker cannot collapse (each angle differs).
3351
+ // The rule: an interior waypoint SURVIVES only if removing it would
3352
+ // make the edge pierce something. Obstacles are the same set the
3353
+ // straight-edge router uses below: non-incident nodes, plus group
3354
+ // boxes when NEITHER endpoint lies inside (a run through a group
3355
+ // interior asserts membership the figure does not declare). Fixed
3356
+ // left-to-right scan to fixpoint — deterministic. Endpoints are then
3357
+ // re-derived so the edge leaves its node facing the first bend, and
3358
+ // each re-derived terminal segment is re-checked: if it would hit an
3359
+ // obstacle the old endpoint stays.
3360
+ {
3361
+ 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}));
3362
+ const ep0=pts[0], ep1=pts[pts.length-1];
3363
+ for(const k in gBox){
3364
+ const b=gBox[k];
3365
+ const inG=(px,py)=>px>b.x0&&px<b.x1&&py>b.yA&&py<b.yB;
3366
+ if(!inG(ep0[0],ep0[1])&&!inG(ep1[0],ep1[1]))
3367
+ pobs.push({x:b.x0,y:b.yA,w:b.x1-b.x0,h:b.yB-b.yA});
3368
+ }
3369
+ let ch=true, pruned=false;
3370
+ while(ch){ ch=false;
3371
+ for(let i=1;i+1<pts.length;i++)
3372
+ if(!segHitsObs(pts[i-1],pts[i+1],pobs)){ pts.splice(i,1); ch=true; pruned=true; i--; }
3373
+ }
3374
+ // untouched chains stay byte-identical: endpoints and the label
3375
+ // segment are only re-derived when the prune removed a waypoint
3376
+ if(pruned){
3377
+ const nb0=borderPoint(A,pts[1][0],pts[1][1]);
3378
+ if(!segHitsObs(nb0,pts[1],pobs)) pts[0]=nb0;
3379
+ const nb1=borderPoint(B,pts[pts.length-2][0],pts[pts.length-2][1]);
3380
+ if(!segHitsObs(nb1,pts[pts.length-2],pobs)) pts[pts.length-1]=nb1;
3381
+ if(midSeg){ let bi=0,bl=-1;
3382
+ for(let i=0;i+1<pts.length;i++){
3383
+ const l=Math.hypot(pts[i+1][0]-pts[i][0],pts[i+1][1]-pts[i][1]);
3384
+ if(l>bl){ bl=l; bi=i; }
3385
+ }
3386
+ midSeg=[pts[bi],pts[bi+1]];
3387
+ }
3388
+ }
3389
+ }
3233
3390
  esvg.push('<path data-edge="'+e.line+'" d="'+roundPath(pts)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
3234
3391
  noteSegs(e,pts);
3235
3392
  if(midSeg) reqLabel({p:midSeg[0],q:midSeg[1],text:e.mid,fs:11,col:lcol,halo,e,A,B,kind:'mid',first:false});
@@ -3322,7 +3479,7 @@ function renderScene(doc,y0){
3322
3479
  const CLAMP=t=>Math.max(0.06,Math.min(0.94,t));
3323
3480
  const cand=(r,t,side)=>{
3324
3481
  const lines=String(r.text).split('\n'), n=lines.length;
3325
- const w=Math.max(...lines.map(l=>l.length))*6.5*r.fs/11;
3482
+ const w=Math.max(...lines.map(cw))*6.5*r.fs/11;
3326
3483
  const lh=r.fs*1.3, h=(n-1)*lh+r.fs*1.1;
3327
3484
  const up=(n-1)*lh/2+r.fs*0.85; // baseline y = box top + up
3328
3485
  const mx=r.p[0]+(r.q[0]-r.p[0])*t, my=r.p[1]+(r.q[1]-r.p[1])*t;
@@ -3428,7 +3585,7 @@ function renderScene(doc,y0){
3428
3585
  // label with shrink-to-fit when size is rigid (`UNDECLARED-ATTRIBUTE-BEHAVIOUR`); multi-line via "\n"
3429
3586
  let fs=FONT;
3430
3587
  const nl=String(n.label).split('\n');
3431
- const need=Math.max(...nl.map(l=>l.length))*CH;
3588
+ const need=Math.max(...nl.map(cw))*CH;
3432
3589
  // budget = the width the OUTLINE offers at the label's own height, minus
3433
3590
  // 8 px clearance each side. For a box that is n.w-16, exactly as before;
3434
3591
  // for a rhombus or an ellipse it is the inscribed width, so a rigid
@@ -3972,7 +4129,7 @@ function renderBitfield(b,y0){
3972
4129
  const cap=bi===0?String(f.name)+suffix:CONT;
3973
4130
  let lead=bx[0]; for(const s of bx) if(s.w>lead.w) lead=s;
3974
4131
  const ly=flat ? bx[0].y+bx.length*rh/2 : lead.y+rh/2;
3975
- let fs=11; const need=cap.length*6.2;
4132
+ let fs=11; const need=cw(cap)*6.2;
3976
4133
  // The font shrinks to fit the segment that carries it, floor 7px — the
3977
4134
  // rule a too-narrow one-row field has always had. Below the floor the
3978
4135
  // caption overflows its box rather than vanishing: a name half outside
@@ -4066,7 +4223,7 @@ function renderBitfield(b,y0){
4066
4223
  // job.
4067
4224
  svg.push('<line x1="'+e.x+'" y1="'+e.y+'" x2="'+e.x+'" y2="'+(e.y+EL_H)+'" stroke="'+st+'" stroke-dasharray="2 3"/>');
4068
4225
  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"/>');
4069
- let fs=10.5; const need=e.text.length*6.2;
4226
+ let fs=10.5; const need=cw(e.text)*6.2;
4070
4227
  if(need>e.w-6) fs=Math.max(7,10.5*(e.w-6)/need);
4071
4228
  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>');
4072
4229
  }
@@ -4086,7 +4243,7 @@ function renderBitfield(b,y0){
4086
4243
  for(const f of capt){
4087
4244
  const s=f.name+' — present: '+f.present;
4088
4245
  svg.push('<text x="0" y="'+(yb+9)+'" font-size="10.5" fill="#6f6e69">'+esc(s)+'</text>');
4089
- wb=Math.max(wb, s.length*6.3+2); yb+=14;
4246
+ wb=Math.max(wb, cw(s)*6.3+2); yb+=14;
4090
4247
  }
4091
4248
  yb+=2;
4092
4249
  }