PageSourceSearch

https://www.knowalot.org/js/print-grid-svg.js?v=202606082152

js knowalot.org collected 2026-10-08 20:49:26 UTC 16,338 bytes, 313 lines download raw bytes

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 { '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[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.