figdown 0.1.3 → 0.1.6

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.
@@ -0,0 +1,25 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
+ "name": "figdown",
4
+ "displayName": "FigDown",
5
+ "version": "0.1.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
+ "author": {
8
+ "name": "FigDown",
9
+ "email": "hello@figdown.org",
10
+ "url": "https://figdown.org"
11
+ },
12
+ "homepage": "https://figdown.org",
13
+ "repository": "https://github.com/FigDown/figdown",
14
+ "license": "MIT",
15
+ "keywords": [
16
+ "figdown",
17
+ "diagram",
18
+ "figure",
19
+ "markdown",
20
+ "svg",
21
+ "diagrams-as-code",
22
+ "deterministic"
23
+ ],
24
+ "skills": "./skill/"
25
+ }
package/README.md CHANGED
@@ -307,7 +307,28 @@ Secondary routes, once you are past the first read:
307
307
 
308
308
  ## Install
309
309
 
310
- The renderer is published on npm under the MIT licence:
310
+ **Into a coding agent.** The FigDown skill teaches an agent to author, build
311
+ and read figures. As a Claude Code plugin:
312
+
313
+ ```sh
314
+ /plugin marketplace add FigDown/figdown
315
+ /plugin install figdown@figdown
316
+ ```
317
+
318
+ Or copy the self-contained bundle into any skills directory, with no plugin
319
+ mechanism involved:
320
+
321
+ ```sh
322
+ cp -r skill/figdown ~/.claude/skills/figdown
323
+ ```
324
+
325
+ Both paths install the same directory — the plugin manifest points at
326
+ [`skill/figdown/`](skill/README.md) rather than copying it. See
327
+ [`skill/README.md`](skill/README.md) for the project-scoped variant and for
328
+ other agent frameworks.
329
+
330
+ **Into a program or a build.** The renderer is published on npm under the MIT
331
+ licence:
311
332
 
312
333
  ```sh
313
334
  npm install -g figdown
package/dist/figdown.js CHANGED
@@ -1,4 +1,4 @@
1
- // figdown.js — FigDown embeddable library (0.1.3)
1
+ // figdown.js — FigDown embeddable library (0.1.6)
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.3";
13
+ var VERSION = "0.1.6";
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.3';
27
+ const FIGDOWN_VERSION = '0.1.6';
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
@@ -3585,6 +3585,89 @@ function routeAround(p,q,obs){
3585
3585
  return pts.length>2?pts:null;
3586
3586
  }
3587
3587
 
3588
+ // ---- shared-edge grid (0.1.6) ----
3589
+ // A boundary between two cells belongs to BOTH of them. Drawing each cell as a
3590
+ // stroked rect drew that boundary twice, and the second painting won: a marked
3591
+ // cell's colour, or a conditional field's dash, was whatever the neighbour
3592
+ // happened to paint last. So the grid is now emitted edge-by-edge, each edge
3593
+ // exactly once, and its appearance is decided by its (at most two) owners.
3594
+ //
3595
+ // WHAT THE OLD DRAWING DESTROYED. A `present=` field's shared edge was
3596
+ // overwritten solid by a plain neighbour. `STYLE-KEY-SCOPE` rules that the dash IS
3597
+ // conditional presence and that nothing else can set or clear it — so the
3598
+ // model's flag was being silently erased at render time. This is a
3599
+ // correctness fix, not a cosmetic one.
3600
+ //
3601
+ // WHY NOT HALF-WIDTH. The obvious alternative gives each owner half the
3602
+ // boundary: two 0.5px bands, one per side. Point-sampled at 1× — the size a
3603
+ // figure is actually read at — a shared edge between an orange mark and a
3604
+ // green one reads #2E6E34: one muddy pixel with the orange unrecoverable,
3605
+ // because half-width strokes sit at quarter-pixel offsets and the rasteriser
3606
+ // averages them. The bands here land on integer pixels and every colour
3607
+ // survives exactly. A zoomed crop hides this entirely, which is why the
3608
+ // decision was made at 1× and must be re-checked at 1× if it is revisited.
3609
+ //
3610
+ // THE RESIDUAL AMBIGUITY, stated rather than hidden. A dashed edge between a
3611
+ // conditional field and a plain one can be read locally as if the plain one
3612
+ // were conditional too. Today's drawing has the same ambiguity whenever the
3613
+ // dash happens to win the overwrite; this is strictly better, not solved.
3614
+ //
3615
+ // WHAT IT COSTS. Two adjacent marked cells put 3px of ink at their shared
3616
+ // boundary (ring + line + ring) against 1px elsewhere: the grid stays even,
3617
+ // but that region reads heavier. Four differently marked cells meeting at a
3618
+ // point give four ring corners around one crossing — busy at 8×, invisible
3619
+ // at 1×.
3620
+ //
3621
+ // `edges` are runs produced by edgeRuns(); `def` is the block's grid colour.
3622
+ function edgeSvg(edges, def){
3623
+ const W=1, out=[];
3624
+ 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)
3625
+ +'" stroke="'+col+'"'+(w!==1?' stroke-width="'+w+'"':'')+(dash?' stroke-dasharray="5 3"':'')+'/>';
3626
+ for(const g of edges){
3627
+ const A=g.a, B=g.b;
3628
+ // The shared line: default colour, full weight, dashed iff EITHER owner
3629
+ // carries `present=`. A field with both `stroke=` and `present=` therefore
3630
+ // gets a dashed boundary AND a separate coloured ring, not a coloured
3631
+ // dash — the two marks answer different questions and must not merge.
3632
+ out.push(L(g.v, g.p, g.s, g.e, def, (A&&A.d)||(B&&B.d), W));
3633
+ // The class marks: solid, full weight, inset INSIDE their own cell. An end
3634
+ // pulls in by W/2 where the owner stops and the ring turns the corner;
3635
+ // it is left long where the owner continues past that end and the next run
3636
+ // carries on, so a run that ends only because the NEIGHBOUR changed joins
3637
+ // with no seam and no ring protrudes past its own cell. The test is the
3638
+ // ownership map (does this cell own the next unit too?), not the geometry.
3639
+ 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));
3640
+ 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));
3641
+ }
3642
+ return out.join('');
3643
+ }
3644
+ // Walk one grid line unit by unit and merge collinear units that have the same
3645
+ // pair of owners into a single run. `v` is 1 for a vertical line, `p` its fixed
3646
+ // coordinate; `n` units; `span(i)` -> [start,end]; `ownAt(i)` -> [before,after]
3647
+ // where an owner is {id, c:colour|null, d:dashed}. A unit with no owner, or
3648
+ // with the SAME owner on both sides (a merged/spanning cell's interior), draws
3649
+ // nothing at all — which is where the doubled interior lines went.
3650
+ function edgeRuns(v, p, n, span, ownAt){
3651
+ const out=[]; let run=null;
3652
+ const sid=(o)=>o?o.id:null;
3653
+ const cont=(i,k,o)=>!!o && sid((ownAt(i)||[])[k])===o.id;
3654
+ for(let i=0;i<n;i++){
3655
+ const o=ownAt(i)||[], a=o[0]||null, b=o[1]||null;
3656
+ const skip=(!a&&!b)||(a&&b&&a.id===b.id);
3657
+ const key=skip?null:JSON.stringify([a&&[a.c,a.d], b&&[b.c,b.d]]);
3658
+ const sp=span(i);
3659
+ if(run && run.key===key && run.e===sp[0]){ run.e=sp[1]; run.i1=i; continue; }
3660
+ if(run){ out.push(run); run=null; }
3661
+ if(!skip) run={v:v,p:p,s:sp[0],e:sp[1],key:key,a:a,b:b,i0:i,i1:i};
3662
+ }
3663
+ if(run) out.push(run);
3664
+ for(const r of out){
3665
+ r.ca0=cont(r.i0-1,0,r.a); r.ca1=cont(r.i1+1,0,r.a);
3666
+ r.cb0=cont(r.i0-1,1,r.b); r.cb1=cont(r.i1+1,1,r.b);
3667
+ }
3668
+ return out;
3669
+ }
3670
+
3588
3671
  // ---- bitfield ----
3589
3672
  function renderBitfield(b,y0){
3590
3673
  const cell=Math.max(18,Math.min(28,Math.floor(760/b.word))), rh=30, ruler=16;
@@ -3777,9 +3860,11 @@ function renderBitfield(b,y0){
3777
3860
  // one edge a per-row rect cannot leave out: a rect strokes four sides or
3778
3861
  // none, and "none" would also lose the two verticals that must continue.
3779
3862
  // 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).
3863
+ // no seam. Since 0.1.6 the path is unstroked and the boundary comes from the
3864
+ // shared-edge grid instead; the internal boundary is still never drawn,
3865
+ // because both of its sides are owned by the same field and edgeRuns() skips
3866
+ // a unit whose two owners are one (`STYLE-KEY-SCOPE`: the dash IS conditional presence,
3867
+ // and nothing else may set or clear it).
3783
3868
  const boxOutline=(bx)=>{
3784
3869
  const bands=[];
3785
3870
  for(const s of bx){
@@ -3801,6 +3886,11 @@ function renderBitfield(b,y0){
3801
3886
  }
3802
3887
  return 'M'+pts.map(p=>p[0]+' '+p[1]).join(' L')+' Z';
3803
3888
  };
3889
+ // Shared-edge grid (0.1.6): field fills carry no stroke of their own. Every
3890
+ // bit column of every row records which field owns it, and the grid is
3891
+ // emitted once, edge by edge, after the fields — see edgeSvg() above.
3892
+ const BOWN=new Map(), BDEF=b.stroke||'#555';
3893
+ const bkey=(rw,cl)=>rw+':'+cl; let BOXN=0;
3804
3894
  let pos=0; // bit cursor
3805
3895
  for(const f of b.fields){
3806
3896
  if(f.wrap){ pos=Math.ceil((pos||1)/b.word)*b.word; continue; }
@@ -3826,9 +3916,11 @@ function renderBitfield(b,y0){
3826
3916
  // `PRESENCE-CONDITION-EXPRESSION`: the carrier is now `present=`, and BOTH of its
3827
3917
  // written forms dash — `present=""` claims conditional presence just as
3828
3918
  // `present="C = 1"` does; only the caption below distinguishes them.
3829
- const dash=f.present!==undefined?' stroke-dasharray="5 3"':'';
3919
+ // 0.1.6: the dash and the class colour no longer ride on the box's own
3920
+ // stroke — they are properties of the shared boundary and of the ring
3921
+ // inside the cell respectively, recorded per bit below and drawn once.
3830
3922
  const cfill=f.fill||b.fill||'#fff';
3831
- const paint=' fill="'+cfill+'" stroke="'+(f.stroke||b.stroke||'#555')+'"'+dash;
3923
+ const paint=' fill="'+cfill+'" stroke="none"';
3832
3924
  // Draw one occurrence. `suffix` is the derived index label — '' for a
3833
3925
  // field that is not one element of a run, ' [0]' / ' [n]' for the two
3834
3926
  // occurrences `REPEATED-RUN-DRAWING` draws. It is APPENDED to the author's label, and the
@@ -3863,6 +3955,13 @@ function renderBitfield(b,y0){
3863
3955
  ? '<rect x="'+bx[0].x+'" y="'+bx[0].y+'" width="'+bx[0].w+'" height="'+(bx.length*rh)+'"'+paint
3864
3956
  : '<path d="'+boxOutline(bx)+'"'+paint;
3865
3957
  const tag=flat?'rect':'path';
3958
+ // Record ownership per BIT, one entry per (row, column) this occurrence
3959
+ // covers. Every occurrence of a repeated field is its own owner id, so
3960
+ // the boundary between two occurrences is a real boundary and is drawn.
3961
+ const bid=b.id+'_'+(BOXN++);
3962
+ const brec={id:bid, c:f.stroke||null, d:f.present!==undefined};
3963
+ bx.forEach(function(s){ const c0=Math.round(s.x/cell), n=Math.round(s.w/cell);
3964
+ for(let k=0;k<n;k++) BOWN.set(bkey(s.row,c0+k), brec); });
3866
3965
  svg.push(desc?shape+'>'+desc+'</'+tag+'>':shape+'/>');
3867
3966
  // ONE caption per box: the NAME on the first box, CONT on every later
3868
3967
  // one. The first box, not the widest — reading order is the order the
@@ -3923,22 +4022,58 @@ function renderBitfield(b,y0){
3923
4022
  }
3924
4023
  }
3925
4024
  }
4025
+ // The shared-edge grid for the bitfield: one pass over every vertical bit
4026
+ // boundary and every horizontal row boundary, each emitted once.
4027
+ let maxRow=-1;
4028
+ BOWN.forEach(function(v,k){ const r=+k.split(':')[0]; if(r>maxRow) maxRow=r; });
4029
+ const yrow=(r)=>y+r*rh+shiftFor(r);
4030
+ const at=(r,c)=>(r<0||r>maxRow||c<0||c>=b.word)?null:(BOWN.get(bkey(r,c))||null);
4031
+ const BE=[];
4032
+ for(let c=0;c<=b.word;c++)
4033
+ BE.push.apply(BE, edgeRuns(1, c*cell, maxRow+1,
4034
+ i=>[yrow(i), yrow(i)+rh], i=>[at(i,c-1), at(i,c)]));
4035
+ // A horizontal boundary is a row's TOP, shared with the row above when the
4036
+ // two are vertically adjacent. A row also needs a bottom of its own when the
4037
+ // row below is not adjacent — an elision strip has been inserted between
4038
+ // them, and shiftFor() has pushed it down.
4039
+ const hb=[];
4040
+ for(let r=0;r<=maxRow;r++){
4041
+ hb.push({y:yrow(r), ar:(r>0 && yrow(r-1)+rh===yrow(r))?r-1:-1, br:r});
4042
+ if(r===maxRow || yrow(r+1)!==yrow(r)+rh) hb.push({y:yrow(r)+rh, ar:r, br:-1});
4043
+ }
4044
+ for(const hbe of hb)
4045
+ BE.push.apply(BE, edgeRuns(0, hbe.y, b.word,
4046
+ i=>[i*cell, (i+1)*cell], i=>[hbe.ar<0?null:at(hbe.ar,i), hbe.br<0?null:at(hbe.br,i)]));
4047
+ svg.push(edgeSvg(BE, BDEF));
3926
4048
  for(const e of elis){
3927
4049
  const st=(b.stroke||'#555');
3928
- // `ELISION-MARK-EXTENT`: THE SIDE DOTS SAY WHICH COLUMNS ARE ELIDED, so a
3929
- // strip that already spans the whole word does not draw them. RFC 8754 §2
3930
- // prints its `...` row with NO `|` at either end — the box is simply open
3931
- // there and an element occupying the full word needs no mark to say
3932
- // which columns it occupied, because it occupied all of them. The dots
3933
- // still carry information for a narrower element (reference/bitfield.fd's
3934
- // `Queue Depth` runs columns 16-31), where without them the strip's extent
3935
- // is guesswork. Derived from the geometry, never an option key: the same
3936
- // shape as `REPEATED-RUN-DRAWING`'s rule for the mark itself, which draws iff something is
3937
- // in fact undrawn.
3938
- if(e.w < b.word*cell){
3939
- svg.push('<line x1="'+e.x+'" y1="'+e.y+'" x2="'+e.x+'" y2="'+(e.y+EL_H)+'" stroke="'+st+'" stroke-dasharray="2 3"/>');
3940
- 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"/>');
3941
- }
4050
+ // `ELISION-MARK-EXTENT`: THE SIDE DOTS ARE ALWAYS DRAWN. This reverses `ELISION-MARK-EXTENT`,
4051
+ // which suppressed them when the strip already spanned the whole word.
4052
+ //
4053
+ // `ELISION-MARK-EXTENT`'s reasoning was sound and incomplete. It asked what the dots MEAN
4054
+ // which columns are elided found that answer vacuous for an element
4055
+ // occupying every column, and removed them. What it never asked is what
4056
+ // the dots DO. They have a second job that was never written down: they
4057
+ // make the gap read as part of the figure. Without them a full-word
4058
+ // elision is two boxes with whitespace between and a 10.5px grey ellipsis
4059
+ // floating in it, which reads as a rendering artefact rather than as a
4060
+ // deliberate mark. That is the reading the maintainer had on seeing it,
4061
+ // and the author of a convention misreading its own output is the
4062
+ // strongest evidence available that the convention does not communicate.
4063
+ //
4064
+ // `ELISION-MARK-EXTENT` also copied RFC 8754 §2's `...` row, which indeed carries no `|`,
4065
+ // without copying what makes it legible there: in a monospace block the
4066
+ // lines above and below put `|` in the same two columns, so the eye reads
4067
+ // one break in a continuous wall. This renderer is far sparser — bordered
4068
+ // boxes with a 16px gap and no frame across it — so the same mark in the
4069
+ // same place does not do the same work. A mark borrowed without its
4070
+ // context is not the same mark.
4071
+ //
4072
+ // The general rule this leaves: before removing a mark because its stated
4073
+ // meaning is redundant, establish that the stated meaning was its only
4074
+ // job.
4075
+ svg.push('<line x1="'+e.x+'" y1="'+e.y+'" x2="'+e.x+'" y2="'+(e.y+EL_H)+'" stroke="'+st+'" stroke-dasharray="2 3"/>');
4076
+ 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"/>');
3942
4077
  let fs=10.5; const need=e.text.length*6.2;
3943
4078
  if(need>e.w-6) fs=Math.max(7,10.5*(e.w-6)/need);
3944
4079
  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>');
@@ -4005,6 +4140,14 @@ function renderTable(t,y0){
4005
4140
  // cell marks: h1..hN address header tiers top-down, r>=1 the data rows
4006
4141
  const markOf=(r,c)=>(t.marks||[]).find(mk=>(mk.hdr?mk.r-1:H+mk.r-1)===r&&mk.c===c+1);
4007
4142
  const yTop=y+4;
4143
+ // Shared-edge grid (0.1.6): cell fills carry no stroke of their own. Every
4144
+ // grid cell records its owning (anchor) cell, and the grid is emitted once,
4145
+ // edge by edge, after the cells — see edgeSvg() above. Table cells have no
4146
+ // conditional-presence mark, so `d` is always false here; the dash branch
4147
+ // exists for the bitfield and is kept common so both genres draw alike.
4148
+ const NC=t.cols.length, DEF=t.stroke||'#c9c7bf';
4149
+ const OWN=grid.map(()=>new Array(NC).fill(null));
4150
+ const xAt=[0]; for(let i=0;i<NC;i++) xAt.push(xAt[i]+widths[i]);
4008
4151
  for(let r=0;r<grid.length;r++){
4009
4152
  for(let c=0;c<grid[r].length;c++){
4010
4153
  const cell=grid[r][c];
@@ -4020,7 +4163,11 @@ function renderTable(t,y0){
4020
4163
  // addressable cells carry table-id:row:col (row 0 = bottom header tier)
4021
4164
  const addrR = r>=H ? (r-H+1) : (r===H-1 ? 0 : null);
4022
4165
  const addr = addrR===null ? '' : ' data-cell="'+t.id+':'+addrR+':'+(c+1)+'" style="cursor:pointer"';
4023
- svg.push('<rect x="'+x+'" y="'+yy+'" width="'+wsum+'" height="'+h+'" fill="'+fill+'" stroke="'+((mk&&mk.stroke)||t.stroke||'#c9c7bf')+'"'+addr+'/>');
4166
+ // A merged cell owns every grid square it spans, so its internal
4167
+ // boundaries have the same owner on both sides and are never drawn.
4168
+ const rec={id:'c'+r+'_'+c, c:(mk&&mk.stroke)||null, d:false};
4169
+ for(let rr=r;rr<r+rs;rr++) for(let cc=c;cc<c+cs;cc++) if(OWN[rr]) OWN[rr][cc]=rec;
4170
+ svg.push('<rect x="'+x+'" y="'+yy+'" width="'+wsum+'" height="'+h+'" fill="'+fill+'" stroke="none"'+addr+'/>');
4024
4171
  // alignment: headers centered; data follows GFM colon alignment (default left)
4025
4172
  const al=cell.hdr?'center':(alignOf(c)||'left');
4026
4173
  const tx=al==='center'?x+wsum/2:(al==='right'?x+wsum-7:x+7);
@@ -4036,6 +4183,16 @@ function renderTable(t,y0){
4036
4183
  }
4037
4184
  }
4038
4185
  }
4186
+ // Each edge drawn exactly once, owned by the (at most two) cells it divides.
4187
+ const EDG=[];
4188
+ const cellAt=(r,c)=>(r<0||r>=grid.length||c<0||c>=NC)?null:OWN[r][c];
4189
+ for(let c=0;c<=NC;c++)
4190
+ EDG.push.apply(EDG, edgeRuns(1, xAt[c], grid.length,
4191
+ i=>[yTop+yAt[i], yTop+yAt[i+1]], i=>[cellAt(i,c-1), cellAt(i,c)]));
4192
+ for(let r=0;r<=grid.length;r++)
4193
+ EDG.push.apply(EDG, edgeRuns(0, yTop+yAt[r], NC,
4194
+ i=>[xAt[i], xAt[i+1]], i=>[cellAt(r-1,i), cellAt(r,i)]));
4195
+ svg.push(edgeSvg(EDG, DEF));
4039
4196
  const yEnd=yTop+yAt[grid.length];
4040
4197
  return {svg:svg.join(''), y:yEnd+6, w:totalW+2};
4041
4198
  }
package/dist/figdown.mjs CHANGED
@@ -1,8 +1,8 @@
1
- // figdown.mjs — FigDown embeddable library (0.1.3)
1
+ // figdown.mjs — FigDown embeddable library (0.1.6)
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.3";
5
+ var VERSION = "0.1.6";
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.3';
19
+ const FIGDOWN_VERSION = '0.1.6';
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
@@ -3577,6 +3577,89 @@ function routeAround(p,q,obs){
3577
3577
  return pts.length>2?pts:null;
3578
3578
  }
3579
3579
 
3580
+ // ---- shared-edge grid (0.1.6) ----
3581
+ // A boundary between two cells belongs to BOTH of them. Drawing each cell as a
3582
+ // stroked rect drew that boundary twice, and the second painting won: a marked
3583
+ // cell's colour, or a conditional field's dash, was whatever the neighbour
3584
+ // happened to paint last. So the grid is now emitted edge-by-edge, each edge
3585
+ // exactly once, and its appearance is decided by its (at most two) owners.
3586
+ //
3587
+ // WHAT THE OLD DRAWING DESTROYED. A `present=` field's shared edge was
3588
+ // overwritten solid by a plain neighbour. `STYLE-KEY-SCOPE` rules that the dash IS
3589
+ // conditional presence and that nothing else can set or clear it — so the
3590
+ // model's flag was being silently erased at render time. This is a
3591
+ // correctness fix, not a cosmetic one.
3592
+ //
3593
+ // WHY NOT HALF-WIDTH. The obvious alternative gives each owner half the
3594
+ // boundary: two 0.5px bands, one per side. Point-sampled at 1× — the size a
3595
+ // figure is actually read at — a shared edge between an orange mark and a
3596
+ // green one reads #2E6E34: one muddy pixel with the orange unrecoverable,
3597
+ // because half-width strokes sit at quarter-pixel offsets and the rasteriser
3598
+ // averages them. The bands here land on integer pixels and every colour
3599
+ // survives exactly. A zoomed crop hides this entirely, which is why the
3600
+ // decision was made at 1× and must be re-checked at 1× if it is revisited.
3601
+ //
3602
+ // THE RESIDUAL AMBIGUITY, stated rather than hidden. A dashed edge between a
3603
+ // conditional field and a plain one can be read locally as if the plain one
3604
+ // were conditional too. Today's drawing has the same ambiguity whenever the
3605
+ // dash happens to win the overwrite; this is strictly better, not solved.
3606
+ //
3607
+ // WHAT IT COSTS. Two adjacent marked cells put 3px of ink at their shared
3608
+ // boundary (ring + line + ring) against 1px elsewhere: the grid stays even,
3609
+ // but that region reads heavier. Four differently marked cells meeting at a
3610
+ // point give four ring corners around one crossing — busy at 8×, invisible
3611
+ // at 1×.
3612
+ //
3613
+ // `edges` are runs produced by edgeRuns(); `def` is the block's grid colour.
3614
+ function edgeSvg(edges, def){
3615
+ const W=1, out=[];
3616
+ 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)
3617
+ +'" stroke="'+col+'"'+(w!==1?' stroke-width="'+w+'"':'')+(dash?' stroke-dasharray="5 3"':'')+'/>';
3618
+ for(const g of edges){
3619
+ const A=g.a, B=g.b;
3620
+ // The shared line: default colour, full weight, dashed iff EITHER owner
3621
+ // carries `present=`. A field with both `stroke=` and `present=` therefore
3622
+ // gets a dashed boundary AND a separate coloured ring, not a coloured
3623
+ // dash — the two marks answer different questions and must not merge.
3624
+ out.push(L(g.v, g.p, g.s, g.e, def, (A&&A.d)||(B&&B.d), W));
3625
+ // The class marks: solid, full weight, inset INSIDE their own cell. An end
3626
+ // pulls in by W/2 where the owner stops and the ring turns the corner;
3627
+ // it is left long where the owner continues past that end and the next run
3628
+ // carries on, so a run that ends only because the NEIGHBOUR changed joins
3629
+ // with no seam and no ring protrudes past its own cell. The test is the
3630
+ // ownership map (does this cell own the next unit too?), not the geometry.
3631
+ 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));
3632
+ 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));
3633
+ }
3634
+ return out.join('');
3635
+ }
3636
+ // Walk one grid line unit by unit and merge collinear units that have the same
3637
+ // pair of owners into a single run. `v` is 1 for a vertical line, `p` its fixed
3638
+ // coordinate; `n` units; `span(i)` -> [start,end]; `ownAt(i)` -> [before,after]
3639
+ // where an owner is {id, c:colour|null, d:dashed}. A unit with no owner, or
3640
+ // with the SAME owner on both sides (a merged/spanning cell's interior), draws
3641
+ // nothing at all — which is where the doubled interior lines went.
3642
+ function edgeRuns(v, p, n, span, ownAt){
3643
+ const out=[]; let run=null;
3644
+ const sid=(o)=>o?o.id:null;
3645
+ const cont=(i,k,o)=>!!o && sid((ownAt(i)||[])[k])===o.id;
3646
+ for(let i=0;i<n;i++){
3647
+ const o=ownAt(i)||[], a=o[0]||null, b=o[1]||null;
3648
+ const skip=(!a&&!b)||(a&&b&&a.id===b.id);
3649
+ const key=skip?null:JSON.stringify([a&&[a.c,a.d], b&&[b.c,b.d]]);
3650
+ const sp=span(i);
3651
+ if(run && run.key===key && run.e===sp[0]){ run.e=sp[1]; run.i1=i; continue; }
3652
+ if(run){ out.push(run); run=null; }
3653
+ if(!skip) run={v:v,p:p,s:sp[0],e:sp[1],key:key,a:a,b:b,i0:i,i1:i};
3654
+ }
3655
+ if(run) out.push(run);
3656
+ for(const r of out){
3657
+ r.ca0=cont(r.i0-1,0,r.a); r.ca1=cont(r.i1+1,0,r.a);
3658
+ r.cb0=cont(r.i0-1,1,r.b); r.cb1=cont(r.i1+1,1,r.b);
3659
+ }
3660
+ return out;
3661
+ }
3662
+
3580
3663
  // ---- bitfield ----
3581
3664
  function renderBitfield(b,y0){
3582
3665
  const cell=Math.max(18,Math.min(28,Math.floor(760/b.word))), rh=30, ruler=16;
@@ -3769,9 +3852,11 @@ function renderBitfield(b,y0){
3769
3852
  // one edge a per-row rect cannot leave out: a rect strokes four sides or
3770
3853
  // none, and "none" would also lose the two verticals that must continue.
3771
3854
  // Fill stays a single closed region, so `fill=` paints the field once with
3772
- // no seam, and the stroke carries `present=`'s dash around the whole
3773
- // outline instead of around each row (`STYLE-KEY-SCOPE`: the dash IS conditional
3774
- // presence, and nothing else may set or clear it).
3855
+ // no seam. Since 0.1.6 the path is unstroked and the boundary comes from the
3856
+ // shared-edge grid instead; the internal boundary is still never drawn,
3857
+ // because both of its sides are owned by the same field and edgeRuns() skips
3858
+ // a unit whose two owners are one (`STYLE-KEY-SCOPE`: the dash IS conditional presence,
3859
+ // and nothing else may set or clear it).
3775
3860
  const boxOutline=(bx)=>{
3776
3861
  const bands=[];
3777
3862
  for(const s of bx){
@@ -3793,6 +3878,11 @@ function renderBitfield(b,y0){
3793
3878
  }
3794
3879
  return 'M'+pts.map(p=>p[0]+' '+p[1]).join(' L')+' Z';
3795
3880
  };
3881
+ // Shared-edge grid (0.1.6): field fills carry no stroke of their own. Every
3882
+ // bit column of every row records which field owns it, and the grid is
3883
+ // emitted once, edge by edge, after the fields — see edgeSvg() above.
3884
+ const BOWN=new Map(), BDEF=b.stroke||'#555';
3885
+ const bkey=(rw,cl)=>rw+':'+cl; let BOXN=0;
3796
3886
  let pos=0; // bit cursor
3797
3887
  for(const f of b.fields){
3798
3888
  if(f.wrap){ pos=Math.ceil((pos||1)/b.word)*b.word; continue; }
@@ -3818,9 +3908,11 @@ function renderBitfield(b,y0){
3818
3908
  // `PRESENCE-CONDITION-EXPRESSION`: the carrier is now `present=`, and BOTH of its
3819
3909
  // written forms dash — `present=""` claims conditional presence just as
3820
3910
  // `present="C = 1"` does; only the caption below distinguishes them.
3821
- const dash=f.present!==undefined?' stroke-dasharray="5 3"':'';
3911
+ // 0.1.6: the dash and the class colour no longer ride on the box's own
3912
+ // stroke — they are properties of the shared boundary and of the ring
3913
+ // inside the cell respectively, recorded per bit below and drawn once.
3822
3914
  const cfill=f.fill||b.fill||'#fff';
3823
- const paint=' fill="'+cfill+'" stroke="'+(f.stroke||b.stroke||'#555')+'"'+dash;
3915
+ const paint=' fill="'+cfill+'" stroke="none"';
3824
3916
  // Draw one occurrence. `suffix` is the derived index label — '' for a
3825
3917
  // field that is not one element of a run, ' [0]' / ' [n]' for the two
3826
3918
  // occurrences `REPEATED-RUN-DRAWING` draws. It is APPENDED to the author's label, and the
@@ -3855,6 +3947,13 @@ function renderBitfield(b,y0){
3855
3947
  ? '<rect x="'+bx[0].x+'" y="'+bx[0].y+'" width="'+bx[0].w+'" height="'+(bx.length*rh)+'"'+paint
3856
3948
  : '<path d="'+boxOutline(bx)+'"'+paint;
3857
3949
  const tag=flat?'rect':'path';
3950
+ // Record ownership per BIT, one entry per (row, column) this occurrence
3951
+ // covers. Every occurrence of a repeated field is its own owner id, so
3952
+ // the boundary between two occurrences is a real boundary and is drawn.
3953
+ const bid=b.id+'_'+(BOXN++);
3954
+ const brec={id:bid, c:f.stroke||null, d:f.present!==undefined};
3955
+ bx.forEach(function(s){ const c0=Math.round(s.x/cell), n=Math.round(s.w/cell);
3956
+ for(let k=0;k<n;k++) BOWN.set(bkey(s.row,c0+k), brec); });
3858
3957
  svg.push(desc?shape+'>'+desc+'</'+tag+'>':shape+'/>');
3859
3958
  // ONE caption per box: the NAME on the first box, CONT on every later
3860
3959
  // one. The first box, not the widest — reading order is the order the
@@ -3915,22 +4014,58 @@ function renderBitfield(b,y0){
3915
4014
  }
3916
4015
  }
3917
4016
  }
4017
+ // The shared-edge grid for the bitfield: one pass over every vertical bit
4018
+ // boundary and every horizontal row boundary, each emitted once.
4019
+ let maxRow=-1;
4020
+ BOWN.forEach(function(v,k){ const r=+k.split(':')[0]; if(r>maxRow) maxRow=r; });
4021
+ const yrow=(r)=>y+r*rh+shiftFor(r);
4022
+ const at=(r,c)=>(r<0||r>maxRow||c<0||c>=b.word)?null:(BOWN.get(bkey(r,c))||null);
4023
+ const BE=[];
4024
+ for(let c=0;c<=b.word;c++)
4025
+ BE.push.apply(BE, edgeRuns(1, c*cell, maxRow+1,
4026
+ i=>[yrow(i), yrow(i)+rh], i=>[at(i,c-1), at(i,c)]));
4027
+ // A horizontal boundary is a row's TOP, shared with the row above when the
4028
+ // two are vertically adjacent. A row also needs a bottom of its own when the
4029
+ // row below is not adjacent — an elision strip has been inserted between
4030
+ // them, and shiftFor() has pushed it down.
4031
+ const hb=[];
4032
+ for(let r=0;r<=maxRow;r++){
4033
+ hb.push({y:yrow(r), ar:(r>0 && yrow(r-1)+rh===yrow(r))?r-1:-1, br:r});
4034
+ if(r===maxRow || yrow(r+1)!==yrow(r)+rh) hb.push({y:yrow(r)+rh, ar:r, br:-1});
4035
+ }
4036
+ for(const hbe of hb)
4037
+ BE.push.apply(BE, edgeRuns(0, hbe.y, b.word,
4038
+ i=>[i*cell, (i+1)*cell], i=>[hbe.ar<0?null:at(hbe.ar,i), hbe.br<0?null:at(hbe.br,i)]));
4039
+ svg.push(edgeSvg(BE, BDEF));
3918
4040
  for(const e of elis){
3919
4041
  const st=(b.stroke||'#555');
3920
- // `ELISION-MARK-EXTENT`: THE SIDE DOTS SAY WHICH COLUMNS ARE ELIDED, so a
3921
- // strip that already spans the whole word does not draw them. RFC 8754 §2
3922
- // prints its `...` row with NO `|` at either end — the box is simply open
3923
- // there and an element occupying the full word needs no mark to say
3924
- // which columns it occupied, because it occupied all of them. The dots
3925
- // still carry information for a narrower element (reference/bitfield.fd's
3926
- // `Queue Depth` runs columns 16-31), where without them the strip's extent
3927
- // is guesswork. Derived from the geometry, never an option key: the same
3928
- // shape as `REPEATED-RUN-DRAWING`'s rule for the mark itself, which draws iff something is
3929
- // in fact undrawn.
3930
- if(e.w < b.word*cell){
3931
- svg.push('<line x1="'+e.x+'" y1="'+e.y+'" x2="'+e.x+'" y2="'+(e.y+EL_H)+'" stroke="'+st+'" stroke-dasharray="2 3"/>');
3932
- 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"/>');
3933
- }
4042
+ // `ELISION-MARK-EXTENT`: THE SIDE DOTS ARE ALWAYS DRAWN. This reverses `ELISION-MARK-EXTENT`,
4043
+ // which suppressed them when the strip already spanned the whole word.
4044
+ //
4045
+ // `ELISION-MARK-EXTENT`'s reasoning was sound and incomplete. It asked what the dots MEAN
4046
+ // which columns are elided found that answer vacuous for an element
4047
+ // occupying every column, and removed them. What it never asked is what
4048
+ // the dots DO. They have a second job that was never written down: they
4049
+ // make the gap read as part of the figure. Without them a full-word
4050
+ // elision is two boxes with whitespace between and a 10.5px grey ellipsis
4051
+ // floating in it, which reads as a rendering artefact rather than as a
4052
+ // deliberate mark. That is the reading the maintainer had on seeing it,
4053
+ // and the author of a convention misreading its own output is the
4054
+ // strongest evidence available that the convention does not communicate.
4055
+ //
4056
+ // `ELISION-MARK-EXTENT` also copied RFC 8754 §2's `...` row, which indeed carries no `|`,
4057
+ // without copying what makes it legible there: in a monospace block the
4058
+ // lines above and below put `|` in the same two columns, so the eye reads
4059
+ // one break in a continuous wall. This renderer is far sparser — bordered
4060
+ // boxes with a 16px gap and no frame across it — so the same mark in the
4061
+ // same place does not do the same work. A mark borrowed without its
4062
+ // context is not the same mark.
4063
+ //
4064
+ // The general rule this leaves: before removing a mark because its stated
4065
+ // meaning is redundant, establish that the stated meaning was its only
4066
+ // job.
4067
+ svg.push('<line x1="'+e.x+'" y1="'+e.y+'" x2="'+e.x+'" y2="'+(e.y+EL_H)+'" stroke="'+st+'" stroke-dasharray="2 3"/>');
4068
+ 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"/>');
3934
4069
  let fs=10.5; const need=e.text.length*6.2;
3935
4070
  if(need>e.w-6) fs=Math.max(7,10.5*(e.w-6)/need);
3936
4071
  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>');
@@ -3997,6 +4132,14 @@ function renderTable(t,y0){
3997
4132
  // cell marks: h1..hN address header tiers top-down, r>=1 the data rows
3998
4133
  const markOf=(r,c)=>(t.marks||[]).find(mk=>(mk.hdr?mk.r-1:H+mk.r-1)===r&&mk.c===c+1);
3999
4134
  const yTop=y+4;
4135
+ // Shared-edge grid (0.1.6): cell fills carry no stroke of their own. Every
4136
+ // grid cell records its owning (anchor) cell, and the grid is emitted once,
4137
+ // edge by edge, after the cells — see edgeSvg() above. Table cells have no
4138
+ // conditional-presence mark, so `d` is always false here; the dash branch
4139
+ // exists for the bitfield and is kept common so both genres draw alike.
4140
+ const NC=t.cols.length, DEF=t.stroke||'#c9c7bf';
4141
+ const OWN=grid.map(()=>new Array(NC).fill(null));
4142
+ const xAt=[0]; for(let i=0;i<NC;i++) xAt.push(xAt[i]+widths[i]);
4000
4143
  for(let r=0;r<grid.length;r++){
4001
4144
  for(let c=0;c<grid[r].length;c++){
4002
4145
  const cell=grid[r][c];
@@ -4012,7 +4155,11 @@ function renderTable(t,y0){
4012
4155
  // addressable cells carry table-id:row:col (row 0 = bottom header tier)
4013
4156
  const addrR = r>=H ? (r-H+1) : (r===H-1 ? 0 : null);
4014
4157
  const addr = addrR===null ? '' : ' data-cell="'+t.id+':'+addrR+':'+(c+1)+'" style="cursor:pointer"';
4015
- svg.push('<rect x="'+x+'" y="'+yy+'" width="'+wsum+'" height="'+h+'" fill="'+fill+'" stroke="'+((mk&&mk.stroke)||t.stroke||'#c9c7bf')+'"'+addr+'/>');
4158
+ // A merged cell owns every grid square it spans, so its internal
4159
+ // boundaries have the same owner on both sides and are never drawn.
4160
+ const rec={id:'c'+r+'_'+c, c:(mk&&mk.stroke)||null, d:false};
4161
+ for(let rr=r;rr<r+rs;rr++) for(let cc=c;cc<c+cs;cc++) if(OWN[rr]) OWN[rr][cc]=rec;
4162
+ svg.push('<rect x="'+x+'" y="'+yy+'" width="'+wsum+'" height="'+h+'" fill="'+fill+'" stroke="none"'+addr+'/>');
4016
4163
  // alignment: headers centered; data follows GFM colon alignment (default left)
4017
4164
  const al=cell.hdr?'center':(alignOf(c)||'left');
4018
4165
  const tx=al==='center'?x+wsum/2:(al==='right'?x+wsum-7:x+7);
@@ -4028,6 +4175,16 @@ function renderTable(t,y0){
4028
4175
  }
4029
4176
  }
4030
4177
  }
4178
+ // Each edge drawn exactly once, owned by the (at most two) cells it divides.
4179
+ const EDG=[];
4180
+ const cellAt=(r,c)=>(r<0||r>=grid.length||c<0||c>=NC)?null:OWN[r][c];
4181
+ for(let c=0;c<=NC;c++)
4182
+ EDG.push.apply(EDG, edgeRuns(1, xAt[c], grid.length,
4183
+ i=>[yTop+yAt[i], yTop+yAt[i+1]], i=>[cellAt(i,c-1), cellAt(i,c)]));
4184
+ for(let r=0;r<=grid.length;r++)
4185
+ EDG.push.apply(EDG, edgeRuns(0, yTop+yAt[r], NC,
4186
+ i=>[xAt[i], xAt[i+1]], i=>[cellAt(r-1,i), cellAt(r,i)]));
4187
+ svg.push(edgeSvg(EDG, DEF));
4031
4188
  const yEnd=yTop+yAt[grid.length];
4032
4189
  return {svg:svg.join(''), y:yEnd+6, w:totalW+2};
4033
4190
  }