1/* public/js/print-grid-svg.js 2 * 3 * Generates pixel-perfect SVG versions of crossword / word-search / 4 * sudoku grids. Reads the existing HTML grid DOM, writes an inline 5 * SVG sibling, and flags the original with `has-print-svg`. 6 * 7 * Why: CSS-grid print rendering suffers from sub-pixel rounding when 8 * cells have fractional widths (1fr columns), producing inconsistent 9 * grid line thicknesses and occasional missing lines. SVG is vector, 10 * renders identically on screen and print at any DPI, no rounding. 11 * 12 * Behaviour: 13 * - Screen: original HTML grid is shown; SVG is display:none 14 * - Print: original HTML grid is display:none; SVG is shown 15 * - JS off / errors: falls back to the CSS-grid print rules in 16 * the per-tool stylesheets (still functional, 17 * just with the old rounding issues) 18 * 19 * SVG drawing approach: each grid line is drawn as a single <line> 20 * element with an integer-pixel position. Cells are drawn as <rect> 21 * with their background colour. Text is placed with <text> and 22 * standard baseline alignment. Nothing relies on computed gap widths 23 * or fractional layout math. 24 */ 25(function () { 26 'use strict'; 27 28 // Visual constants shared across grids. Units are SVG user-units 29 // (viewBox coordinates) â the browser scales these to whatever 30 // the viewport gives us. 31 var COLOUR_LINE = '#111827'; 32 var COLOUR_BLOCK = '#111827'; 33 var COLOUR_LETTER = '#000000'; 34 var COLOUR_NUM = '#111827'; 35 // Fill for a word-search cell that is part of a found/answer-key word. 36 // Deliberately a light grey rather than the on-screen orange: the printed 37 // sheet is black-and-white, and a solid colour both costs ink and can drop 38 // out entirely on a greyscale printer. Light enough to keep the letter 39 // readable, dark enough to be unmistakable. 40 var COLOUR_FOUND = '#d4d4d8'; 41 42 function init() { 43 document.querySelectorAll('.xw-grid').forEach(function (g) { safe(buildCrosswordSvg, g); }); 44 document.querySelectorAll('.ws-grid').forEach(function (g) { safe(buildWordSearchSvg, g); }); 45 document.querySelectorAll('.sd-grid').forEach(function (g) { safe(buildSudokuSvg, g); }); 46 } 47 48 function safe(fn, grid) { 49 try { 50 var svg = fn(grid); 51 if (svg) insertPrintSvg(grid, svg); 52 } catch (e) { /* non-fatal â CSS fallback handles it */ } 53 } 54 55 function insertPrintSvg(grid, svgString) { 56 grid.insertAdjacentHTML('afterend', svgString); 57 grid.classList.add('has-print-svg'); 58 } 59 60 function escXml(s) { 61 return String(s).replace(/[&<>"']/g, function (c) { 62 return { '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]; 63 }); 64 } 65 66 // Read the real column count from computed grid-template-columns. 67 // Reliable regardless of whether --cols is set as a CSS var â the 68 // computed value is always a list of resolved track sizes. 69 function columnCount(grid) { 70 var tpl = getComputedStyle(grid).gridTemplateColumns; 71 if (!tpl || tpl === 'none') return 0; 72 return tpl.trim().split(/\s+/).filter(Boolean).length; 73 } 74 75 // ================================================================== 76 // Crossword 77 // ================================================================== 78 function buildCrosswordSvg(grid) { 79 var cells = grid.querySelectorAll('.xw-cell'); 80 if (!cells.length) return ''; 81 var cols = columnCount(grid) || Math.round(Math.sqrt(cells.length)); 82 var rows = Math.ceil(cells.length / cols); 83 84 var cell = 36; // cell width/height 85 var line = 1.5; // inner grid line thickness 86 var outer = 2.5; // outer border thickness 87 88 // Overall SVG dimensions: cells + lines between them + outer border 89 var w = cols * cell + (cols + 1) * line + 2 * (outer - line / 2); 90 var h = rows * cell + (rows + 1) * line + 2 * (outer - line / 2); 91 // Simpler recompute for alignment: 92 var step = cell + line; // distance between cell top-lefts 93 var W = outer * 2 + cols * cell + (cols - 1) * line; 94 var H = outer * 2 + rows * cell + (rows - 1) * line; 95 96 var parts = []; 97 parts.push('<svg xmlns="http://www.w3.org/2000/svg" class="print-grid-svg print-grid-svg-xw" viewB
97ox="0 0 ' + W + ' ' + H + '" width="' + W + '" height="' + H + '">'); 98 99 // Outer frame 100 parts.push('<rect x="0" y="0" width="' + W + '" height="' + H + '" fill="' + COLOUR_LINE + '"/>'); 101 // Inner white area (one block of white behind everything) 102 parts.push('<rect x="' + outer + '" y="' + outer + '" width="' + (W - outer * 2) + '" height="' + (H - outer * 2) + '" fill="#ffffff"/>'); 103 104 // Draw each cell in its correct position. 105 cells.forEach(function (el, i) { 106 var col = i % cols; 107 var row = Math.floor(i / cols); 108 var x = outer + col * (cell + line); 109 var y = outer + row * (cell + line); 110 111 var isBlock = el.classList.contains('xw-block'); 112 parts.push('<rect x="' + x + '" y="' + y + '" width="' + cell + '" height="' + cell + '" fill="' + (isBlock ? COLOUR_BLOCK : '#ffffff') + '"/>'); 113 114 if (!isBlock) { 115 // Cell number (small, top-left) 116 var numEl = el.querySelector('.xw-num'); 117 var num = numEl ? (numEl.textContent || '').trim() : ''; 118 if (num) { 119 parts.push('<text x="' + (x + 2.5) + '" y="' + (y + 8) + '" font-size="8" font-family="Arial, sans-serif" fill="' + COLOUR_NUM + '">' + escXml(num) + '</text>'); 120 } 121 // Letter for answers pages (large, centred) 122 var letter = ''; 123 var letterEl = el.querySelector('.xw-letter'); 124 var input = el.querySelector('.xw-input, input'); 125 if (letterEl && letterEl.textContent.trim()) letter = letterEl.textContent.trim(); 126 else if (input && input.value) letter = input.value; 127 if (letter) { 128 parts.push('<text x="' + (x + cell / 2) + '" y="' + (y + cell * 0.68) + '" font-size="20" font-weight="700" text-anchor="middle" font-family="Arial, sans-serif" fill="' + COLOUR_LETTER + '">' + escXml(letter.toUpperCase()) + '</text>'); 129 } 130 } 131 }); 132 133 // Draw grid lines explicitly over cells, using integer-pixel 134 // stroke at exact known positions. Same thickness everywhere. 135 // Vertical: 136 for (var c = 1; c < cols; c++) { 137 var vx = outer + c * cell + (c - 0.5) * line; 138 parts.push('<line x1="' + vx + '" y1="' + outer + '" x2="' + vx + '" y2="' + (H - outer) + '" stroke="' + COLOUR_LINE + '" stroke-width="' + line + '"/>'); 139 } 140 // Horizontal: 141 for (var r = 1; r < rows; r++) { 142 var hy = outer + r * cell + (r - 0.5) * line; 143 parts.push('<line x1="' + outer + '" y1="' + hy + '" x2="' + (W - outer) + '" y2="' + hy + '" stroke="' + COLOUR_LINE + '" stroke-width="' + line + '"/>'); 144 } 145 146 parts.push('</svg>'); 147 return parts.join(''); 148 } 149 150 // ================================================================== 151 // Word search 152 // ================================================================== 153 function buildWordSearchSvg(grid) { 154 var cells = grid.querySelectorAll('.ws-cell'); 155 if (!cells.length) return ''; 156 var cols = columnCount(grid) || Math.round(Math.sqrt(cells.length)); 157 var rows = Math.ceil(cells.length / cols); 158 159 var cell = 28, line = 1, outer = 2; 160 var W = outer * 2 + cols * cell + (cols - 1) * line; 161 var H = outer * 2 + rows * cell + (rows - 1) * line; 162 163 var parts = []; 164 parts.push('<svg xmlns="http://www.w3.org/2000/svg" class="print-grid-svg print-grid-svg-ws" viewBox="0 0 ' + W + ' ' + H + '" width="' + W + '" height="' + H + '">'); 165 parts.push('<rect x="0" y="0" width="' + W + '" height="' + H + '" fill="' + COLOUR_LINE + '"/>'); 166 parts.push('<rect x="' + outer + '" y="' + outer + '" width="' + (W - outer * 2) + '" height="' + (H - outer * 2) + '" fill="#ffffff"/>'); 167 168 cells.forEach(function (el, i) { 169 var col = i % cols; 170 var row = Math.floor(i / cols); 171 var x = outer + col * (cell + line); 172 var y = outer + row * (cell + line); 173 // Carry the solved state into print. The answers page marks every 174 // word's cells with .ws-cell-placed; a part-solved play page marks 175 // what the visitor has found with .ws-cell-found. Without this the 176 // printed answer key was an unmarked grid â the same sheet as the 177 // puzzle, with nothing to show for it. 178 var isFound = el.classList 179 && (el.classList.contains('ws-cell-placed') || el.classList.contains('ws-cell-found')); 180 parts.push('<rect x="' + x + '" y="' + y + '" width="' + cell + '" height="' + cell + '" fill="' + (isFound ? COLOUR_FOUND : '#ffffff') + '"/>'); 181 var letter = (el.textContent || '').trim(); 182 if (letter) { 183 parts.push('<text x="' + (x + cell / 2) + '" y="' + (y + cell * 0.68) + '" font-size="16" font-weight="' + (isFound ? '700' : '600') + '" text-anchor="middle" font-family="Arial, sans-serif" fill="' + COLOUR_LETTER + '">
183' + escXml(letter.toUpperCase()) + '</text>'); 184 } 185 }); 186 187 // Grid lines 188 for (var c = 1; c < cols; c++) { 189 var vx = outer + c * cell + (c - 0.5) * line; 190 parts.push('<line x1="' + vx + '" y1="' + outer + '" x2="' + vx + '" y2="' + (H - outer) + '" stroke="' + COLOUR_LINE + '" stroke-width="' + line + '"/>'); 191 } 192 for (var r = 1; r < rows; r++) { 193 var hy = outer + r * cell + (r - 0.5) * line; 194 parts.push('<line x1="' + outer + '" y1="' + hy + '" x2="' + (W - outer) + '" y2="' + hy + '" stroke="' + COLOUR_LINE + '" stroke-width="' + line + '"/>'); 195 } 196 197 parts.push('</svg>'); 198 return parts.join(''); 199 } 200 201 // ================================================================== 202 // Sudoku 203 // ================================================================== 204 function buildSudokuSvg(grid) { 205 var cells = grid.querySelectorAll('.sd-cell'); 206 if (cells.length !== 81) return ''; // only handle the standard 9Ã9 207 208 var cell = 54; 209 var thin = 0.75; 210 var thick = 2.5; 211 var outer = 3; 212 // Use uniform cell stride (cell + thin) â thick lines are drawn 213 // OVER the cell edges, so geometry stays simple. 214 var W = outer * 2 + 9 * cell + 8 * thin; 215 var H = W; 216 217 var parts = []; 218 parts.push('<svg xmlns="http://www.w3.org/2000/svg" class="print-grid-svg print-grid-svg-sd" viewBox="0 0 ' + W + ' ' + H + '" width="' + W + '" height="' + H + '">'); 219 parts.push('<rect x="0" y="0" width="' + W + '" height="' + H + '" fill="#ffffff"/>'); 220 221 var puzzle = grid.closest('.sd-puzzle'); 222 var showAll = !!(puzzle && puzzle.classList.contains('sd-puzzle-answers')); 223 224 cells.forEach(function (el, i) { 225 var col = i % 9; 226 var row = Math.floor(i / 9); 227 var x = outer + col * (cell + thin); 228 var y = outer + row * (cell + thin); 229 230 var given = el.classList.contains('sd-cell-given'); 231 232 // Three places a cell's digit might live: 233 // 1. For GIVEN cells, the digit is just plain text directly 234 // inside the .sd-cell div (no wrapping span). 235 // 2. For USER-entered values, a .sd-cell-value span holds it. 236 // 3. data-solution attribute is always present â the full 237 // solved grid. Use for answers page. 238 var val = ''; 239 if (given) { 240 // Read data-solution for reliability; fall back to text. 241 val = (el.getAttribute('data-solution') || '').trim() 242 || (el.childNodes[0] && el.childNodes[0].nodeType === 3 243 ? (el.childNodes[0].nodeValue || '').trim() 244 : (el.textContent || '').trim()); 245 } else { 246 var valEl = el.querySelector('.sd-cell-value'); 247 if (valEl && valEl.textContent.trim()) { 248 val = valEl.textContent.trim(); 249 } else if (showAll) { 250 val = (el.getAttribute('data-solution') || '').trim(); 251 } 252 } 253 254 if (val && (given || showAll)) { 255 var weight = given ? '700' : '500'; 256 parts.push('<text x="' + (x + cell / 2) + '" y="' + (y + cell * 0.68) + '" font-size="28" font-weight="' + weight + '" text-anchor="middle" font-family="Arial, sans-serif" fill="' + COLOUR_LETTER + '">' + escXml(val) + '</text>'); 257 } 258 }); 259 260 // Grid lines â thin for cell dividers, thick for 3Ã3 box dividers. 261 // Draw thin first, then thick over the top where they coincide. 262 for (var c = 1; c < 9; c++) { 263 var isBoxLine = (c % 3 === 0); 264 var vx = outer + c * cell + (c - 0.5) * thin; 265 parts.push('<line x1="' + vx + '" y1="' + outer + '" x2="' + vx + '" y2="' + (H - outer) + '" stroke="' + COLOUR_LINE + '" stroke-width="' + (isBoxLine ? thick : thin) + '"/>'); 266 } 267 for (var r = 1; r < 9; r++) { 268 var isBoxLine2 = (r % 3 === 0); 269 var hy = outer + r * cell + (r - 0.5) * thin; 270 parts.push('<line x1="' + outer + '" y1="' + hy + '" x2="' + (W - outer) + '" y2="' + hy + '" stroke="' + COLOUR_LINE + '" stroke-width="' + (isBoxLine2 ? thick : thin) + '"/>'); 271 } 272 // Outer border 273 parts.push('<rect x="' + (outer / 2) + '" y="' + (outer / 2) + '" width="' + (W - outer) + '" height="' + (H - outer) + '" fill="none" stroke="' + COLOUR_LINE + '" stroke-width="' + outer + '"/>'); 274 275 // Killer cages, drawn last so the dashes sit over the grid lines. 276 // A killer sudoku has NO given digits â the cages ARE the puzzle â so 277 // without this the printed sheet was an empty 9x9 and unsolvable. 278 var inset = 5;
279 cells.forEach(function (el, i) { 280 if (!el.classList) return; 281 var col = i % 9; 282 var row = Math.floor(i / 9); 283 var x = outer + col * (cell + thin); 284 var y = outer + row * (cell + thin); 285 var x0 = x + inset, y0 = y + inset; 286 var x1 = x + cell - inset, y1 = y + cell - inset; 287 var dash = ' stroke="' + COLOUR_LINE + '" stroke-width="1" stroke-dasharray="4 3"'; 288 289 if (el.classList.contains('sd-cage-t')) parts.push('<line x1="' + x0 + '" y1="' + y0 + '" x2="' + x1 + '" y2="' + y0 + '"' + dash + '/>'); 290 if (el.classList.contains('sd-cage-b')) parts.push('<line x1="' + x0 + '" y1="' + y1 + '" x2="' + x1 + '" y2="' + y1 + '"' + dash + '/>'); 291 if (el.classList.contains('sd-cage-l')) parts.push('<line x1="' + x0 + '" y1="' + y0 + '" x2="' + x0 + '" y2="' + y1 + '"' + dash + '/>'); 292 if (el.classList.contains('sd-cage-r')) parts.push('<line x1="' + x1 + '" y1="' + y0 + '" x2="' + x1 + '" y2="' + y1 + '"' + dash + '/>'); 293 294 var sumEl = el.querySelector ? el.querySelector('.sd-cage-sum') : null; 295 var sumTxt = sumEl ? (sumEl.textContent || '').trim() : ''; 296 if (sumTxt) { 297 // White pad behind the number so a cage dash never runs through it. 298 parts.push('<rect x="' + (x0 - 1) + '" y="' + (y0 - 1) + '" width="20" height="14" fill="#ffffff"/>'); 299 parts.push('<text x="' + (x0 + 1) + '" y="' + (y0 + 10) + '" font-size="13" font-weight="700" font-family="Arial, sans-serif" fill="' + COLOUR_LINE + '">' + escXml(sumTxt) + '</text>'); 300 } 301 }); 302 303 parts.push('</svg>'); 304 return parts.join(''); 305 } 306 307 // Kick off once DOM is parsed. 308 if (document.readyState === 'loading') { 309 document.addEventListener('DOMContentLoaded', init); 310 } else { 311 init(); 312 } 313})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.