summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorkj_sh6042026-03-01 19:07:42 -0500
committerkj_sh6042026-03-01 19:07:42 -0500
commit41fa7fe9ed84c4b8989f622fb532722b7f39ad72 (patch)
treec0cff2582ae6bfa4f06a699dc13e4210d76c318f /src
parenta181069363b19274f65e36e69b172e7063647c1e (diff)
refactor: src/
Diffstat (limited to 'src')
-rw-r--r--src/font.php44
-rw-r--r--src/fonts.php49
-rw-r--r--src/index.php693
-rw-r--r--src/upload.php66
-rw-r--r--src/uploads/.htaccess2
5 files changed, 854 insertions, 0 deletions
diff --git a/src/font.php b/src/font.php
new file mode 100644
index 0000000..de39569
--- /dev/null
+++ b/src/font.php
@@ -0,0 +1,44 @@
1<?php
2/* font.php — serve font files from the server's font directories */
3
4$encoded = $_GET['f'] ?? '';
5if (empty($encoded)) {
6 http_response_code(400);
7 exit('Missing parameter');
8}
9
10$file = base64_decode($encoded, true);
11if ($file === false || !file_exists($file)) {
12 http_response_code(404);
13 exit('Font not found');
14}
15
16$real = realpath($file);
17$allowed = ['/usr/share/fonts', '/usr/local/share/fonts'];
18$ok = false;
19
20foreach ($allowed as $dir) {
21 if (str_starts_with($real, $dir)) {
22 $ok = true;
23 break;
24 }
25}
26
27if (!$ok) {
28 http_response_code(403);
29 exit('Access denied');
30}
31
32$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
33$mime = match ($ext) {
34 'ttf' => 'font/ttf',
35 'otf' => 'font/otf',
36 'woff' => 'font/woff',
37 'woff2' => 'font/woff2',
38 default => 'application/octet-stream',
39};
40
41header("Content-Type: $mime");
42header('Cache-Control: public, max-age=31536000, immutable');
43header('Content-Length: ' . filesize($file));
44readfile($file);
diff --git a/src/fonts.php b/src/fonts.php
new file mode 100644
index 0000000..1959f51
--- /dev/null
+++ b/src/fonts.php
@@ -0,0 +1,49 @@
1<?php
2// fonts.php — LIST server-side fonts via fontconfig
3
4header('Content-Type: application/json');
5header('Cache-Control: public, max-age=3600');
6
7$output = shell_exec('fc-list --format="%{family}|%{file}\n" 2>/dev/null');
8if (!$output) {
9 echo json_encode([]);
10 exit;
11}
12
13$lines = array_filter(explode("\n", trim($output)));
14$fonts = [];
15$seen = [];
16
17foreach ($lines as $line) {
18 $parts = explode('|', $line, 2);
19 if (count($parts) < 2) continue;
20
21 /* take first family name (some entries are comma-separated) */
22 $families = explode(',', $parts[0]);
23 $family = trim($families[0]);
24
25 if (empty($family) || isset($seen[$family])) continue;
26
27 $file = trim($parts[1]);
28 if (!file_exists($file)) continue;
29
30 $seen[$family] = true;
31
32 $ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
33 $format = match ($ext) {
34 'ttf' => 'truetype',
35 'otf' => 'opentype',
36 'woff' => 'woff',
37 'woff2' => 'woff2',
38 default => 'truetype',
39 };
40
41 $fonts[] = [
42 'family' => $family,
43 'file' => base64_encode($file),
44 'format' => $format,
45 ];
46}
47
48usort($fonts, fn($a, $b) => strcasecmp($a['family'], $b['family']));
49echo json_encode($fonts);
diff --git a/src/index.php b/src/index.php
new file mode 100644
index 0000000..062d80e
--- /dev/null
+++ b/src/index.php
@@ -0,0 +1,693 @@
1<?php /* sent-web — index.php */ ?>
2<!DOCTYPE html>
3<html lang="en">
4
5<head>
6 <meta charset="UTF-8">
7 <meta name="viewport" content="width=device-width, initial-scale=1.0">
8 <title>sent-web</title>
9 <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/kj-sh604/noir.css@latest/out/noir.min.css">
10 <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 { max-width: 85vw; max-height: 85vh; width: 85vw; height: 85vh; object-fit: contain; } </style>
11</head>
12
13<body>
14
15 <header>
16 <h1>sent-web</h1>
17 <p class="subtitle">suckless's sent tool ported to the very sucky web world</p>
18 </header>
19
20 <main>
21 <section id="controls">
22 <label>
23 fg
24 <input type="color" id="fg-color" value="#000000">
25 </label>
26 <label>
27 bg
28 <input type="color" id="bg-color" value="#ffffff">
29 </label>
30 <label>
31 font
32 <select id="font-select">
33 <option value="">loading…</option>
34 </select>
35 </label>
36 <div class="upload-area">
37 <button type="button" onclick="document.getElementById('upload-input').click()">upload image</button>
38 <input type="file" id="upload-input" accept="image/*">
39 <span id="upload-status"></span>
40 </div>
41 </section>
42
43 <textarea id="input">
44sent-web
45
46a port of suckless sent
47for the very sucky web 🌎
48
49# this is a comment and will not appear
50# the next slide is blank slide since we escaped with \
51
52\
53
54why?
55• PPTX sucks
56• LATEX sucks
57• PDF sucks
58• but everything is in
59 the web now 😓
60
61easy to use
62
63▸ one slide per paragraph
64▸ lines starting with # are ignored
65▸ image slide: @filename
66▸ empty slide: just use \
67
68navigate with:
69← → ↑ ↓ h j k l
70space, enter, backspace
71or mouse clicks
72
73press escape to exit
74
75😀😁😂😃😄😅😆😇😈😉😊
76emoji just works™
77
78thanks.
79questions?</textarea>
80
81 <div class="btn-row">
82 <button type="button" id="btn-present" onclick="App.startPresentation()">present</button>
83 <button type="button" onclick="App.downloadSent()">download for local sent usage</button>
84 <button type="button" onclick="App.exportPDF()">export .pdf</button>
85 </div>
86 <p class="hint">F5 to present · Esc to exit · ← → h l j k space enter to navigate</p>
87 </main>
88
89 <!-- presentation overlay -->
90 <div id="presentation">
91 <div id="slide-content"></div>
92 </div>
93
94 <script>
95 // sent-web — vanilla JS presentation engine
96
97 const App = {
98 slides: [],
99 idx: 0,
100 presenting: false,
101 loadedFonts: new Set(),
102
103 settings: {
104 fg: '#000000',
105 bg: '#ffffff',
106 fontFamily: '',
107 lineSpacing: 1.4,
108 usableWidth: 0.85,
109 usableHeight: 0.85,
110 },
111
112 init() {
113 this.restoreState();
114 this.loadFonts();
115 this.bindEvents();
116 this.updateColors();
117 },
118
119 restoreState() {
120 const saved = localStorage.getItem('sent-web-content');
121 if (saved !== null) {
122 document.getElementById('input').value = saved;
123 }
124
125 const raw = localStorage.getItem('sent-web-settings');
126 if (raw) {
127 try {
128 const s = JSON.parse(raw);
129 if (s.fg) {
130 this.settings.fg = s.fg;
131 document.getElementById('fg-color').value = s.fg;
132 }
133 if (s.bg) {
134 this.settings.bg = s.bg;
135 document.getElementById('bg-color').value = s.bg;
136 }
137 if (s.fontFamily) this.settings.fontFamily = s.fontFamily;
138 } catch (_) {}
139 }
140 },
141
142 saveSettings() {
143 localStorage.setItem('sent-web-settings', JSON.stringify({
144 fg: this.settings.fg,
145 bg: this.settings.bg,
146 fontFamily: this.settings.fontFamily,
147 }));
148 },
149
150 async loadFonts() {
151 try {
152 const res = await fetch('fonts.php');
153 const data = await res.json();
154 const sel = document.getElementById('font-select');
155 sel.innerHTML = '';
156
157 // preload fallback fonts
158 const notoEmoji = data.find(f => f.family === 'Noto Color Emoji');
159 const dejavu = data.find(f => f.family.startsWith('DejaVu Sans') && !f.family.includes('Mono'));
160
161 if (notoEmoji) await this.loadFont(notoEmoji);
162 if (dejavu) await this.loadFont(dejavu);
163
164 // populate dropdown
165 let selectedOpt = null;
166 data.forEach(f => {
167 const opt = document.createElement('option');
168 opt.value = JSON.stringify(f);
169 opt.textContent = f.family;
170 sel.appendChild(opt);
171
172 // restore saved selection or default to DejaVu Sans
173 if (this.settings.fontFamily && f.family === this.settings.fontFamily) {
174 selectedOpt = opt;
175 } else if (!this.settings.fontFamily && dejavu && f.family === dejavu.family) {
176 selectedOpt = opt;
177 }
178 });
179
180 if (selectedOpt) selectedOpt.selected = true;
181 this.onFontChange();
182 } catch (e) {
183 console.error('Failed to load fonts:', e);
184 }
185 },
186
187 async loadFont(fontData) {
188 if (this.loadedFonts.has(fontData.family)) return;
189
190 try {
191 const url = `font.php?f=${encodeURIComponent(fontData.file)}`;
192 const src = `local('${fontData.family}'), url(${url}) format('${fontData.format}')`;
193 const face = new FontFace(fontData.family, src, {
194 display: 'swap'
195 });
196 const loaded = await face.load();
197 document.fonts.add(loaded);
198 this.loadedFonts.add(fontData.family);
199 } catch (e) {
200 console.warn(`Could not load font "${fontData.family}":`, e);
201 }
202 },
203
204 async onFontChange() {
205 const sel = document.getElementById('font-select');
206 if (!sel.value) return;
207
208 const fontData = JSON.parse(sel.value);
209 this.settings.fontFamily = fontData.family;
210 await this.loadFont(fontData);
211
212 const stack = `'${fontData.family}', 'Noto Color Emoji', 'DejaVu Sans', sans-serif`;
213 document.documentElement.style.setProperty('--sent-font', stack);
214
215 this.saveSettings();
216 if (this.presenting) this.renderSlide();
217 },
218
219 bindEvents() {
220 document.getElementById('fg-color').addEventListener('input', () => this.updateColors());
221 document.getElementById('bg-color').addEventListener('input', () => this.updateColors());
222 document.getElementById('font-select').addEventListener('change', () => this.onFontChange());
223 document.getElementById('upload-input').addEventListener('change', e => this.handleUpload(e));
224
225 document.getElementById('input').addEventListener('input', () => {
226 localStorage.setItem('sent-web-content', document.getElementById('input').value);
227 });
228
229 document.addEventListener('keydown', e => this.handleKeydown(e));
230
231 const pres = document.getElementById('presentation');
232 pres.addEventListener('click', e => {
233 if (e.clientX < window.innerWidth / 2) this.navigate(-1);
234 else this.navigate(1);
235 });
236 pres.addEventListener('wheel', e => {
237 e.preventDefault();
238 this.navigate(e.deltaY > 0 ? 1 : -1);
239 }, {
240 passive: false
241 });
242
243 window.addEventListener('resize', () => {
244 if (this.presenting) this.renderSlide();
245 });
246
247 // stop presentation whenever fullscreen is exited (covers browser-
248 // intercepted Escape that never reaches the keydown handler)
249 document.addEventListener('fullscreenchange', () => {
250 if (!document.fullscreenElement && this.presenting) {
251 this.stopPresentation();
252 }
253 });
254 },
255
256 updateColors() {
257 this.settings.fg = document.getElementById('fg-color').value;
258 this.settings.bg = document.getElementById('bg-color').value;
259 document.documentElement.style.setProperty('--sent-fg', this.settings.fg);
260 document.documentElement.style.setProperty('--sent-bg', this.settings.bg);
261 this.saveSettings();
262 if (this.presenting) this.renderSlide();
263 },
264
265 // sent format parser
266 parseSent(text) {
267 const slides = [];
268 const paragraphs = text.split(/\n{2,}/);
269
270 for (const para of paragraphs) {
271 const rawLines = para.split('\n');
272 const lines = [];
273 let img = null;
274 let firstContent = true;
275
276 for (const raw of rawLines) {
277 if (raw.trim() === '' || raw.startsWith('#')) continue;
278
279 let line = raw;
280
281 if (firstContent && line.startsWith('@')) {
282 img = line.substring(1).trim();
283 firstContent = false;
284 continue;
285 }
286 firstContent = false;
287
288 // image slides ignore remaining text lines
289 if (img !== null) continue;
290
291 // strip leading backslash (escape)
292 if (line.startsWith('\\')) {
293 line = line.substring(1);
294 }
295
296 lines.push(line);
297 }
298
299 if (img !== null || lines.length > 0) {
300 slides.push({
301 lines,
302 img
303 });
304 }
305 }
306
307 return slides;
308 },
309
310 // presentation controls
311 startPresentation() {
312 const text = document.getElementById('input').value;
313 this.slides = this.parseSent(text);
314
315 if (this.slides.length === 0) {
316 alert('No slides to present.');
317 return;
318 }
319
320 this.idx = 0;
321 this.presenting = true;
322 document.getElementById('presentation').classList.add('active');
323 document.body.style.overflow = 'hidden';
324
325 const el = document.getElementById('presentation');
326 if (el.requestFullscreen) el.requestFullscreen().catch(() => {});
327
328 this.renderSlide();
329 },
330
331 stopPresentation() {
332 this.presenting = false;
333 document.getElementById('presentation').classList.remove('active');
334 document.body.style.overflow = '';
335
336 if (document.fullscreenElement) {
337 document.exitFullscreen().catch(() => {});
338 }
339 },
340
341 navigate(dir) {
342 if (!this.presenting) return;
343 const next = this.idx + dir;
344 if (next >= 0 && next < this.slides.length) {
345 this.idx = next;
346 this.renderSlide();
347 }
348 },
349
350 // rendering engine
351 renderSlide() {
352 if (!this.presenting || this.slides.length === 0) return;
353
354 const slide = this.slides[this.idx];
355 const content = document.getElementById('slide-content');
356 const pres = document.getElementById('presentation');
357
358 pres.style.backgroundColor = this.settings.bg;
359 pres.style.color = this.settings.fg;
360
361 content.innerHTML = '';
362
363 if (slide.img) {
364 const img = document.createElement('img');
365 if (slide.img.startsWith('http://') || slide.img.startsWith('https://')) {
366 img.src = slide.img;
367 } else {
368 img.src = 'uploads/' + slide.img;
369 }
370 img.alt = slide.img;
371 img.style.maxWidth = (this.settings.usableWidth * 100) + 'vw';
372 img.style.maxHeight = (this.settings.usableHeight * 100) + 'vh';
373 img.style.width = (this.settings.usableWidth * 100) + 'vw';
374 img.style.height = (this.settings.usableHeight * 100) + 'vh';
375 img.style.objectFit = 'contain';
376 content.appendChild(img);
377 } else {
378 const fontSize = this.calcFontSize(slide.lines);
379 content.style.fontSize = fontSize + 'px';
380 content.style.lineHeight = String(this.settings.lineSpacing);
381
382 slide.lines.forEach((line, i) => {
383 if (i > 0) content.appendChild(document.createElement('br'));
384 content.appendChild(document.createTextNode(line));
385 });
386 }
387
388 },
389
390 calcFontSize(lines) {
391 const maxW = window.innerWidth * this.settings.usableWidth;
392 const maxH = window.innerHeight * this.settings.usableHeight;
393 const font = getComputedStyle(document.documentElement)
394 .getPropertyValue('--sent-font').trim() || 'sans-serif';
395
396 const canvas = document.createElement('canvas');
397 const ctx = canvas.getContext('2d');
398
399 let lo = 1,
400 hi = 500,
401 best = 1;
402
403 while (lo <= hi) {
404 const mid = Math.floor((lo + hi) / 2);
405 ctx.font = `${mid}px ${font}`;
406
407 let fits = true;
408 for (const line of lines) {
409 if (ctx.measureText(line).width > maxW) {
410 fits = false;
411 break;
412 }
413 }
414
415 // height check: line-spacing × (n-1) + 1 base height
416 const totalH = mid * this.settings.lineSpacing * (lines.length - 1) + mid;
417 if (totalH > maxH) fits = false;
418
419 if (fits) {
420 best = mid;
421 lo = mid + 1;
422 } else {
423 hi = mid - 1;
424 }
425 }
426
427 return best;
428 },
429
430 // keyboard handler
431
432 handleKeydown(e) {
433 // F5 to start presentation from editor
434 if (!this.presenting && e.key === 'F5') {
435 e.preventDefault();
436 this.startPresentation();
437 return;
438 }
439
440 if (!this.presenting) return;
441
442 switch (e.key) {
443 case 'Escape':
444 case 'q':
445 e.preventDefault();
446 this.stopPresentation();
447 break;
448 case 'ArrowRight':
449 case 'ArrowDown':
450 case ' ':
451 case 'Enter':
452 case 'l':
453 case 'j':
454 case 'n':
455 case 'PageDown':
456 e.preventDefault();
457 this.navigate(1);
458 break;
459 case 'ArrowLeft':
460 case 'ArrowUp':
461 case 'Backspace':
462 case 'h':
463 case 'k':
464 case 'p':
465 case 'PageUp':
466 e.preventDefault();
467 this.navigate(-1);
468 break;
469 }
470 },
471
472 // image upload
473
474 async handleUpload(e) {
475 const file = e.target.files[0];
476 if (!file) return;
477
478 const status = document.getElementById('upload-status');
479 status.textContent = 'uploading…';
480
481 const fd = new FormData();
482 fd.append('image', file);
483
484 try {
485 const res = await fetch('upload.php', {
486 method: 'POST',
487 body: fd
488 });
489 const data = await res.json();
490
491 if (data.error) {
492 status.textContent = 'error: ' + data.error;
493 return;
494 }
495
496 // insert @filename at cursor position
497 const ta = document.getElementById('input');
498 const pos = ta.selectionStart;
499 const txt = ta.value;
500 const ins = `\n@${data.filename}\n`;
501 ta.value = txt.substring(0, pos) + ins + txt.substring(pos);
502 ta.selectionStart = ta.selectionEnd = pos + ins.length;
503 ta.focus();
504
505 localStorage.setItem('sent-web-content', ta.value);
506 status.textContent = `uploaded: ${data.filename}`;
507 setTimeout(() => {
508 status.textContent = '';
509 }, 3000);
510 } catch (err) {
511 status.textContent = 'upload failed';
512 console.error(err);
513 }
514
515 e.target.value = '';
516 },
517
518 // download .sent file for local usage (base64-encoded to preserve unicode and avoid filename issues)
519 downloadSent() {
520 const text = document.getElementById('input').value;
521 const blob = new Blob([text], {
522 type: 'text/plain'
523 });
524 const a = document.createElement('a');
525 a.href = URL.createObjectURL(blob);
526 a.download = 'presentation.sent';
527 a.click();
528 URL.revokeObjectURL(a.href);
529 },
530
531 // download pdf export of the presentation from canvas
532 async exportPDF() {
533 const text = document.getElementById('input').value;
534 const slides = this.parseSent(text);
535
536 if (slides.length === 0) {
537 alert('No slides to export.');
538 return;
539 }
540
541 const btn = document.querySelector('button[onclick="App.exportPDF()"]');
542 if (btn) {
543 btn.textContent = 'generating pdf…';
544 btn.disabled = true;
545 }
546
547 try {
548 if (!window.jspdf) {
549 await new Promise((resolve, reject) => {
550 const s = document.createElement('script');
551 s.src = 'https://cdn.jsdelivr.net/npm/jspdf@2.5.2/dist/jspdf.umd.min.js';
552 s.onload = resolve;
553 s.onerror = () => reject(new Error('Failed to load jsPDF'));
554 document.head.appendChild(s);
555 });
556 }
557
558 // 1440p canvas per slide — rasterise via browser engine
559 // (handles fonts, unicode, emoji, images exactly as the live view does)
560 const W = 2560;
561 const H = 1440;
562
563 const {
564 jsPDF
565 } = window.jspdf;
566 // px unit + hotfix keeps jsPDF from rescaling our pixel-perfect canvases
567 const pdf = new jsPDF({
568 orientation: 'landscape',
569 unit: 'px',
570 format: [W, H],
571 hotfixes: ['px_scaling'],
572 });
573
574 for (let i = 0; i < slides.length; i++) {
575 const canvas = await this.renderSlideToCanvas(slides[i], W, H);
576 const imgData = canvas.toDataURL('image/jpeg', 0.93);
577
578 if (i > 0) pdf.addPage([W, H], 'landscape');
579 pdf.addImage(imgData, 'JPEG', 0, 0, W, H);
580 }
581
582 const epoch = Math.floor(Date.now() / 1000);
583 const uid = crypto.randomUUID().replace(/-/g, '').slice(0, 8);
584 pdf.save(`sent-web${epoch}-${uid}.pdf`);
585
586 } catch (err) {
587 console.error('PDF export failed:', err);
588 alert('PDF export failed: ' + err.message);
589 } finally {
590 if (btn) {
591 btn.textContent = 'export .pdf';
592 btn.disabled = false;
593 }
594 }
595 },
596
597 // render one slide to an off-screen canvas, mirrors renderSlide() exactly
598 async renderSlideToCanvas(slide, W, H) {
599 const canvas = document.createElement('canvas');
600 canvas.width = W;
601 canvas.height = H;
602 const ctx = canvas.getContext('2d');
603
604 const usable = this.settings.usableWidth; // 0.85
605 const maxW = W * usable;
606 const maxH = H * usable;
607 const marginX = (W - maxW) / 2;
608
609 // resolve the same font stack the live view uses
610 const fontStack = getComputedStyle(document.documentElement)
611 .getPropertyValue('--sent-font').trim() || 'sans-serif';
612
613 // background
614 ctx.fillStyle = this.settings.bg;
615 ctx.fillRect(0, 0, W, H);
616
617 if (slide.img) {
618 // image slide — draw the actual image
619 await new Promise((resolve) => {
620 const img = new Image();
621 img.crossOrigin = 'anonymous';
622 img.onload = () => {
623 const ratio = img.naturalWidth / img.naturalHeight;
624 let dw = maxW,
625 dh = maxH;
626 if (dw / dh > ratio) dw = dh * ratio;
627 else dh = dw / ratio;
628 ctx.drawImage(img, (W - dw) / 2, (H - dh) / 2, dw, dh);
629 resolve();
630 };
631 img.onerror = resolve; // still produce a page even on failure
632 img.src = (slide.img.startsWith('http://') || slide.img.startsWith('https://')) ?
633 slide.img :
634 'uploads/' + slide.img;
635 });
636 } else {
637 // text slide — left-aligned, same sizing logic as live view
638 const fontSize = this.calcFontSizeCanvas(ctx, slide.lines, fontStack, maxW, maxH);
639
640 ctx.font = `${fontSize}px ${fontStack}`;
641 ctx.fillStyle = this.settings.fg;
642 ctx.textBaseline = 'alphabetic';
643
644 const lineH = fontSize * this.settings.lineSpacing;
645 const totalH = lineH * (slide.lines.length - 1) + fontSize;
646 const startX = marginX;
647 const startY = (H - totalH) / 2 + fontSize; // first baseline
648
649 slide.lines.forEach((line, i) => {
650 ctx.fillText(line, startX, startY + i * lineH);
651 });
652 }
653
654 return canvas;
655 },
656
657 // binary-search font size on a canvas context for given absolute dimensions
658 calcFontSizeCanvas(ctx, lines, fontStack, maxW, maxH) {
659 let lo = 1,
660 hi = 600,
661 best = 1;
662
663 while (lo <= hi) {
664 const mid = Math.floor((lo + hi) / 2);
665 ctx.font = `${mid}px ${fontStack}`;
666
667 let fits = true;
668 for (const line of lines) {
669 if (ctx.measureText(line).width > maxW) {
670 fits = false;
671 break;
672 }
673 }
674
675 const totalH = mid * this.settings.lineSpacing * (lines.length - 1) + mid;
676 if (totalH > maxH) fits = false;
677
678 if (fits) {
679 best = mid;
680 lo = mid + 1;
681 } else {
682 hi = mid - 1;
683 }
684 }
685
686 return best;
687 },
688 };
689
690 document.addEventListener('DOMContentLoaded', () => App.init());
691 </script>
692</body>
693</html> \ No newline at end of file
diff --git a/src/upload.php b/src/upload.php
new file mode 100644
index 0000000..62db139
--- /dev/null
+++ b/src/upload.php
@@ -0,0 +1,66 @@
1<?php
2/* upload.php — handle image uploads */
3
4header('Content-Type: application/json');
5
6if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
7 http_response_code(405);
8 echo json_encode(['error' => 'Method not allowed']);
9 exit;
10}
11
12if (!isset($_FILES['image']) || $_FILES['image']['error'] !== UPLOAD_ERR_OK) {
13 $code = $_FILES['image']['error'] ?? 'unknown';
14 http_response_code(400);
15 echo json_encode(['error' => "Upload failed (code: $code)"]);
16 exit;
17}
18
19$file = $_FILES['image'];
20$allowed = [
21 'image/png', 'image/jpeg', 'image/gif',
22 'image/webp', 'image/svg+xml', 'image/bmp',
23];
24
25$finfo = finfo_open(FILEINFO_MIME_TYPE);
26$mime = finfo_file($finfo, $file['tmp_name']);
27finfo_close($finfo);
28
29if (!in_array($mime, $allowed, true)) {
30 http_response_code(400);
31 echo json_encode(['error' => "Invalid file type: $mime"]);
32 exit;
33}
34
35$ext = match ($mime) {
36 'image/png' => 'png',
37 'image/jpeg' => 'jpg',
38 'image/gif' => 'gif',
39 'image/webp' => 'webp',
40 'image/svg+xml' => 'svg',
41 'image/bmp' => 'bmp',
42 default => 'bin',
43};
44
45/* generate safe filename */
46$basename = pathinfo($file['name'], PATHINFO_FILENAME);
47$basename = preg_replace('/[^a-zA-Z0-9_-]/', '_', $basename);
48$basename = substr($basename, 0, 64);
49$filename = $basename . '_' . bin2hex(random_bytes(4)) . '.' . $ext;
50
51$uploadDir = __DIR__ . '/uploads';
52if (!is_dir($uploadDir)) {
53 mkdir($uploadDir, 0755, true);
54}
55
56$dest = $uploadDir . '/' . $filename;
57if (!move_uploaded_file($file['tmp_name'], $dest)) {
58 http_response_code(500);
59 echo json_encode(['error' => 'Failed to save file']);
60 exit;
61}
62
63echo json_encode([
64 'filename' => $filename,
65 'url' => 'uploads/' . $filename,
66]);
diff --git a/src/uploads/.htaccess b/src/uploads/.htaccess
new file mode 100644
index 0000000..2cf2ddc
--- /dev/null
+++ b/src/uploads/.htaccess
@@ -0,0 +1,2 @@
1# prevent PHP execution in uploads directory
2php_flag engine off