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.
@@ -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.3';
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, and the stroke carries `present=`'s dash around the whole
3878
- // outline instead of around each row (`STYLE-KEY-SCOPE`: the dash IS conditional
3879
- // presence, and nothing else may set or clear it).
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
- const dash=f.present!==undefined?' stroke-dasharray="5 3"':'';
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="'+(f.stroke||b.stroke||'#555')+'"'+dash;
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 SAY WHICH COLUMNS ARE ELIDED, so a
4026
- // strip that already spans the whole word does not draw them. RFC 8754 §2
4027
- // prints its `...` row with NO `|` at either end — the box is simply open
4028
- // there and an element occupying the full word needs no mark to say
4029
- // which columns it occupied, because it occupied all of them. The dots
4030
- // still carry information for a narrower element (reference/bitfield.fd's
4031
- // `Queue Depth` runs columns 16-31), where without them the strip's extent
4032
- // is guesswork. Derived from the geometry, never an option key: the same
4033
- // shape as `REPEATED-RUN-DRAWING`'s rule for the mark itself, which draws iff something is
4034
- // in fact undrawn.
4035
- if(e.w < b.word*cell){
4036
- svg.push('<line x1="'+e.x+'" y1="'+e.y+'" x2="'+e.x+'" y2="'+(e.y+EL_H)+'" stroke="'+st+'" stroke-dasharray="2 3"/>');
4037
- 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"/>');
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
- svg.push('<rect x="'+x+'" y="'+yy+'" width="'+wsum+'" height="'+h+'" fill="'+fill+'" stroke="'+((mk&&mk.stroke)||t.stroke||'#c9c7bf')+'"'+addr+'/>');
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
  }