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.
- package/.claude-plugin/plugin.json +25 -0
- package/README.md +22 -1
- package/dist/figdown.js +180 -23
- package/dist/figdown.mjs +180 -23
- package/examples/evpn-fabric.svg +1 -1
- package/examples/showcase/arp-resolution.svg +1 -1
- package/examples/showcase/ethernet-frame.svg +1 -1
- package/examples/showcase/l2-forwarding-logic.svg +1 -1
- package/examples/showcase/tcp-handshake.svg +1 -1
- package/examples/showcase/tcp-header.svg +1 -1
- package/examples/showcase/tcp-state-machine.svg +1 -1
- package/package.json +3 -1
- package/skill/figdown/figdown.html +178 -21
|
@@ -121,7 +121,7 @@ const SHAPES = ['box','rounded','circle','ellipse','diamond','cylinder'];
|
|
|
121
121
|
// input to that promise, and under core §13 a 0.x renderer may differ from
|
|
122
122
|
// the next — which makes the recorded version the only thing that can
|
|
123
123
|
// explain a diff between two renderings of one source.
|
|
124
|
-
const FIGDOWN_VERSION = '0.1.
|
|
124
|
+
const FIGDOWN_VERSION = '0.1.6';
|
|
125
125
|
// Retired shape VALUES keep a named diagnostic (PROCESS §5(d)), the same way
|
|
126
126
|
// retired option keys do: `cloud` was the one value that named a domain
|
|
127
127
|
// (the internet cloud) in an enum the language keeps purely geometric
|
|
@@ -3682,6 +3682,89 @@ function routeAround(p,q,obs){
|
|
|
3682
3682
|
return pts.length>2?pts:null;
|
|
3683
3683
|
}
|
|
3684
3684
|
|
|
3685
|
+
// ---- shared-edge grid (0.1.6) ----
|
|
3686
|
+
// A boundary between two cells belongs to BOTH of them. Drawing each cell as a
|
|
3687
|
+
// stroked rect drew that boundary twice, and the second painting won: a marked
|
|
3688
|
+
// cell's colour, or a conditional field's dash, was whatever the neighbour
|
|
3689
|
+
// happened to paint last. So the grid is now emitted edge-by-edge, each edge
|
|
3690
|
+
// exactly once, and its appearance is decided by its (at most two) owners.
|
|
3691
|
+
//
|
|
3692
|
+
// WHAT THE OLD DRAWING DESTROYED. A `present=` field's shared edge was
|
|
3693
|
+
// overwritten solid by a plain neighbour. `STYLE-KEY-SCOPE` rules that the dash IS
|
|
3694
|
+
// conditional presence and that nothing else can set or clear it — so the
|
|
3695
|
+
// model's flag was being silently erased at render time. This is a
|
|
3696
|
+
// correctness fix, not a cosmetic one.
|
|
3697
|
+
//
|
|
3698
|
+
// WHY NOT HALF-WIDTH. The obvious alternative gives each owner half the
|
|
3699
|
+
// boundary: two 0.5px bands, one per side. Point-sampled at 1× — the size a
|
|
3700
|
+
// figure is actually read at — a shared edge between an orange mark and a
|
|
3701
|
+
// green one reads #2E6E34: one muddy pixel with the orange unrecoverable,
|
|
3702
|
+
// because half-width strokes sit at quarter-pixel offsets and the rasteriser
|
|
3703
|
+
// averages them. The bands here land on integer pixels and every colour
|
|
3704
|
+
// survives exactly. A zoomed crop hides this entirely, which is why the
|
|
3705
|
+
// decision was made at 1× and must be re-checked at 1× if it is revisited.
|
|
3706
|
+
//
|
|
3707
|
+
// THE RESIDUAL AMBIGUITY, stated rather than hidden. A dashed edge between a
|
|
3708
|
+
// conditional field and a plain one can be read locally as if the plain one
|
|
3709
|
+
// were conditional too. Today's drawing has the same ambiguity whenever the
|
|
3710
|
+
// dash happens to win the overwrite; this is strictly better, not solved.
|
|
3711
|
+
//
|
|
3712
|
+
// WHAT IT COSTS. Two adjacent marked cells put 3px of ink at their shared
|
|
3713
|
+
// boundary (ring + line + ring) against 1px elsewhere: the grid stays even,
|
|
3714
|
+
// but that region reads heavier. Four differently marked cells meeting at a
|
|
3715
|
+
// point give four ring corners around one crossing — busy at 8×, invisible
|
|
3716
|
+
// at 1×.
|
|
3717
|
+
//
|
|
3718
|
+
// `edges` are runs produced by edgeRuns(); `def` is the block's grid colour.
|
|
3719
|
+
function edgeSvg(edges, def){
|
|
3720
|
+
const W=1, out=[];
|
|
3721
|
+
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)
|
|
3722
|
+
+'" stroke="'+col+'"'+(w!==1?' stroke-width="'+w+'"':'')+(dash?' stroke-dasharray="5 3"':'')+'/>';
|
|
3723
|
+
for(const g of edges){
|
|
3724
|
+
const A=g.a, B=g.b;
|
|
3725
|
+
// The shared line: default colour, full weight, dashed iff EITHER owner
|
|
3726
|
+
// carries `present=`. A field with both `stroke=` and `present=` therefore
|
|
3727
|
+
// gets a dashed boundary AND a separate coloured ring, not a coloured
|
|
3728
|
+
// dash — the two marks answer different questions and must not merge.
|
|
3729
|
+
out.push(L(g.v, g.p, g.s, g.e, def, (A&&A.d)||(B&&B.d), W));
|
|
3730
|
+
// The class marks: solid, full weight, inset INSIDE their own cell. An end
|
|
3731
|
+
// pulls in by W/2 where the owner stops and the ring turns the corner;
|
|
3732
|
+
// it is left long where the owner continues past that end and the next run
|
|
3733
|
+
// carries on, so a run that ends only because the NEIGHBOUR changed joins
|
|
3734
|
+
// with no seam and no ring protrudes past its own cell. The test is the
|
|
3735
|
+
// ownership map (does this cell own the next unit too?), not the geometry.
|
|
3736
|
+
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));
|
|
3737
|
+
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));
|
|
3738
|
+
}
|
|
3739
|
+
return out.join('');
|
|
3740
|
+
}
|
|
3741
|
+
// Walk one grid line unit by unit and merge collinear units that have the same
|
|
3742
|
+
// pair of owners into a single run. `v` is 1 for a vertical line, `p` its fixed
|
|
3743
|
+
// coordinate; `n` units; `span(i)` -> [start,end]; `ownAt(i)` -> [before,after]
|
|
3744
|
+
// where an owner is {id, c:colour|null, d:dashed}. A unit with no owner, or
|
|
3745
|
+
// with the SAME owner on both sides (a merged/spanning cell's interior), draws
|
|
3746
|
+
// nothing at all — which is where the doubled interior lines went.
|
|
3747
|
+
function edgeRuns(v, p, n, span, ownAt){
|
|
3748
|
+
const out=[]; let run=null;
|
|
3749
|
+
const sid=(o)=>o?o.id:null;
|
|
3750
|
+
const cont=(i,k,o)=>!!o && sid((ownAt(i)||[])[k])===o.id;
|
|
3751
|
+
for(let i=0;i<n;i++){
|
|
3752
|
+
const o=ownAt(i)||[], a=o[0]||null, b=o[1]||null;
|
|
3753
|
+
const skip=(!a&&!b)||(a&&b&&a.id===b.id);
|
|
3754
|
+
const key=skip?null:JSON.stringify([a&&[a.c,a.d], b&&[b.c,b.d]]);
|
|
3755
|
+
const sp=span(i);
|
|
3756
|
+
if(run && run.key===key && run.e===sp[0]){ run.e=sp[1]; run.i1=i; continue; }
|
|
3757
|
+
if(run){ out.push(run); run=null; }
|
|
3758
|
+
if(!skip) run={v:v,p:p,s:sp[0],e:sp[1],key:key,a:a,b:b,i0:i,i1:i};
|
|
3759
|
+
}
|
|
3760
|
+
if(run) out.push(run);
|
|
3761
|
+
for(const r of out){
|
|
3762
|
+
r.ca0=cont(r.i0-1,0,r.a); r.ca1=cont(r.i1+1,0,r.a);
|
|
3763
|
+
r.cb0=cont(r.i0-1,1,r.b); r.cb1=cont(r.i1+1,1,r.b);
|
|
3764
|
+
}
|
|
3765
|
+
return out;
|
|
3766
|
+
}
|
|
3767
|
+
|
|
3685
3768
|
// ---- bitfield ----
|
|
3686
3769
|
function renderBitfield(b,y0){
|
|
3687
3770
|
const cell=Math.max(18,Math.min(28,Math.floor(760/b.word))), rh=30, ruler=16;
|
|
@@ -3874,9 +3957,11 @@ function renderBitfield(b,y0){
|
|
|
3874
3957
|
// one edge a per-row rect cannot leave out: a rect strokes four sides or
|
|
3875
3958
|
// none, and "none" would also lose the two verticals that must continue.
|
|
3876
3959
|
// Fill stays a single closed region, so `fill=` paints the field once with
|
|
3877
|
-
// no seam
|
|
3878
|
-
//
|
|
3879
|
-
//
|
|
3960
|
+
// no seam. Since 0.1.6 the path is unstroked and the boundary comes from the
|
|
3961
|
+
// shared-edge grid instead; the internal boundary is still never drawn,
|
|
3962
|
+
// because both of its sides are owned by the same field and edgeRuns() skips
|
|
3963
|
+
// a unit whose two owners are one (`STYLE-KEY-SCOPE`: the dash IS conditional presence,
|
|
3964
|
+
// and nothing else may set or clear it).
|
|
3880
3965
|
const boxOutline=(bx)=>{
|
|
3881
3966
|
const bands=[];
|
|
3882
3967
|
for(const s of bx){
|
|
@@ -3898,6 +3983,11 @@ function renderBitfield(b,y0){
|
|
|
3898
3983
|
}
|
|
3899
3984
|
return 'M'+pts.map(p=>p[0]+' '+p[1]).join(' L')+' Z';
|
|
3900
3985
|
};
|
|
3986
|
+
// Shared-edge grid (0.1.6): field fills carry no stroke of their own. Every
|
|
3987
|
+
// bit column of every row records which field owns it, and the grid is
|
|
3988
|
+
// emitted once, edge by edge, after the fields — see edgeSvg() above.
|
|
3989
|
+
const BOWN=new Map(), BDEF=b.stroke||'#555';
|
|
3990
|
+
const bkey=(rw,cl)=>rw+':'+cl; let BOXN=0;
|
|
3901
3991
|
let pos=0; // bit cursor
|
|
3902
3992
|
for(const f of b.fields){
|
|
3903
3993
|
if(f.wrap){ pos=Math.ceil((pos||1)/b.word)*b.word; continue; }
|
|
@@ -3923,9 +4013,11 @@ function renderBitfield(b,y0){
|
|
|
3923
4013
|
// `PRESENCE-CONDITION-EXPRESSION`: the carrier is now `present=`, and BOTH of its
|
|
3924
4014
|
// written forms dash — `present=""` claims conditional presence just as
|
|
3925
4015
|
// `present="C = 1"` does; only the caption below distinguishes them.
|
|
3926
|
-
|
|
4016
|
+
// 0.1.6: the dash and the class colour no longer ride on the box's own
|
|
4017
|
+
// stroke — they are properties of the shared boundary and of the ring
|
|
4018
|
+
// inside the cell respectively, recorded per bit below and drawn once.
|
|
3927
4019
|
const cfill=f.fill||b.fill||'#fff';
|
|
3928
|
-
const paint=' fill="'+cfill+'" stroke="
|
|
4020
|
+
const paint=' fill="'+cfill+'" stroke="none"';
|
|
3929
4021
|
// Draw one occurrence. `suffix` is the derived index label — '' for a
|
|
3930
4022
|
// field that is not one element of a run, ' [0]' / ' [n]' for the two
|
|
3931
4023
|
// occurrences `REPEATED-RUN-DRAWING` draws. It is APPENDED to the author's label, and the
|
|
@@ -3960,6 +4052,13 @@ function renderBitfield(b,y0){
|
|
|
3960
4052
|
? '<rect x="'+bx[0].x+'" y="'+bx[0].y+'" width="'+bx[0].w+'" height="'+(bx.length*rh)+'"'+paint
|
|
3961
4053
|
: '<path d="'+boxOutline(bx)+'"'+paint;
|
|
3962
4054
|
const tag=flat?'rect':'path';
|
|
4055
|
+
// Record ownership per BIT, one entry per (row, column) this occurrence
|
|
4056
|
+
// covers. Every occurrence of a repeated field is its own owner id, so
|
|
4057
|
+
// the boundary between two occurrences is a real boundary and is drawn.
|
|
4058
|
+
const bid=b.id+'_'+(BOXN++);
|
|
4059
|
+
const brec={id:bid, c:f.stroke||null, d:f.present!==undefined};
|
|
4060
|
+
bx.forEach(function(s){ const c0=Math.round(s.x/cell), n=Math.round(s.w/cell);
|
|
4061
|
+
for(let k=0;k<n;k++) BOWN.set(bkey(s.row,c0+k), brec); });
|
|
3963
4062
|
svg.push(desc?shape+'>'+desc+'</'+tag+'>':shape+'/>');
|
|
3964
4063
|
// ONE caption per box: the NAME on the first box, CONT on every later
|
|
3965
4064
|
// one. The first box, not the widest — reading order is the order the
|
|
@@ -4020,22 +4119,58 @@ function renderBitfield(b,y0){
|
|
|
4020
4119
|
}
|
|
4021
4120
|
}
|
|
4022
4121
|
}
|
|
4122
|
+
// The shared-edge grid for the bitfield: one pass over every vertical bit
|
|
4123
|
+
// boundary and every horizontal row boundary, each emitted once.
|
|
4124
|
+
let maxRow=-1;
|
|
4125
|
+
BOWN.forEach(function(v,k){ const r=+k.split(':')[0]; if(r>maxRow) maxRow=r; });
|
|
4126
|
+
const yrow=(r)=>y+r*rh+shiftFor(r);
|
|
4127
|
+
const at=(r,c)=>(r<0||r>maxRow||c<0||c>=b.word)?null:(BOWN.get(bkey(r,c))||null);
|
|
4128
|
+
const BE=[];
|
|
4129
|
+
for(let c=0;c<=b.word;c++)
|
|
4130
|
+
BE.push.apply(BE, edgeRuns(1, c*cell, maxRow+1,
|
|
4131
|
+
i=>[yrow(i), yrow(i)+rh], i=>[at(i,c-1), at(i,c)]));
|
|
4132
|
+
// A horizontal boundary is a row's TOP, shared with the row above when the
|
|
4133
|
+
// two are vertically adjacent. A row also needs a bottom of its own when the
|
|
4134
|
+
// row below is not adjacent — an elision strip has been inserted between
|
|
4135
|
+
// them, and shiftFor() has pushed it down.
|
|
4136
|
+
const hb=[];
|
|
4137
|
+
for(let r=0;r<=maxRow;r++){
|
|
4138
|
+
hb.push({y:yrow(r), ar:(r>0 && yrow(r-1)+rh===yrow(r))?r-1:-1, br:r});
|
|
4139
|
+
if(r===maxRow || yrow(r+1)!==yrow(r)+rh) hb.push({y:yrow(r)+rh, ar:r, br:-1});
|
|
4140
|
+
}
|
|
4141
|
+
for(const hbe of hb)
|
|
4142
|
+
BE.push.apply(BE, edgeRuns(0, hbe.y, b.word,
|
|
4143
|
+
i=>[i*cell, (i+1)*cell], i=>[hbe.ar<0?null:at(hbe.ar,i), hbe.br<0?null:at(hbe.br,i)]));
|
|
4144
|
+
svg.push(edgeSvg(BE, BDEF));
|
|
4023
4145
|
for(const e of elis){
|
|
4024
4146
|
const st=(b.stroke||'#555');
|
|
4025
|
-
// `ELISION-MARK-EXTENT`: THE SIDE DOTS
|
|
4026
|
-
//
|
|
4027
|
-
//
|
|
4028
|
-
//
|
|
4029
|
-
// which columns
|
|
4030
|
-
//
|
|
4031
|
-
//
|
|
4032
|
-
//
|
|
4033
|
-
//
|
|
4034
|
-
// in
|
|
4035
|
-
|
|
4036
|
-
|
|
4037
|
-
|
|
4038
|
-
|
|
4147
|
+
// `ELISION-MARK-EXTENT`: THE SIDE DOTS ARE ALWAYS DRAWN. This reverses `ELISION-MARK-EXTENT`,
|
|
4148
|
+
// which suppressed them when the strip already spanned the whole word.
|
|
4149
|
+
//
|
|
4150
|
+
// `ELISION-MARK-EXTENT`'s reasoning was sound and incomplete. It asked what the dots MEAN —
|
|
4151
|
+
// which columns are elided — found that answer vacuous for an element
|
|
4152
|
+
// occupying every column, and removed them. What it never asked is what
|
|
4153
|
+
// the dots DO. They have a second job that was never written down: they
|
|
4154
|
+
// make the gap read as part of the figure. Without them a full-word
|
|
4155
|
+
// elision is two boxes with whitespace between and a 10.5px grey ellipsis
|
|
4156
|
+
// floating in it, which reads as a rendering artefact rather than as a
|
|
4157
|
+
// deliberate mark. That is the reading the maintainer had on seeing it,
|
|
4158
|
+
// and the author of a convention misreading its own output is the
|
|
4159
|
+
// strongest evidence available that the convention does not communicate.
|
|
4160
|
+
//
|
|
4161
|
+
// `ELISION-MARK-EXTENT` also copied RFC 8754 §2's `...` row, which indeed carries no `|`,
|
|
4162
|
+
// without copying what makes it legible there: in a monospace block the
|
|
4163
|
+
// lines above and below put `|` in the same two columns, so the eye reads
|
|
4164
|
+
// one break in a continuous wall. This renderer is far sparser — bordered
|
|
4165
|
+
// boxes with a 16px gap and no frame across it — so the same mark in the
|
|
4166
|
+
// same place does not do the same work. A mark borrowed without its
|
|
4167
|
+
// context is not the same mark.
|
|
4168
|
+
//
|
|
4169
|
+
// The general rule this leaves: before removing a mark because its stated
|
|
4170
|
+
// meaning is redundant, establish that the stated meaning was its only
|
|
4171
|
+
// job.
|
|
4172
|
+
svg.push('<line x1="'+e.x+'" y1="'+e.y+'" x2="'+e.x+'" y2="'+(e.y+EL_H)+'" stroke="'+st+'" stroke-dasharray="2 3"/>');
|
|
4173
|
+
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"/>');
|
|
4039
4174
|
let fs=10.5; const need=e.text.length*6.2;
|
|
4040
4175
|
if(need>e.w-6) fs=Math.max(7,10.5*(e.w-6)/need);
|
|
4041
4176
|
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>');
|
|
@@ -4102,6 +4237,14 @@ function renderTable(t,y0){
|
|
|
4102
4237
|
// cell marks: h1..hN address header tiers top-down, r>=1 the data rows
|
|
4103
4238
|
const markOf=(r,c)=>(t.marks||[]).find(mk=>(mk.hdr?mk.r-1:H+mk.r-1)===r&&mk.c===c+1);
|
|
4104
4239
|
const yTop=y+4;
|
|
4240
|
+
// Shared-edge grid (0.1.6): cell fills carry no stroke of their own. Every
|
|
4241
|
+
// grid cell records its owning (anchor) cell, and the grid is emitted once,
|
|
4242
|
+
// edge by edge, after the cells — see edgeSvg() above. Table cells have no
|
|
4243
|
+
// conditional-presence mark, so `d` is always false here; the dash branch
|
|
4244
|
+
// exists for the bitfield and is kept common so both genres draw alike.
|
|
4245
|
+
const NC=t.cols.length, DEF=t.stroke||'#c9c7bf';
|
|
4246
|
+
const OWN=grid.map(()=>new Array(NC).fill(null));
|
|
4247
|
+
const xAt=[0]; for(let i=0;i<NC;i++) xAt.push(xAt[i]+widths[i]);
|
|
4105
4248
|
for(let r=0;r<grid.length;r++){
|
|
4106
4249
|
for(let c=0;c<grid[r].length;c++){
|
|
4107
4250
|
const cell=grid[r][c];
|
|
@@ -4117,7 +4260,11 @@ function renderTable(t,y0){
|
|
|
4117
4260
|
// addressable cells carry table-id:row:col (row 0 = bottom header tier)
|
|
4118
4261
|
const addrR = r>=H ? (r-H+1) : (r===H-1 ? 0 : null);
|
|
4119
4262
|
const addr = addrR===null ? '' : ' data-cell="'+t.id+':'+addrR+':'+(c+1)+'" style="cursor:pointer"';
|
|
4120
|
-
|
|
4263
|
+
// A merged cell owns every grid square it spans, so its internal
|
|
4264
|
+
// boundaries have the same owner on both sides and are never drawn.
|
|
4265
|
+
const rec={id:'c'+r+'_'+c, c:(mk&&mk.stroke)||null, d:false};
|
|
4266
|
+
for(let rr=r;rr<r+rs;rr++) for(let cc=c;cc<c+cs;cc++) if(OWN[rr]) OWN[rr][cc]=rec;
|
|
4267
|
+
svg.push('<rect x="'+x+'" y="'+yy+'" width="'+wsum+'" height="'+h+'" fill="'+fill+'" stroke="none"'+addr+'/>');
|
|
4121
4268
|
// alignment: headers centered; data follows GFM colon alignment (default left)
|
|
4122
4269
|
const al=cell.hdr?'center':(alignOf(c)||'left');
|
|
4123
4270
|
const tx=al==='center'?x+wsum/2:(al==='right'?x+wsum-7:x+7);
|
|
@@ -4133,6 +4280,16 @@ function renderTable(t,y0){
|
|
|
4133
4280
|
}
|
|
4134
4281
|
}
|
|
4135
4282
|
}
|
|
4283
|
+
// Each edge drawn exactly once, owned by the (at most two) cells it divides.
|
|
4284
|
+
const EDG=[];
|
|
4285
|
+
const cellAt=(r,c)=>(r<0||r>=grid.length||c<0||c>=NC)?null:OWN[r][c];
|
|
4286
|
+
for(let c=0;c<=NC;c++)
|
|
4287
|
+
EDG.push.apply(EDG, edgeRuns(1, xAt[c], grid.length,
|
|
4288
|
+
i=>[yTop+yAt[i], yTop+yAt[i+1]], i=>[cellAt(i,c-1), cellAt(i,c)]));
|
|
4289
|
+
for(let r=0;r<=grid.length;r++)
|
|
4290
|
+
EDG.push.apply(EDG, edgeRuns(0, yTop+yAt[r], NC,
|
|
4291
|
+
i=>[xAt[i], xAt[i+1]], i=>[cellAt(r-1,i), cellAt(r,i)]));
|
|
4292
|
+
svg.push(edgeSvg(EDG, DEF));
|
|
4136
4293
|
const yEnd=yTop+yAt[grid.length];
|
|
4137
4294
|
return {svg:svg.join(''), y:yEnd+6, w:totalW+2};
|
|
4138
4295
|
}
|