Export PNG matches the editor: tints, strength, underlines, opens, near-miss

The export now mirrors render() — per-token fills scaled by strength,
rhyming words tinted in their family color, sub-word tail underlines,
gray unanswered endings, near-miss dotted marks, plus the existing
alliteration/rhythm/header styling.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 17:39:33 -04:00
parent b1d411c8e7
commit 5f22e28721
+63 -28
View File
@@ -1075,49 +1075,84 @@ document.getElementById('exportBtn').addEventListener('click', async ()=>{
x.fillStyle = bg; x.fillRect(0, 0, w, h);
x.font = font; x.textBaseline = 'middle';
const groupInfo = {}, tokByLine = {};
const hexRgb = hex =>{ hex = hex.replace('#',''); if(hex.length===3) hex=hex.split('').map(c=>c+c).join('');
return [parseInt(hex.slice(0,2),16),parseInt(hex.slice(2,4),16),parseInt(hex.slice(4,6),16)]; };
const inkRgb = hexRgb(ink), dimRgb = hexRgb(css.getPropertyValue('--ink-dim').trim());
const mix = (a,b,t)=>`rgb(${a.map((v,i)=>Math.round(v+(b[i]-v)*t)).join(',')})`;
const groupInfo = {}, tokByLine = {}, openByLine = {}, nearByLine = {};
if(analysis){
analysis.groups.forEach(g=>{ groupInfo[g.id] = g; });
analysis.tokens.forEach(t=>{ (tokByLine[t.l] ||= []).push(t); });
if(analysis.open) analysis.open.forEach(t=>{ (openByLine[t.l] ||= []).push(t); });
if(analysis.near) analysis.near.forEach(t=>{ (nearByLine[t.l] ||= []).push(t); });
}
const xat = (line, c)=> PAD + x.measureText(line.slice(0, c)).width;
lines.forEach((line, i)=>{
const y = PAD + i * LH + LH / 2;
const fresh = analysis && analysis.lines[i] === line;
const toks = (rhymeToggle.checked && fresh ? (tokByLine[i] || []) : []).filter(t=>groupInfo[t.g]);
const on = rhymeToggle.checked && fresh;
const toks = (on ? (tokByLine[i] || []) : []).filter(t=>groupInfo[t.g]);
const words = toks.filter(t=>!t.ph), phrases = toks.filter(t=>t.ph);
const cuts = new Set([0, line.length]);
toks.forEach(t=>{ cuts.add(t.s); cuts.add(t.e); });
const pts = [...cuts].sort((a,b)=>a-b);
for(let k = 0; k < pts.length - 1; k++){
const a = pts[k], b = pts[k+1];
const wt = words.find(t=>t.s <= a && b <= t.e);
const pt = phrases.find(t=>t.s <= a && b <= t.e);
if(!wt && !pt) continue;
const t = wt || pt;
x.globalAlpha = wt ? (wt.end ? 0.34 : 0.19) : (pt.end ? 0.24 : 0.14);
x.fillStyle = palette[groupInfo[t.g].color % COLORS];
const x0 = PAD + x.measureText(line.slice(0, a)).width;
const wpx = x.measureText(line.slice(a, b)).width;
const opens = on ? (openByLine[i] || []) : [];
const nears = on ? (nearByLine[i] || []) : [];
const palHex = t => palette[groupInfo[t.g].color % COLORS];
// 1) fills — one rounded rect per token, opacity scaled by strength
const fillTok = (t, end0)=>{
let base = !t.ph ? (t.end ? 34 : 19) : (t.end ? 24 : 14);
const str = t.str != null ? t.str : (groupInfo[t.g].strength || 1);
x.globalAlpha = (base * (0.4 + 0.6*str)) / 100;
x.fillStyle = palHex(t);
x.beginPath();
x.roundRect(x0 - 2, y - FS * 0.72, wpx + 4, FS * 1.42, 4);
x.fill();
x.roundRect(xat(line,t.s) - 2, y - FS*0.72, xat(line,t.e)-xat(line,t.s) + 4, FS*1.42, 4);
x.fill(); x.globalAlpha = 1;
};
phrases.forEach(t=>fillTok(t)); words.forEach(t=>fillTok(t));
// gray fill on unanswered endings
opens.forEach(t=>{
x.globalAlpha = 0.13; x.fillStyle = ink;
x.beginPath();
x.roundRect(xat(line,t.s) - 2, y - FS*0.72, xat(line,t.e)-xat(line,t.s) + 4, FS*1.42, 4);
x.fill(); x.globalAlpha = 1;
});
// 2) base text (ink, or dim for headers/annotations)
const isHdr = /^\s*#/.test(line), isAnno = /^\s*[([]/.test(line);
x.font = isHdr ? '700 ' + font : font;
x.fillStyle = isHdr ? '#8a7d6c' : isAnno ? '#6a5f52' : ink;
x.fillText(line, PAD, y);
x.font = font;
// 3) tint each rhyming word in its family color, over the ink
words.forEach(t=>{
const str = t.str != null ? t.str : (groupInfo[t.g].strength || 1);
x.fillStyle = mix(inkRgb, hexRgb(palHex(t)), 0.28 + 0.32*str);
x.fillText(line.slice(t.s, t.e), xat(line,t.s), y);
});
// 4) sub-word tail underline (rs..e) per word
words.forEach(t=>{
if(t.rs == null) return;
const str = t.str != null ? t.str : 1;
x.globalAlpha = 0.42 * str; x.fillStyle = palHex(t);
x.fillRect(xat(line,t.rs), y + FS*0.62, xat(line,t.e)-xat(line,t.rs), 1.5);
x.globalAlpha = 1;
}
});
// alliteration underline
if(allitToggle.checked && fresh){
(analysis.allit || []).filter(t=>t.l===i).forEach(t=>{
const left = PAD + x.measureText(line.slice(0, t.s)).width;
const wpx = x.measureText(line.slice(t.s, t.e)).width;
x.globalAlpha = 0.75;
x.fillStyle = palette[t.g % COLORS];
x.fillRect(left - 1, y + FS * 0.55, wpx + 2, 2);
x.globalAlpha = 0.75; x.fillStyle = palette[t.g % COLORS];
x.fillRect(xat(line,t.s) - 1, y + FS*0.66, xat(line,t.e)-xat(line,t.s) + 2, 2);
x.globalAlpha = 1;
});
}
const isHdr = /^\s*#/.test(line);
x.font = isHdr ? '700 ' + font : font;
x.fillStyle = isHdr ? '#8a7d6c' : /^\s*[([]/.test(line) ? '#6a5f52' : ink;
x.fillText(line, PAD, y);
x.font = font;
// near-miss: dotted gold underline
nears.forEach(t=>{
x.fillStyle = mix(inkRgb, hexRgb(css.getPropertyValue('--accent-2').trim()), 0.6);
let px = xat(line,t.s), end = xat(line,t.e);
while(px < end){ x.fillRect(px, y + FS*0.62, 1.6, 1.6); px += 3.5; }
});
if(rhythm && fresh){
const spans = (analysis.stress.filter(s=>s.l===i)).sort((a,b)=>a.s-b.s);
x.font = "8px 'Spline Sans Mono', monospace";