summaryrefslogtreecommitdiff
path: root/src/index.html
diff options
context:
space:
mode:
Diffstat (limited to 'src/index.html')
-rw-r--r--src/index.html742
1 files changed, 742 insertions, 0 deletions
diff --git a/src/index.html b/src/index.html
new file mode 100644
index 0000000..ef2f584
--- /dev/null
+++ b/src/index.html
@@ -0,0 +1,742 @@
1<!DOCTYPE html>
2<html lang="en">
3
4<head>
5 <meta charset="UTF-8">
6 <meta name="viewport" content="width=device-width, initial-scale=1.0">
7 <title>sent-web</title>
8 <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/kj-sh604/noir.css@latest/out/noir.min.css">
9 <style> :root { --sent-fg: #000000; --sent-bg: #ffffff; --sent-font: 'Noto Color Emoji', 'DejaVu Sans', sans-serif; } body { max-width: 960px; margin: 0 auto; padding: 1rem; } .subtitle { opacity: 0.6; font-size: 0.9em; margin-top: -0.8em; } /* ── controls ── */ #controls { display: flex; flex-wrap: wrap; gap: 1rem; align-items: center; margin-bottom: 1rem; padding: 0.75rem; border: 1px solid currentColor; border-radius: 4px; } #controls label { display: flex; align-items: center; gap: 0.4rem; font-size: 0.9rem; } #controls input[type="color"] { width: 2rem; height: 2rem; padding: 0; border: 1px solid currentColor; cursor: pointer; background: none; } #controls select { max-width: 200px; } .upload-area { display: flex; align-items: center; gap: 0.5rem; } #upload-input { display: none; } #upload-status { font-size: 0.85rem; opacity: 0.7; } /* ── editor ── */ #input { width: 100%; min-height: 420px; font-family: monospace; font-size: 0.95rem; resize: vertical; tab-size: 4; } .btn-row { display: flex; gap: 0.5rem; margin-top: 0.5rem; } .hint { font-size: 0.8rem; opacity: 0.5; margin-top: 0.25rem; } /* ── presentation overlay ── */ #presentation { position: fixed; inset: 0; z-index: 9999; display: none; align-items: center; justify-content: flex-start; background: var(--sent-bg); color: var(--sent-fg); cursor: none; overflow: hidden; padding-left: 7.5%; } #presentation.active { display: flex; } #slide-content { text-align: left; white-space: pre-line; word-wrap: break-word; font-family: var(--sent-font); } #slide-content img { display: block; max-width: 85vw; max-height: 85vh; object-fit: contain; } </style>
10 <script>
11 /*
12 @licstart The following is the entire license notice for the
13 JavaScript code in this page.
14
15 Copyright (c) 2026 kj_sh604/kj-sh604/kjsh604
16
17 SPDX-License-Identifier: MIT
18
19 Permission is hereby granted, free of charge, to any person obtaining a copy
20 of this software and associated documentation files (the "Software"), to deal
21 in the Software without restriction, including without limitation the rights
22 to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
23 copies of the Software, and to permit persons to whom the Software is
24 furnished to do so, subject to the following conditions:
25
26 The above copyright notice and this permission notice shall be included in all
27 copies or substantial portions of the Software.
28
29 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
30 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
31 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
32 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
33 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
34 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
35 SOFTWARE.
36
37 @licend The above is the entire license notice
38 for the JavaScript code in this page.
39 */
40 </script>
41</head>
42
43<body>
44
45 <header>
46 <h1>sent-web</h1>
47 <p class="subtitle">suckless's sent tool ported to the very sucky web world</p>
48 </header>
49
50 <main>
51 <section id="controls">
52 <label>
53 fg
54 <input type="color" id="fg-color" value="#000000">
55 </label>
56 <label>
57 bg
58 <input type="color" id="bg-color" value="#ffffff">
59 </label>
60 <label>
61 font
62 <select id="font-select">
63 <option value="">loading…</option>
64 </select>
65 </label>
66 <div class="upload-area">
67 <button type="button" onclick="document.getElementById('upload-input').click()">upload image</button>
68 <input type="file" id="upload-input" accept="image/*">
69 <span id="upload-status"></span>
70 </div>
71 </section>
72
73 <textarea id="input">
74sent-web
75
76a port of suckless sent
77for the very sucky web 🌎
78
79# this is a comment and will not appear
80# the next slide is blank slide since we escaped with \
81
82\
83
84@nyan_819cac51.png
85
86why?
87• PPTX sucks
88• LATEX sucks
89• PDF sucks
90• but everything is in
91 the web now 😓
92
93easy to use
94
95▸ one slide per paragraph
96▸ lines starting with # are ignored
97▸ image slide: @filename
98▸ empty slide: just use \
99
100navigate with:
101← → ↑ ↓ h j k l
102space, enter, backspace
103or mouse clicks
104
105press escape to exit
106
107😀😁😂😃😄😅😆😇😈😉😊
108emoji just works™
109
110thanks.
111questions?</textarea>
112
113 <div class="btn-row">
114 <button type="button" id="btn-present" onclick="App.startPresentation()">present</button>
115 <button type="button" onclick="App.downloadSent()">download for local sent usage</button>
116 <button type="button" onclick="App.exportPDF()">export .pdf</button>
117 </div>
118 <p class="hint">F5 to present · Esc to exit · ← → h l j k space enter to navigate</p>
119 </main>
120
121 <!-- presentation overlay -->
122 <div id="presentation">
123 <div id="slide-content"></div>
124 </div>
125
126 <script>
127 // sent-web — vanilla JS presentation engine
128
129 const App = {
130 slides: [],
131 idx: 0,
132 presenting: false,
133 loadedFonts: new Set(),
134
135 settings: {
136 fg: '#000000',
137 bg: '#ffffff',
138 fontFamily: '',
139 lineSpacing: 1.25,
140 usableWidth: 0.85,
141 usableHeight: 0.85,
142 },
143
144 init() {
145 this.restoreState();
146 this.loadFonts();
147 this.bindEvents();
148 this.updateColors();
149 },
150
151 restoreState() {
152 const saved = localStorage.getItem('sent-web-content');
153 if (saved !== null) {
154 document.getElementById('input').value = saved;
155 }
156
157 const raw = localStorage.getItem('sent-web-settings');
158 if (raw) {
159 try {
160 const s = JSON.parse(raw);
161 if (s.fg) {
162 this.settings.fg = s.fg;
163 document.getElementById('fg-color').value = s.fg;
164 }
165 if (s.bg) {
166 this.settings.bg = s.bg;
167 document.getElementById('bg-color').value = s.bg;
168 }
169 if (s.fontFamily) this.settings.fontFamily = s.fontFamily;
170 } catch (_) {}
171 }
172 },
173
174 saveSettings() {
175 localStorage.setItem('sent-web-settings', JSON.stringify({
176 fg: this.settings.fg,
177 bg: this.settings.bg,
178 fontFamily: this.settings.fontFamily,
179 }));
180 },
181
182 async loadFonts() {
183 try {
184 const res = await fetch('/fonts');
185 const data = await res.json();
186 const sel = document.getElementById('font-select');
187 sel.innerHTML = '';
188
189 // preload fallback fonts
190 const notoEmoji = data.find(f => f.family === 'Noto Color Emoji');
191 const dejavu = data.find(f => f.family.startsWith('DejaVu Sans') && !f.family.includes('Mono'));
192
193 if (notoEmoji) await this.loadFont(notoEmoji);
194 if (dejavu) await this.loadFont(dejavu);
195
196 // populate dropdown
197 let selectedOpt = null;
198 data.forEach(f => {
199 const opt = document.createElement('option');
200 opt.value = JSON.stringify(f);
201 opt.textContent = f.family;
202 sel.appendChild(opt);
203
204 // restore saved selection or default to DejaVu Sans
205 if (this.settings.fontFamily && f.family === this.settings.fontFamily) {
206 selectedOpt = opt;
207 } else if (!this.settings.fontFamily && dejavu && f.family === dejavu.family) {
208 selectedOpt = opt;
209 }
210 });
211
212 if (selectedOpt) selectedOpt.selected = true;
213 this.onFontChange();
214 } catch (e) {
215 console.error('Failed to load fonts:', e);
216 }
217 },
218
219 async loadFont(fontData) {
220 if (this.loadedFonts.has(fontData.family)) return;
221
222 try {
223 const url = `/font?f=${encodeURIComponent(fontData.file)}`;
224 const src = `local('${fontData.family}'), url(${url}) format('${fontData.format}')`;
225 const face = new FontFace(fontData.family, src, {
226 display: 'swap'
227 });
228 const loaded = await face.load();
229 document.fonts.add(loaded);
230 this.loadedFonts.add(fontData.family);
231 } catch (e) {
232 console.warn(`Could not load font "${fontData.family}":`, e);
233 }
234 },
235
236 async onFontChange() {
237 const sel = document.getElementById('font-select');
238 if (!sel.value) return;
239
240 const fontData = JSON.parse(sel.value);
241 this.settings.fontFamily = fontData.family;
242 await this.loadFont(fontData);
243
244 const stack = `'${fontData.family}', 'Noto Color Emoji', 'DejaVu Sans', sans-serif`;
245 document.documentElement.style.setProperty('--sent-font', stack);
246
247 this.saveSettings();
248 if (this.presenting) this.renderSlide();
249 },
250
251 bindEvents() {
252 document.getElementById('fg-color').addEventListener('input', () => this.updateColors());
253 document.getElementById('bg-color').addEventListener('input', () => this.updateColors());
254 document.getElementById('font-select').addEventListener('change', () => this.onFontChange());
255 document.getElementById('upload-input').addEventListener('change', e => this.handleUpload(e));
256
257 document.getElementById('input').addEventListener('input', () => {
258 localStorage.setItem('sent-web-content', document.getElementById('input').value);
259 });
260
261 document.addEventListener('keydown', e => this.handleKeydown(e));
262
263 const pres = document.getElementById('presentation');
264 pres.addEventListener('click', e => {
265 if (e.clientX < window.innerWidth / 2) this.navigate(-1);
266 else this.navigate(1);
267 });
268 pres.addEventListener('wheel', e => {
269 e.preventDefault();
270 this.navigate(e.deltaY > 0 ? 1 : -1);
271 }, {
272 passive: false
273 });
274
275 window.addEventListener('resize', () => {
276 if (this.presenting) this.renderSlide();
277 });
278
279 // stop presentation whenever fullscreen is exited (covers browser-
280 // intercepted Escape that never reaches the keydown handler).
281 document.addEventListener('fullscreenchange', () => {
282 if (!document.fullscreenElement && this.presenting) {
283 this.stopPresentation();
284 } else if (document.fullscreenElement && this.presenting) {
285 this.renderSlide();
286 }
287 });
288 },
289
290 updateColors() {
291 this.settings.fg = document.getElementById('fg-color').value;
292 this.settings.bg = document.getElementById('bg-color').value;
293 document.documentElement.style.setProperty('--sent-fg', this.settings.fg);
294 document.documentElement.style.setProperty('--sent-bg', this.settings.bg);
295 this.saveSettings();
296 if (this.presenting) this.renderSlide();
297 },
298
299 // sent format parser
300 parseSent(text) {
301 const slides = [];
302 const paragraphs = text.split(/\n{2,}/);
303
304 for (const para of paragraphs) {
305 const rawLines = para.split('\n');
306 const lines = [];
307 let img = null;
308 let firstContent = true;
309
310 for (const raw of rawLines) {
311 if (raw.trim() === '' || raw.startsWith('#')) continue;
312
313 let line = raw;
314
315 if (firstContent && line.startsWith('@')) {
316 img = line.substring(1).trim();
317 firstContent = false;
318 continue;
319 }
320 firstContent = false;
321
322 // image slides ignore remaining text lines
323 if (img !== null) continue;
324
325 // strip leading backslash (escape)
326 if (line.startsWith('\\')) {
327 line = line.substring(1);
328 }
329
330 lines.push(line);
331 }
332
333 if (img !== null || lines.length > 0) {
334 slides.push({
335 lines,
336 img
337 });
338 }
339 }
340
341 return slides;
342 },
343
344 // presentation controls
345 startPresentation() {
346 const text = document.getElementById('input').value;
347 this.slides = this.parseSent(text);
348
349 if (this.slides.length === 0) {
350 alert('No slides to present.');
351 return;
352 }
353
354 this.idx = 0;
355 this.presenting = true;
356 document.getElementById('presentation').classList.add('active');
357 document.body.style.overflow = 'hidden';
358
359 const el = document.getElementById('presentation');
360 if (el.requestFullscreen) el.requestFullscreen().catch(() => {});
361
362 this.renderSlide();
363 },
364
365 stopPresentation() {
366 this.presenting = false;
367 document.getElementById('presentation').classList.remove('active');
368 document.body.style.overflow = '';
369
370 if (document.fullscreenElement) {
371 document.exitFullscreen().catch(() => {});
372 }
373 },
374
375 navigate(dir) {
376 if (!this.presenting) return;
377 const next = this.idx + dir;
378 if (next >= 0 && next < this.slides.length) {
379 this.idx = next;
380 this.renderSlide();
381 }
382 },
383
384 // rendering engine
385 renderSlide() {
386 if (!this.presenting || this.slides.length === 0) return;
387
388 const slide = this.slides[this.idx];
389 const content = document.getElementById('slide-content');
390 const pres = document.getElementById('presentation');
391
392 pres.style.backgroundColor = this.settings.bg;
393 pres.style.color = this.settings.fg;
394
395 content.innerHTML = '';
396
397 if (slide.img) {
398 // center image slides — override the text-layout defaults
399 pres.style.justifyContent = 'center';
400 pres.style.alignItems = 'center';
401 pres.style.paddingLeft = '0';
402 const img = document.createElement('img');
403 if (slide.img.startsWith('http://') || slide.img.startsWith('https://')) {
404 img.src = slide.img;
405 } else {
406 img.src = 'uploads/' + slide.img;
407 }
408 img.alt = slide.img;
409 // changed to reflect the fullscreen viewport.
410 const pw = pres.offsetWidth || window.innerWidth;
411 const ph = pres.offsetHeight || window.innerHeight;
412 const maxW = Math.floor(pw * this.settings.usableWidth);
413 const maxH = Math.floor(ph * this.settings.usableHeight);
414 img.style.width = maxW + 'px';
415 img.style.height = maxH + 'px';
416 img.style.maxWidth = maxW + 'px';
417 img.style.maxHeight = maxH + 'px';
418 img.style.objectFit = 'contain';
419 img.style.display = 'block';
420 content.appendChild(img);
421 } else {
422 // restore text-layout defaults
423 pres.style.justifyContent = 'flex-start';
424 pres.style.alignItems = 'center';
425 pres.style.paddingLeft = '7.5%';
426 const fontSize = this.calcFontSize(slide.lines);
427 content.style.fontSize = fontSize + 'px';
428 content.style.lineHeight = String(this.settings.lineSpacing);
429
430 slide.lines.forEach((line, i) => {
431 if (i > 0) content.appendChild(document.createElement('br'));
432 content.appendChild(document.createTextNode(line));
433 });
434 }
435
436 },
437
438 calcFontSize(lines) {
439 const maxW = window.innerWidth * this.settings.usableWidth;
440 const maxH = window.innerHeight * this.settings.usableHeight;
441 const font = getComputedStyle(document.documentElement)
442 .getPropertyValue('--sent-font').trim() || 'sans-serif';
443
444 const canvas = document.createElement('canvas');
445 const ctx = canvas.getContext('2d');
446
447 let lo = 1,
448 hi = 500,
449 best = 1;
450
451 while (lo <= hi) {
452 const mid = Math.floor((lo + hi) / 2);
453 ctx.font = `${mid}px ${font}`;
454
455 let fits = true;
456 for (const line of lines) {
457 if (ctx.measureText(line).width > maxW) {
458 fits = false;
459 break;
460 }
461 }
462
463 // height check: line-spacing × (n-1) + 1 base height
464 const totalH = mid * this.settings.lineSpacing * (lines.length - 1) + mid;
465 if (totalH > maxH) fits = false;
466
467 if (fits) {
468 best = mid;
469 lo = mid + 1;
470 } else {
471 hi = mid - 1;
472 }
473 }
474
475 return best;
476 },
477
478 // keyboard handler
479
480 handleKeydown(e) {
481 // F5 to start presentation from editor
482 if (!this.presenting && e.key === 'F5') {
483 e.preventDefault();
484 this.startPresentation();
485 return;
486 }
487
488 if (!this.presenting) return;
489
490 switch (e.key) {
491 case 'Escape':
492 case 'q':
493 e.preventDefault();
494 this.stopPresentation();
495 break;
496 case 'ArrowRight':
497 case 'ArrowDown':
498 case ' ':
499 case 'Enter':
500 case 'l':
501 case 'j':
502 case 'n':
503 case 'PageDown':
504 e.preventDefault();
505 this.navigate(1);
506 break;
507 case 'ArrowLeft':
508 case 'ArrowUp':
509 case 'Backspace':
510 case 'h':
511 case 'k':
512 case 'p':
513 case 'PageUp':
514 e.preventDefault();
515 this.navigate(-1);
516 break;
517 }
518 },
519
520 // image upload
521
522 async handleUpload(e) {
523 const file = e.target.files[0];
524 if (!file) return;
525
526 const status = document.getElementById('upload-status');
527 status.textContent = 'uploading…';
528
529 const fd = new FormData();
530 fd.append('image', file);
531
532 try {
533 const res = await fetch('/upload', {
534 method: 'POST',
535 body: fd
536 });
537 const data = await res.json();
538
539 if (data.error) {
540 status.textContent = 'error: ' + data.error;
541 return;
542 }
543
544 // insert @filename at cursor position
545 const ta = document.getElementById('input');
546 const pos = ta.selectionStart;
547 const txt = ta.value;
548 const ins = `\n@${data.filename}\n`;
549 ta.value = txt.substring(0, pos) + ins + txt.substring(pos);
550 ta.selectionStart = ta.selectionEnd = pos + ins.length;
551 ta.focus();
552
553 localStorage.setItem('sent-web-content', ta.value);
554 status.textContent = `uploaded: ${data.filename}`;
555 setTimeout(() => {
556 status.textContent = '';
557 }, 3000);
558 } catch (err) {
559 status.textContent = 'upload failed';
560 console.error(err);
561 }
562
563 e.target.value = '';
564 },
565
566 // download .sent file for local usage (base64-encoded to preserve unicode and avoid filename issues)
567 downloadSent() {
568 const text = document.getElementById('input').value;
569 const blob = new Blob([text], {
570 type: 'text/plain'
571 });
572 const a = document.createElement('a');
573 a.href = URL.createObjectURL(blob);
574 a.download = 'presentation.sent';
575 a.click();
576 URL.revokeObjectURL(a.href);
577 },
578
579 // download pdf export of the presentation from canvas
580 async exportPDF() {
581 const text = document.getElementById('input').value;
582 const slides = this.parseSent(text);
583
584 if (slides.length === 0) {
585 alert('No slides to export.');
586 return;
587 }
588
589 const btn = document.querySelector('button[onclick="App.exportPDF()"]');
590 if (btn) {
591 btn.textContent = 'generating pdf…';
592 btn.disabled = true;
593 }
594
595 try {
596 if (!window.jspdf) {
597 await new Promise((resolve, reject) => {
598 const s = document.createElement('script');
599 s.src = 'https://cdn.jsdelivr.net/npm/jspdf@2.5.2/dist/jspdf.umd.min.js';
600 s.onload = resolve;
601 s.onerror = () => reject(new Error('Failed to load jsPDF'));
602 document.head.appendChild(s);
603 });
604 }
605
606 // 1440p canvas per slide — rasterise via browser engine
607 // (handles fonts, unicode, emoji, images exactly as the live view does)
608 const W = 2560;
609 const H = 1440;
610
611 const {
612 jsPDF
613 } = window.jspdf;
614 // px unit + hotfix keeps jsPDF from rescaling our pixel-perfect canvases
615 const pdf = new jsPDF({
616 orientation: 'landscape',
617 unit: 'px',
618 format: [W, H],
619 hotfixes: ['px_scaling'],
620 });
621
622 for (let i = 0; i < slides.length; i++) {
623 const canvas = await this.renderSlideToCanvas(slides[i], W, H);
624 const imgData = canvas.toDataURL('image/jpeg', 0.93);
625
626 if (i > 0) pdf.addPage([W, H], 'landscape');
627 pdf.addImage(imgData, 'JPEG', 0, 0, W, H);
628 }
629
630 const epoch = Math.floor(Date.now() / 1000);
631 const uid = crypto.randomUUID().replace(/-/g, '').slice(0, 8);
632 pdf.save(`sent-web${epoch}-${uid}.pdf`);
633
634 } catch (err) {
635 console.error('PDF export failed:', err);
636 alert('PDF export failed: ' + err.message);
637 } finally {
638 if (btn) {
639 btn.textContent = 'export .pdf';
640 btn.disabled = false;
641 }
642 }
643 },
644
645 // render one slide to an off-screen canvas, mirrors renderSlide() exactly
646 async renderSlideToCanvas(slide, W, H) {
647 const canvas = document.createElement('canvas');
648 canvas.width = W;
649 canvas.height = H;
650 const ctx = canvas.getContext('2d');
651
652 const usable = this.settings.usableWidth; // 0.85
653 const maxW = W * usable;
654 const maxH = H * usable;
655 const marginX = (W - maxW) / 2;
656
657 // resolve the same font stack the live view uses
658 const fontStack = getComputedStyle(document.documentElement)
659 .getPropertyValue('--sent-font').trim() || 'sans-serif';
660
661 // background
662 ctx.fillStyle = this.settings.bg;
663 ctx.fillRect(0, 0, W, H);
664
665 if (slide.img) {
666 // image slide — draw the actual image
667 await new Promise((resolve) => {
668 const img = new Image();
669 img.crossOrigin = 'anonymous';
670 img.onload = () => {
671 const ratio = img.naturalWidth / img.naturalHeight;
672 let dw = maxW,
673 dh = maxH;
674 if (dw / dh > ratio) dw = dh * ratio;
675 else dh = dw / ratio;
676 ctx.drawImage(img, (W - dw) / 2, (H - dh) / 2, dw, dh);
677 resolve();
678 };
679 img.onerror = resolve; // still produce a page even on failure
680 img.src = (slide.img.startsWith('http://') || slide.img.startsWith('https://')) ?
681 slide.img :
682 'uploads/' + slide.img;
683 });
684 } else {
685 // text slide — left-aligned, same sizing logic as live view
686 const fontSize = this.calcFontSizeCanvas(ctx, slide.lines, fontStack, maxW, maxH);
687
688 ctx.font = `${fontSize}px ${fontStack}`;
689 ctx.fillStyle = this.settings.fg;
690 ctx.textBaseline = 'middle';
691
692 const lineH = fontSize * this.settings.lineSpacing;
693 const totalH = slide.lines.length * lineH;
694 const startX = marginX;
695 const startY = (H - totalH) / 2;
696
697 slide.lines.forEach((line, i) => {
698 ctx.fillText(line, startX, startY + (i + 0.5) * lineH);
699 });
700 }
701
702 return canvas;
703 },
704
705 // binary-search font size on a canvas context for given absolute dimensions
706 calcFontSizeCanvas(ctx, lines, fontStack, maxW, maxH) {
707 let lo = 1,
708 hi = 600,
709 best = 1;
710
711 while (lo <= hi) {
712 const mid = Math.floor((lo + hi) / 2);
713 ctx.font = `${mid}px ${fontStack}`;
714
715 let fits = true;
716 for (const line of lines) {
717 if (ctx.measureText(line).width > maxW) {
718 fits = false;
719 break;
720 }
721 }
722
723 const totalH = mid * this.settings.lineSpacing * (lines.length - 1) + mid;
724 if (totalH > maxH) fits = false;
725
726 if (fits) {
727 best = mid;
728 lo = mid + 1;
729 } else {
730 hi = mid - 1;
731 }
732 }
733
734 return best;
735 },
736 };
737
738 document.addEventListener('DOMContentLoaded', () => App.init());
739 </script>
740</body>
741
742</html> \ No newline at end of file