figdown 0.1.5 → 0.1.7

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.5",
5
+ "version": "0.1.7",
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.5)
1
+ // figdown.js — FigDown embeddable library (0.1.7)
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.5";
13
+ var VERSION = "0.1.7";
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.5';
27
+ const FIGDOWN_VERSION = '0.1.7';
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
@@ -3112,6 +3112,45 @@ function renderScene(doc,y0){
3112
3112
  const halo=' paint-order="stroke" stroke="#fff" stroke-width="3"';
3113
3113
  const seg=(p,q,t,lbl,fs)=>reqLabel({p,q,t0:t,text:lbl,fs,col:ecol,halo,e,A,B,kind:'end'});
3114
3114
  if(isBack.has(e)&&!pinned(e.a)&&!pinned(e.b)){
3115
+ // ── ROUTING-CHANGE ARCHITECTURE NOTE ──────────
3116
+ // Edge labels are DEFERRED: every label is registered against its
3117
+ // FINAL segment geometry (reqLabel/lblReq above) and placed by ONE
3118
+ // greedy pass after all edges are drawn; arrowheads are computed from
3119
+ // final terminal points (arrowTri). Therefore a routing change made
3120
+ // HERE — upstream of reqLabel/noteSegs/arrowTri — gets label and
3121
+ // arrowhead re-placement for free. A change spliced into the emitted
3122
+ // SVG downstream, or made outside the engine, will strand both.
3123
+ // Two prototype rounds re-derived this the hard way (floating
3124
+ // arrowheads, orphaned labels): that was external splicing, not an
3125
+ // engine gap. Patch routing here; do not "fix" the label machinery.
3126
+ if(A===B){
3127
+ // Self-transition: a small side loop on the node, the
3128
+ // convention of every drawing tool — never a lap of the figure
3129
+ // through the back-edge channel. Side order r,l,b,t; first side
3130
+ // whose loop box overlaps no other node wins (deterministic).
3131
+ const scy=A.y+A.h/2, scx=A.x+A.w/2;
3132
+ 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]]
3133
+ :sd==='l'?[[A.x,scy-8],[A.x-20,scy-8],[A.x-20,scy+8],[A.x,scy+8]]
3134
+ :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]]
3135
+ :[[scx-8,A.y],[scx-8,A.y-20],[scx+8,A.y-20],[scx+8,A.y]];
3136
+ const loopHit=pp=>{
3137
+ const x0=Math.min(...pp.map(p=>p[0]))-2, x1=Math.max(...pp.map(p=>p[0]))+2;
3138
+ const z0=Math.min(...pp.map(p=>p[1]))-2, z1=Math.max(...pp.map(p=>p[1]))+2;
3139
+ return nodes.some(n=>n!==A&&!n.boundary&&n.x<x1&&n.x+n.w>x0&&n.y<z1&&n.y+n.h>z0);
3140
+ };
3141
+ let sp=null;
3142
+ for(const sd of ['r','l','b','t']){ const pp=mkLoop(sd); if(!loopHit(pp)){ sp=pp; break; } }
3143
+ if(!sp) sp=mkLoop('r');
3144
+ for(const p of sp){ W=Math.max(W,p[0]+4); Hh=Math.max(Hh,p[1]+16-y0-20); }
3145
+ esvg.push('<path data-edge="'+e.line+'" d="'+roundPath(sp)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
3146
+ noteSegs(e,sp);
3147
+ 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});
3148
+ if(e.tail) seg(sp[0],sp[1],0.5,e.tail,10);
3149
+ if(e.head) seg(sp[3],sp[2],0.5,e.head,10);
3150
+ if(wantsStart) arrowTri(sp[0],sp[1],col);
3151
+ if(wantsEnd) arrowTri(sp[sp.length-1],sp[sp.length-2],col);
3152
+ continue;
3153
+ }
3115
3154
  // back-edge (retry loop): polyline through a side channel beyond the
3116
3155
  // occupied lanes instead of a straight line hidden under the spine,
3117
3156
  // using the nested slot from the channel plan. Ring-eligible loops
@@ -3238,6 +3277,50 @@ function renderScene(doc,y0){
3238
3277
  // their neighbours turns a 40-point staircase into the 2–4 bends a
3239
3278
  // dummy-vertex chain should have, without moving the drawn line.
3240
3279
  simplifyPts(pts);
3280
+ // Waypoint prune: after collinear simplification a
3281
+ // chain can still carry a staircase of near-collinear jogs — the drift
3282
+ // clamp allows only a few px of sideways movement per rank, so a run
3283
+ // that wants to move 35px sideways alternates short diagonals and
3284
+ // verticals that Douglas-Peucker cannot collapse (each angle differs).
3285
+ // The rule: an interior waypoint SURVIVES only if removing it would
3286
+ // make the edge pierce something. Obstacles are the same set the
3287
+ // straight-edge router uses below: non-incident nodes, plus group
3288
+ // boxes when NEITHER endpoint lies inside (a run through a group
3289
+ // interior asserts membership the figure does not declare). Fixed
3290
+ // left-to-right scan to fixpoint — deterministic. Endpoints are then
3291
+ // re-derived so the edge leaves its node facing the first bend, and
3292
+ // each re-derived terminal segment is re-checked: if it would hit an
3293
+ // obstacle the old endpoint stays.
3294
+ {
3295
+ 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}));
3296
+ const ep0=pts[0], ep1=pts[pts.length-1];
3297
+ for(const k in gBox){
3298
+ const b=gBox[k];
3299
+ const inG=(px,py)=>px>b.x0&&px<b.x1&&py>b.yA&&py<b.yB;
3300
+ if(!inG(ep0[0],ep0[1])&&!inG(ep1[0],ep1[1]))
3301
+ pobs.push({x:b.x0,y:b.yA,w:b.x1-b.x0,h:b.yB-b.yA});
3302
+ }
3303
+ let ch=true, pruned=false;
3304
+ while(ch){ ch=false;
3305
+ for(let i=1;i+1<pts.length;i++)
3306
+ if(!segHitsObs(pts[i-1],pts[i+1],pobs)){ pts.splice(i,1); ch=true; pruned=true; i--; }
3307
+ }
3308
+ // untouched chains stay byte-identical: endpoints and the label
3309
+ // segment are only re-derived when the prune removed a waypoint
3310
+ if(pruned){
3311
+ const nb0=borderPoint(A,pts[1][0],pts[1][1]);
3312
+ if(!segHitsObs(nb0,pts[1],pobs)) pts[0]=nb0;
3313
+ const nb1=borderPoint(B,pts[pts.length-2][0],pts[pts.length-2][1]);
3314
+ if(!segHitsObs(nb1,pts[pts.length-2],pobs)) pts[pts.length-1]=nb1;
3315
+ if(midSeg){ let bi=0,bl=-1;
3316
+ for(let i=0;i+1<pts.length;i++){
3317
+ const l=Math.hypot(pts[i+1][0]-pts[i][0],pts[i+1][1]-pts[i][1]);
3318
+ if(l>bl){ bl=l; bi=i; }
3319
+ }
3320
+ midSeg=[pts[bi],pts[bi+1]];
3321
+ }
3322
+ }
3323
+ }
3241
3324
  esvg.push('<path data-edge="'+e.line+'" d="'+roundPath(pts)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
3242
3325
  noteSegs(e,pts);
3243
3326
  if(midSeg) reqLabel({p:midSeg[0],q:midSeg[1],text:e.mid,fs:11,col:lcol,halo,e,A,B,kind:'mid',first:false});
@@ -3585,6 +3668,89 @@ function routeAround(p,q,obs){
3585
3668
  return pts.length>2?pts:null;
3586
3669
  }
3587
3670
 
3671
+ // ---- shared-edge grid (0.1.6) ----
3672
+ // A boundary between two cells belongs to BOTH of them. Drawing each cell as a
3673
+ // stroked rect drew that boundary twice, and the second painting won: a marked
3674
+ // cell's colour, or a conditional field's dash, was whatever the neighbour
3675
+ // happened to paint last. So the grid is now emitted edge-by-edge, each edge
3676
+ // exactly once, and its appearance is decided by its (at most two) owners.
3677
+ //
3678
+ // WHAT THE OLD DRAWING DESTROYED. A `present=` field's shared edge was
3679
+ // overwritten solid by a plain neighbour. `STYLE-KEY-SCOPE` rules that the dash IS
3680
+ // conditional presence and that nothing else can set or clear it — so the
3681
+ // model's flag was being silently erased at render time. This is a
3682
+ // correctness fix, not a cosmetic one.
3683
+ //
3684
+ // WHY NOT HALF-WIDTH. The obvious alternative gives each owner half the
3685
+ // boundary: two 0.5px bands, one per side. Point-sampled at 1× — the size a
3686
+ // figure is actually read at — a shared edge between an orange mark and a
3687
+ // green one reads #2E6E34: one muddy pixel with the orange unrecoverable,
3688
+ // because half-width strokes sit at quarter-pixel offsets and the rasteriser
3689
+ // averages them. The bands here land on integer pixels and every colour
3690
+ // survives exactly. A zoomed crop hides this entirely, which is why the
3691
+ // decision was made at 1× and must be re-checked at 1× if it is revisited.
3692
+ //
3693
+ // THE RESIDUAL AMBIGUITY, stated rather than hidden. A dashed edge between a
3694
+ // conditional field and a plain one can be read locally as if the plain one
3695
+ // were conditional too. Today's drawing has the same ambiguity whenever the
3696
+ // dash happens to win the overwrite; this is strictly better, not solved.
3697
+ //
3698
+ // WHAT IT COSTS. Two adjacent marked cells put 3px of ink at their shared
3699
+ // boundary (ring + line + ring) against 1px elsewhere: the grid stays even,
3700
+ // but that region reads heavier. Four differently marked cells meeting at a
3701
+ // point give four ring corners around one crossing — busy at 8×, invisible
3702
+ // at 1×.
3703
+ //
3704
+ // `edges` are runs produced by edgeRuns(); `def` is the block's grid colour.
3705
+ function edgeSvg(edges, def){
3706
+ const W=1, out=[];
3707
+ const L=(v,p,s,e,col,dash,w)=>'<line x1="'+(v?p:s)+'" y1="'+(v?s:p)+'" x2="'+(v?p:e)+'" y2="'+(v?e:p)
3708
+ +'" stroke="'+col+'"'+(w!==1?' stroke-width="'+w+'"':'')+(dash?' stroke-dasharray="5 3"':'')+'/>';
3709
+ for(const g of edges){
3710
+ const A=g.a, B=g.b;
3711
+ // The shared line: default colour, full weight, dashed iff EITHER owner
3712
+ // carries `present=`. A field with both `stroke=` and `present=` therefore
3713
+ // gets a dashed boundary AND a separate coloured ring, not a coloured
3714
+ // dash — the two marks answer different questions and must not merge.
3715
+ out.push(L(g.v, g.p, g.s, g.e, def, (A&&A.d)||(B&&B.d), W));
3716
+ // The class marks: solid, full weight, inset INSIDE their own cell. An end
3717
+ // pulls in by W/2 where the owner stops and the ring turns the corner;
3718
+ // it is left long where the owner continues past that end and the next run
3719
+ // carries on, so a run that ends only because the NEIGHBOUR changed joins
3720
+ // with no seam and no ring protrudes past its own cell. The test is the
3721
+ // ownership map (does this cell own the next unit too?), not the geometry.
3722
+ if(A&&A.c) out.push(L(g.v, g.p-W, g.s+(g.ca0?0:W/2), g.e-(g.ca1?0:W/2), A.c, false, W));
3723
+ if(B&&B.c) out.push(L(g.v, g.p+W, g.s+(g.cb0?0:W/2), g.e-(g.cb1?0:W/2), B.c, false, W));
3724
+ }
3725
+ return out.join('');
3726
+ }
3727
+ // Walk one grid line unit by unit and merge collinear units that have the same
3728
+ // pair of owners into a single run. `v` is 1 for a vertical line, `p` its fixed
3729
+ // coordinate; `n` units; `span(i)` -> [start,end]; `ownAt(i)` -> [before,after]
3730
+ // where an owner is {id, c:colour|null, d:dashed}. A unit with no owner, or
3731
+ // with the SAME owner on both sides (a merged/spanning cell's interior), draws
3732
+ // nothing at all — which is where the doubled interior lines went.
3733
+ function edgeRuns(v, p, n, span, ownAt){
3734
+ const out=[]; let run=null;
3735
+ const sid=(o)=>o?o.id:null;
3736
+ const cont=(i,k,o)=>!!o && sid((ownAt(i)||[])[k])===o.id;
3737
+ for(let i=0;i<n;i++){
3738
+ const o=ownAt(i)||[], a=o[0]||null, b=o[1]||null;
3739
+ const skip=(!a&&!b)||(a&&b&&a.id===b.id);
3740
+ const key=skip?null:JSON.stringify([a&&[a.c,a.d], b&&[b.c,b.d]]);
3741
+ const sp=span(i);
3742
+ if(run && run.key===key && run.e===sp[0]){ run.e=sp[1]; run.i1=i; continue; }
3743
+ if(run){ out.push(run); run=null; }
3744
+ if(!skip) run={v:v,p:p,s:sp[0],e:sp[1],key:key,a:a,b:b,i0:i,i1:i};
3745
+ }
3746
+ if(run) out.push(run);
3747
+ for(const r of out){
3748
+ r.ca0=cont(r.i0-1,0,r.a); r.ca1=cont(r.i1+1,0,r.a);
3749
+ r.cb0=cont(r.i0-1,1,r.b); r.cb1=cont(r.i1+1,1,r.b);
3750
+ }
3751
+ return out;
3752
+ }
3753
+
3588
3754
  // ---- bitfield ----
3589
3755
  function renderBitfield(b,y0){
3590
3756
  const cell=Math.max(18,Math.min(28,Math.floor(760/b.word))), rh=30, ruler=16;
@@ -3777,9 +3943,11 @@ function renderBitfield(b,y0){
3777
3943
  // one edge a per-row rect cannot leave out: a rect strokes four sides or
3778
3944
  // none, and "none" would also lose the two verticals that must continue.
3779
3945
  // Fill stays a single closed region, so `fill=` paints the field once with
3780
- // no seam, and the stroke carries `present=`'s dash around the whole
3781
- // outline instead of around each row (`STYLE-KEY-SCOPE`: the dash IS conditional
3782
- // presence, and nothing else may set or clear it).
3946
+ // no seam. Since 0.1.6 the path is unstroked and the boundary comes from the
3947
+ // shared-edge grid instead; the internal boundary is still never drawn,
3948
+ // because both of its sides are owned by the same field and edgeRuns() skips
3949
+ // a unit whose two owners are one (`STYLE-KEY-SCOPE`: the dash IS conditional presence,
3950
+ // and nothing else may set or clear it).
3783
3951
  const boxOutline=(bx)=>{
3784
3952
  const bands=[];
3785
3953
  for(const s of bx){
@@ -3801,6 +3969,11 @@ function renderBitfield(b,y0){
3801
3969
  }
3802
3970
  return 'M'+pts.map(p=>p[0]+' '+p[1]).join(' L')+' Z';
3803
3971
  };
3972
+ // Shared-edge grid (0.1.6): field fills carry no stroke of their own. Every
3973
+ // bit column of every row records which field owns it, and the grid is
3974
+ // emitted once, edge by edge, after the fields — see edgeSvg() above.
3975
+ const BOWN=new Map(), BDEF=b.stroke||'#555';
3976
+ const bkey=(rw,cl)=>rw+':'+cl; let BOXN=0;
3804
3977
  let pos=0; // bit cursor
3805
3978
  for(const f of b.fields){
3806
3979
  if(f.wrap){ pos=Math.ceil((pos||1)/b.word)*b.word; continue; }
@@ -3826,9 +3999,11 @@ function renderBitfield(b,y0){
3826
3999
  // `PRESENCE-CONDITION-EXPRESSION`: the carrier is now `present=`, and BOTH of its
3827
4000
  // written forms dash — `present=""` claims conditional presence just as
3828
4001
  // `present="C = 1"` does; only the caption below distinguishes them.
3829
- const dash=f.present!==undefined?' stroke-dasharray="5 3"':'';
4002
+ // 0.1.6: the dash and the class colour no longer ride on the box's own
4003
+ // stroke — they are properties of the shared boundary and of the ring
4004
+ // inside the cell respectively, recorded per bit below and drawn once.
3830
4005
  const cfill=f.fill||b.fill||'#fff';
3831
- const paint=' fill="'+cfill+'" stroke="'+(f.stroke||b.stroke||'#555')+'"'+dash;
4006
+ const paint=' fill="'+cfill+'" stroke="none"';
3832
4007
  // Draw one occurrence. `suffix` is the derived index label — '' for a
3833
4008
  // field that is not one element of a run, ' [0]' / ' [n]' for the two
3834
4009
  // occurrences `REPEATED-RUN-DRAWING` draws. It is APPENDED to the author's label, and the
@@ -3863,6 +4038,13 @@ function renderBitfield(b,y0){
3863
4038
  ? '<rect x="'+bx[0].x+'" y="'+bx[0].y+'" width="'+bx[0].w+'" height="'+(bx.length*rh)+'"'+paint
3864
4039
  : '<path d="'+boxOutline(bx)+'"'+paint;
3865
4040
  const tag=flat?'rect':'path';
4041
+ // Record ownership per BIT, one entry per (row, column) this occurrence
4042
+ // covers. Every occurrence of a repeated field is its own owner id, so
4043
+ // the boundary between two occurrences is a real boundary and is drawn.
4044
+ const bid=b.id+'_'+(BOXN++);
4045
+ const brec={id:bid, c:f.stroke||null, d:f.present!==undefined};
4046
+ bx.forEach(function(s){ const c0=Math.round(s.x/cell), n=Math.round(s.w/cell);
4047
+ for(let k=0;k<n;k++) BOWN.set(bkey(s.row,c0+k), brec); });
3866
4048
  svg.push(desc?shape+'>'+desc+'</'+tag+'>':shape+'/>');
3867
4049
  // ONE caption per box: the NAME on the first box, CONT on every later
3868
4050
  // one. The first box, not the widest — reading order is the order the
@@ -3923,6 +4105,29 @@ function renderBitfield(b,y0){
3923
4105
  }
3924
4106
  }
3925
4107
  }
4108
+ // The shared-edge grid for the bitfield: one pass over every vertical bit
4109
+ // boundary and every horizontal row boundary, each emitted once.
4110
+ let maxRow=-1;
4111
+ BOWN.forEach(function(v,k){ const r=+k.split(':')[0]; if(r>maxRow) maxRow=r; });
4112
+ const yrow=(r)=>y+r*rh+shiftFor(r);
4113
+ const at=(r,c)=>(r<0||r>maxRow||c<0||c>=b.word)?null:(BOWN.get(bkey(r,c))||null);
4114
+ const BE=[];
4115
+ for(let c=0;c<=b.word;c++)
4116
+ BE.push.apply(BE, edgeRuns(1, c*cell, maxRow+1,
4117
+ i=>[yrow(i), yrow(i)+rh], i=>[at(i,c-1), at(i,c)]));
4118
+ // A horizontal boundary is a row's TOP, shared with the row above when the
4119
+ // two are vertically adjacent. A row also needs a bottom of its own when the
4120
+ // row below is not adjacent — an elision strip has been inserted between
4121
+ // them, and shiftFor() has pushed it down.
4122
+ const hb=[];
4123
+ for(let r=0;r<=maxRow;r++){
4124
+ hb.push({y:yrow(r), ar:(r>0 && yrow(r-1)+rh===yrow(r))?r-1:-1, br:r});
4125
+ if(r===maxRow || yrow(r+1)!==yrow(r)+rh) hb.push({y:yrow(r)+rh, ar:r, br:-1});
4126
+ }
4127
+ for(const hbe of hb)
4128
+ BE.push.apply(BE, edgeRuns(0, hbe.y, b.word,
4129
+ i=>[i*cell, (i+1)*cell], i=>[hbe.ar<0?null:at(hbe.ar,i), hbe.br<0?null:at(hbe.br,i)]));
4130
+ svg.push(edgeSvg(BE, BDEF));
3926
4131
  for(const e of elis){
3927
4132
  const st=(b.stroke||'#555');
3928
4133
  // `ELISION-MARK-EXTENT`: THE SIDE DOTS ARE ALWAYS DRAWN. This reverses `ELISION-MARK-EXTENT`,
@@ -4018,6 +4223,14 @@ function renderTable(t,y0){
4018
4223
  // cell marks: h1..hN address header tiers top-down, r>=1 the data rows
4019
4224
  const markOf=(r,c)=>(t.marks||[]).find(mk=>(mk.hdr?mk.r-1:H+mk.r-1)===r&&mk.c===c+1);
4020
4225
  const yTop=y+4;
4226
+ // Shared-edge grid (0.1.6): cell fills carry no stroke of their own. Every
4227
+ // grid cell records its owning (anchor) cell, and the grid is emitted once,
4228
+ // edge by edge, after the cells — see edgeSvg() above. Table cells have no
4229
+ // conditional-presence mark, so `d` is always false here; the dash branch
4230
+ // exists for the bitfield and is kept common so both genres draw alike.
4231
+ const NC=t.cols.length, DEF=t.stroke||'#c9c7bf';
4232
+ const OWN=grid.map(()=>new Array(NC).fill(null));
4233
+ const xAt=[0]; for(let i=0;i<NC;i++) xAt.push(xAt[i]+widths[i]);
4021
4234
  for(let r=0;r<grid.length;r++){
4022
4235
  for(let c=0;c<grid[r].length;c++){
4023
4236
  const cell=grid[r][c];
@@ -4033,7 +4246,11 @@ function renderTable(t,y0){
4033
4246
  // addressable cells carry table-id:row:col (row 0 = bottom header tier)
4034
4247
  const addrR = r>=H ? (r-H+1) : (r===H-1 ? 0 : null);
4035
4248
  const addr = addrR===null ? '' : ' data-cell="'+t.id+':'+addrR+':'+(c+1)+'" style="cursor:pointer"';
4036
- svg.push('<rect x="'+x+'" y="'+yy+'" width="'+wsum+'" height="'+h+'" fill="'+fill+'" stroke="'+((mk&&mk.stroke)||t.stroke||'#c9c7bf')+'"'+addr+'/>');
4249
+ // A merged cell owns every grid square it spans, so its internal
4250
+ // boundaries have the same owner on both sides and are never drawn.
4251
+ const rec={id:'c'+r+'_'+c, c:(mk&&mk.stroke)||null, d:false};
4252
+ for(let rr=r;rr<r+rs;rr++) for(let cc=c;cc<c+cs;cc++) if(OWN[rr]) OWN[rr][cc]=rec;
4253
+ svg.push('<rect x="'+x+'" y="'+yy+'" width="'+wsum+'" height="'+h+'" fill="'+fill+'" stroke="none"'+addr+'/>');
4037
4254
  // alignment: headers centered; data follows GFM colon alignment (default left)
4038
4255
  const al=cell.hdr?'center':(alignOf(c)||'left');
4039
4256
  const tx=al==='center'?x+wsum/2:(al==='right'?x+wsum-7:x+7);
@@ -4049,6 +4266,16 @@ function renderTable(t,y0){
4049
4266
  }
4050
4267
  }
4051
4268
  }
4269
+ // Each edge drawn exactly once, owned by the (at most two) cells it divides.
4270
+ const EDG=[];
4271
+ const cellAt=(r,c)=>(r<0||r>=grid.length||c<0||c>=NC)?null:OWN[r][c];
4272
+ for(let c=0;c<=NC;c++)
4273
+ EDG.push.apply(EDG, edgeRuns(1, xAt[c], grid.length,
4274
+ i=>[yTop+yAt[i], yTop+yAt[i+1]], i=>[cellAt(i,c-1), cellAt(i,c)]));
4275
+ for(let r=0;r<=grid.length;r++)
4276
+ EDG.push.apply(EDG, edgeRuns(0, yTop+yAt[r], NC,
4277
+ i=>[xAt[i], xAt[i+1]], i=>[cellAt(r-1,i), cellAt(r,i)]));
4278
+ svg.push(edgeSvg(EDG, DEF));
4052
4279
  const yEnd=yTop+yAt[grid.length];
4053
4280
  return {svg:svg.join(''), y:yEnd+6, w:totalW+2};
4054
4281
  }