1use super::base_css;
2
3pub fn wrap_preview_html_document(
17 body: &str,
18 css: &str,
19 theme_class: &str,
20 background_color: Option<&str>,
21) -> String {
22 let inline_bg_style = if let Some(bg_color) = background_color {
24 format!("body {{ background-color: {} !important; }}\n", bg_color)
25 } else {
26 String::new()
27 };
28
29 let css = format!(
31 "{}\n\n/* ── Theme tokens ── */\n{}",
32 base_css::base_css(),
33 css
34 );
35
36 let table_resize_css = r#"
39/* Viewer: interactive table resizing */
40body.marco-table-resizing,
41body.marco-table-resizing * {
42 -webkit-user-select: none !important;
43 user-select: none !important;
44}
45
46table.marco-resize-active {
47 table-layout: fixed;
48}
49
50table.marco-resize-active th,
51table.marco-resize-active td {
52 overflow: hidden;
53 text-overflow: ellipsis;
54}
55
56/* Viewer: heading anchor - the heading text itself is the link */
57.marco-heading-anchor {
58 text-decoration: none !important;
59 color: inherit !important;
60 display: inline;
61}
62
63.marco-heading-anchor:link,
64.marco-heading-anchor:visited,
65.marco-heading-anchor:hover,
66.marco-heading-anchor:focus,
67.marco-heading-anchor:focus-visible,
68.marco-heading-anchor:active {
69 color: inherit !important;
70 text-decoration: none !important;
71 -webkit-text-fill-color: inherit !important;
72 background: inherit !important;
73 -webkit-background-clip: inherit !important;
74 background-clip: inherit !important;
75}
76
77/* Viewer: internal and external links
78 - Keeps links looking like normal links.
79 - On hover/focus, suppresses theme hover effects.
80 - Excludes the injected heading anchor link itself.
81*/
82a[href^='#']:not(.marco-heading-anchor),
83a[href^='http:']:not(.marco-heading-anchor),
84a[href^='https:']:not(.marco-heading-anchor),
85a[href^='mailto:']:not(.marco-heading-anchor) {
86 position: relative;
87}
88
89a[href^='#']:not(.marco-heading-anchor):link,
90a[href^='#']:not(.marco-heading-anchor):visited,
91a[href^='http:']:not(.marco-heading-anchor):link,
92a[href^='http:']:not(.marco-heading-anchor):visited,
93a[href^='https:']:not(.marco-heading-anchor):link,
94a[href^='https:']:not(.marco-heading-anchor):visited,
95a[href^='mailto:']:not(.marco-heading-anchor):link,
96a[href^='mailto:']:not(.marco-heading-anchor):visited {
97 color: var(--link-color) !important;
98}
99
100a[href^='#']:not(.marco-heading-anchor):hover,
101a[href^='#']:not(.marco-heading-anchor):focus,
102a[href^='#']:not(.marco-heading-anchor):focus-visible,
103a[href^='#']:not(.marco-heading-anchor):active,
104a[href^='http:']:not(.marco-heading-anchor):hover,
105a[href^='http:']:not(.marco-heading-anchor):focus,
106a[href^='http:']:not(.marco-heading-anchor):focus-visible,
107a[href^='http:']:not(.marco-heading-anchor):active,
108a[href^='https:']:not(.marco-heading-anchor):hover,
109a[href^='https:']:not(.marco-heading-anchor):focus,
110a[href^='https:']:not(.marco-heading-anchor):focus-visible,
111a[href^='https:']:not(.marco-heading-anchor):active,
112a[href^='mailto:']:not(.marco-heading-anchor):hover,
113a[href^='mailto:']:not(.marco-heading-anchor):focus,
114a[href^='mailto:']:not(.marco-heading-anchor):focus-visible,
115a[href^='mailto:']:not(.marco-heading-anchor):active {
116 color: var(--link-hover, var(--link-color)) !important;
117 text-decoration: underline !important;
118 text-shadow: none !important;
119 background: none !important;
120 box-shadow: none !important;
121 transform: none !important;
122 filter: none !important;
123}
124
125
126/* Viewer: sliders / slide decks */
127.marco-sliders {
128 position: relative;
129 margin: 1rem 0;
130 padding: 0.75rem 0.9rem;
131 border-radius: 10px;
132 border: 1px solid var(--mc-sliders-border, transparent);
133 background: var(--mc-sliders-bg, transparent);
134}
135
136.marco-sliders__viewport {
137 position: relative;
138 display: grid;
139 grid-template-columns: 1fr;
140 overflow: hidden;
141}
142
143.marco-sliders__slide {
144 grid-area: 1 / 1;
145 align-self: start;
146 justify-self: stretch;
147 opacity: 0;
148 visibility: hidden;
149 pointer-events: none;
150 transform: translateY(0.35rem);
151 transition: opacity 180ms ease-in-out, transform 180ms ease-in-out;
152}
153
154.marco-sliders__slide.is-active {
155 opacity: 1;
156 visibility: visible;
157 pointer-events: auto;
158 transform: translateY(0);
159}
160
161@media (prefers-reduced-motion: reduce) {
162 .marco-sliders__slide {
163 transition: none !important;
164 transform: none !important;
165 }
166}
167
168.marco-sliders__controls {
169 display: flex;
170 align-items: center;
171 justify-content: space-between;
172 gap: 0.75rem;
173 margin-top: 0.5rem;
174}
175
176.marco-sliders__btn {
177 display: inline-flex;
178 align-items: center;
179 justify-content: center;
180 gap: 0.25rem;
181 padding: 0.25rem 0.35rem;
182 border: none;
183 background: transparent;
184 color: inherit;
185 cursor: pointer;
186 opacity: 0.85;
187}
188
189.marco-sliders__btn:hover,
190.marco-sliders__dot:hover {
191 opacity: 1;
192}
193
194.marco-sliders__btn:disabled {
195 opacity: 0.35;
196 cursor: default;
197}
198
199.marco-sliders__btn svg,
200.marco-sliders__dot svg {
201 width: 1.15em;
202 height: 1.15em;
203 display: block;
204}
205
206.marco-sliders__dots {
207 display: flex;
208 align-items: center;
209 justify-content: center;
210 gap: 0.25rem;
211 margin-top: 0.35rem;
212}
213
214.marco-sliders__dot {
215 display: inline-flex;
216 align-items: center;
217 justify-content: center;
218 padding: 0.1rem;
219 border: none;
220 background: transparent;
221 color: inherit;
222 cursor: pointer;
223 opacity: 0.75;
224}
225
226.marco-sliders__dot.is-active {
227 opacity: 1;
228}
229
230.marco-sliders__dot-icon--active {
231 display: none;
232}
233
234.marco-sliders__dot.is-active .marco-sliders__dot-icon--inactive {
235 display: none;
236}
237
238.marco-sliders__dot.is-active .marco-sliders__dot-icon--active {
239 display: inline-flex;
240}
241
242/* Toggle button shows play when paused, pause when playing */
243.marco-sliders .marco-sliders__icon--pause {
244 display: none;
245}
246
247.marco-sliders.is-playing .marco-sliders__icon--play {
248 display: none;
249}
250
251.marco-sliders.is-playing .marco-sliders__icon--pause {
252 display: inline-flex;
253}
254"#;
255
256 format!(
259 r#"<!DOCTYPE html>
260<html class="{}">
261 <head>
262 <meta charset=\"utf-8\">
263 <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">
264 <link rel=\"stylesheet\" href=\"https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css\" integrity=\"sha384-n8MVd4RsNIU0tAv4ct0nTaAbDJwPJzDEaqSD1odI+WdtXRGWt2kTvGFasHpSy3SV\" crossorigin=\"anonymous\">
265 <style id=\"mc-preview-style\">{}{}</style>
266 <style id=\"mc-preview-internal-style\">{}</style>
267 <script>
268 // Preview management object to avoid global namespace pollution
269 window.MarcoCorePreview = (function() {{
270 var scrollTimeouts = [];
271 var tableResizerCleanup = null;
272 var tableSizeState = Object.create(null);
273 var sliderDeckState = Object.create(null);
274 var sliderDelegatedInstalled = false;
275 var sliderResizeObservers = Object.create(null);
276 var sliderMeasureScheduled = Object.create(null);
277 var sliderWindowResizeInstalled = false;
278
279 // Cleanup function to clear any pending timeouts
280 function cleanupScrollRestoration() {{
281 scrollTimeouts.forEach(function(id) {{
282 clearTimeout(id);
283 }});
284 scrollTimeouts = [];
285 }}
286
287 // Full cleanup used on page unload / WebView destroy.
288 // NOTE: updateContent() should NOT call this, otherwise it would
289 // uninstall delegated event listeners and break interactions.
290 function cleanup() {{
291 cleanupScrollRestoration();
292
293 // Stop any slider timers
294 try {{
295 Object.keys(sliderDeckState).forEach(function(deckId) {{
296 var st = sliderDeckState[deckId];
297 if (st && st.intervalId) {{
298 clearInterval(st.intervalId);
299 st.intervalId = null;
300 }}
301 }});
302 }} catch(e) {{
303 console.error('Error stopping sliders:', e);
304 }}
305
306 // Disconnect any ResizeObservers
307 try {{
308 Object.keys(sliderResizeObservers).forEach(function(deckId) {{
309 var ro = sliderResizeObservers[deckId];
310 if (ro && typeof ro.disconnect === 'function') {{
311 ro.disconnect();
312 }}
313 }});
314 }} catch(e) {{
315 console.error('Error disconnecting slider ResizeObservers:', e);
316 }}
317
318 // Remove table resizer listeners (if installed)
319 try {{
320 if (typeof tableResizerCleanup === 'function') {{
321 tableResizerCleanup();
322 }}
323 }} catch(e) {{
324 console.error('Error cleaning up table resizer:', e);
325 }}
326
327 // Clear any persisted state
328 tableSizeState = Object.create(null);
329 sliderDeckState = Object.create(null);
330 sliderResizeObservers = Object.create(null);
331 sliderMeasureScheduled = Object.create(null);
332 }}
333
334 function parsePositiveInt(s) {{
335 var n = parseInt(s, 10);
336 if (!isFinite(n) || isNaN(n) || n <= 0) return null;
337 return n;
338 }}
339
340 function setDeckPlaying(deck, playing) {{
341 try {{
342 if (playing) deck.classList.add('is-playing');
343 else deck.classList.remove('is-playing');
344 }} catch(_e) {{
345 // ignore
346 }}
347 }}
348
349 function getDeckState(deck) {{
350 if (!deck || !deck.id) return null;
351 return sliderDeckState[deck.id] || null;
352 }}
353
354 function measureDeckViewportHeight(deck) {{
355 try {{
356 if (!deck) return;
357 var viewport = deck.querySelector('.marco-sliders__viewport');
358 if (!viewport) return;
359
360 var slides = deck.querySelectorAll('.marco-sliders__slide');
361 if (!slides || slides.length === 0) return;
362
363 var maxH = 0;
364 for (var i = 0; i < slides.length; i++) {{
365 var el = slides[i];
366 if (!el) continue;
367 var r = el.getBoundingClientRect ? el.getBoundingClientRect() : null;
368 var h = (r && r.height) ? r.height : (el.scrollHeight || 0);
369 if (h > maxH) maxH = h;
370 }}
371
372 if (maxH > 0) {{
373 viewport.style.minHeight = Math.ceil(maxH) + 'px';
374 }}
375 }} catch(e) {{
376 console.error('Failed to measure slider deck height:', e);
377 }}
378 }}
379
380 function scheduleDeckMeasure(deck) {{
381 try {{
382 if (!deck || !deck.id) return;
383 if (sliderMeasureScheduled[deck.id]) return;
384 sliderMeasureScheduled[deck.id] = true;
385 requestAnimationFrame(function() {{
386 try {{
387 delete sliderMeasureScheduled[deck.id];
388 measureDeckViewportHeight(deck);
389 }} catch(_e) {{
390 // ignore
391 }}
392 }});
393 }} catch(_e) {{
394 // ignore
395 }}
396 }}
397
398 function ensureSliderWindowResizeInstalled() {{
399 if (sliderWindowResizeInstalled) return;
400 sliderWindowResizeInstalled = true;
401
402 window.addEventListener('resize', function() {{
403 try {{
404 Object.keys(sliderDeckState).forEach(function(deckId) {{
405 var st = sliderDeckState[deckId];
406 if (st && st.deckEl) scheduleDeckMeasure(st.deckEl);
407 }});
408 }} catch(e) {{
409 console.error('Slider resize handler error:', e);
410 }}
411 }}, true);
412 }}
413
414 function installDeckResizeObserver(deck) {{
415 try {{
416 if (!deck || !deck.id) return;
417 if (sliderResizeObservers[deck.id]) return;
418
419 if (!window.ResizeObserver) return;
420 var ro = new ResizeObserver(function(_entries) {{
421 scheduleDeckMeasure(deck);
422 }});
423
424 // Observe the viewport and each slide so height changes (e.g. images loading)
425 // trigger a re-measure.
426 var viewport = deck.querySelector('.marco-sliders__viewport');
427 if (viewport) ro.observe(viewport);
428
429 var slides = deck.querySelectorAll('.marco-sliders__slide');
430 for (var i = 0; i < slides.length; i++) {{
431 ro.observe(slides[i]);
432 }}
433
434 sliderResizeObservers[deck.id] = ro;
435 }} catch(e) {{
436 // ResizeObserver is best-effort; don't break sliders if it fails.
437 console.error('Failed to install ResizeObserver for slider deck:', e);
438 }}
439 }}
440
441 function showSlide(deck, index) {{
442 var st = getDeckState(deck);
443 if (!st) return;
444 var slides = deck.querySelectorAll('.marco-sliders__slide');
445 var dots = deck.querySelectorAll('.marco-sliders__dot');
446 if (!slides || slides.length === 0) return;
447
448 var n = slides.length;
449 var i = index;
450 if (i < 0) i = n - 1;
451 if (i >= n) i = 0;
452 st.index = i;
453
454 for (var k = 0; k < slides.length; k++) {{
455 if (k === i) slides[k].classList.add('is-active');
456 else slides[k].classList.remove('is-active');
457
458 // Keep hidden slides out of the accessibility tree.
459 try {{
460 if (k === i) slides[k].removeAttribute('aria-hidden');
461 else slides[k].setAttribute('aria-hidden', 'true');
462 }} catch(_e) {{
463 // ignore
464 }}
465 }}
466
467 for (var d = 0; d < dots.length; d++) {{
468 if (d === i) dots[d].classList.add('is-active');
469 else dots[d].classList.remove('is-active');
470
471 // Sync ARIA for keyboard/screen-reader navigation.
472 try {{
473 if (d === i) {{
474 dots[d].setAttribute('aria-selected', 'true');
475 dots[d].setAttribute('tabindex', '0');
476 }} else {{
477 dots[d].setAttribute('aria-selected', 'false');
478 dots[d].setAttribute('tabindex', '-1');
479 }}
480 }} catch(_e) {{
481 // ignore
482 }}
483 }}
484
485 // Lock the viewport size to the tallest slide to avoid layout jumps.
486 scheduleDeckMeasure(deck);
487 }}
488
489 function slidersPauseDeck(deckId) {{
490 var st = sliderDeckState[deckId];
491 if (!st) return;
492 if (st.intervalId) {{
493 clearInterval(st.intervalId);
494 st.intervalId = null;
495 }}
496 st.playing = false;
497 if (st.deckEl) setDeckPlaying(st.deckEl, false);
498 }}
499
500 function slidersPlayDeck(deckId) {{
501 var st = sliderDeckState[deckId];
502 if (!st) return;
503 if (!st.timerSeconds || st.timerSeconds <= 0) return;
504
505 slidersPauseDeck(deckId);
506 st.playing = true;
507 if (st.deckEl) setDeckPlaying(st.deckEl, true);
508
509 st.intervalId = setInterval(function() {{
510 try {{
511 var deck = st.deckEl;
512 if (!deck) return;
513 showSlide(deck, st.index + 1);
514 }} catch(e) {{
515 console.error('Slider tick error:', e);
516 }}
517 }}, st.timerSeconds * 1000);
518 }}
519
520 function slidersToggleDeck(deckId) {{
521 var st = sliderDeckState[deckId];
522 if (!st) return;
523 if (st.playing) slidersPauseDeck(deckId);
524 else slidersPlayDeck(deckId);
525 }}
526
527 function slidersPauseAll() {{
528 Object.keys(sliderDeckState).forEach(function(deckId) {{
529 slidersPauseDeck(deckId);
530 }});
531 }}
532
533 function slidersPlayAll() {{
534 Object.keys(sliderDeckState).forEach(function(deckId) {{
535 slidersPlayDeck(deckId);
536 }});
537 }}
538
539 function slidersToggleAll() {{
540 Object.keys(sliderDeckState).forEach(function(deckId) {{
541 slidersToggleDeck(deckId);
542 }});
543 }}
544
545 function initSliderDeck(deck) {{
546 if (!deck || !deck.id) return;
547 var timerSeconds = parsePositiveInt(deck.getAttribute('data-timer-seconds'));
548 var slides = deck.querySelectorAll('.marco-sliders__slide');
549 if (!slides || slides.length === 0) return;
550
551 sliderDeckState[deck.id] = {{
552 deckEl: deck,
553 index: 0,
554 timerSeconds: timerSeconds,
555 intervalId: null,
556 playing: false
557 }};
558
559 // Disable toggle button if no timer.
560 var toggleBtn = deck.querySelector('.marco-sliders__btn--toggle');
561 if (toggleBtn) {{
562 if (!timerSeconds) {{
563 toggleBtn.disabled = true;
564 toggleBtn.setAttribute('aria-disabled', 'true');
565 }} else {{
566 toggleBtn.disabled = false;
567 toggleBtn.removeAttribute('aria-disabled');
568 }}
569 }}
570
571 showSlide(deck, 0);
572 setDeckPlaying(deck, false);
573
574 // Prevent content jumps by measuring the largest slide and
575 // keeping the viewport height stable.
576 ensureSliderWindowResizeInstalled();
577 installDeckResizeObserver(deck);
578 scheduleDeckMeasure(deck);
579
580 // Autoplay if timer is present.
581 if (timerSeconds) {{
582 slidersPlayDeck(deck.id);
583 }}
584 }}
585
586 function ensureSliderDelegationInstalled() {{
587 if (sliderDelegatedInstalled) return;
588 sliderDelegatedInstalled = true;
589
590 // Delegated click handler; survives innerHTML updates.
591 document.addEventListener('click', function(ev) {{
592 try {{
593 var target = ev.target;
594 if (!target) return;
595
596 // Handle code block copy button
597 var copyBtn = target.closest('.marco-copy-btn');
598 if (copyBtn) {{
599 var wrapper = copyBtn.closest('.marco-code-block');
600 if (!wrapper) return;
601
602 var codeEl = wrapper.querySelector('code');
603 if (!codeEl) return;
604
605 var codeText = codeEl.textContent || '';
606
607 // Try to copy to clipboard
608 try {{
609 if (navigator.clipboard && navigator.clipboard.writeText) {{
610 navigator.clipboard.writeText(codeText).then(function() {{
611 // Show success feedback
612 copyBtn.classList.add('copied');
613 setTimeout(function() {{
614 copyBtn.classList.remove('copied');
615 }}, 2000);
616 }}).catch(function(err) {{
617 console.error('Failed to copy code:', err);
618 }});
619 }} else {{
620 // Fallback for older browsers
621 var textArea = document.createElement('textarea');
622 textArea.value = codeText;
623 textArea.style.position = 'fixed';
624 textArea.style.left = '-9999px';
625 document.body.appendChild(textArea);
626 textArea.select();
627 try {{
628 document.execCommand('copy');
629 copyBtn.classList.add('copied');
630 setTimeout(function() {{
631 copyBtn.classList.remove('copied');
632 }}, 2000);
633 }} catch(err) {{
634 console.error('Fallback copy failed:', err);
635 }}
636 document.body.removeChild(textArea);
637 }}
638 }} catch(err) {{
639 console.error('Copy error:', err);
640 }}
641 return;
642 }}
643
644 // Handle slider controls
645 var btn = target.closest('button');
646 if (!btn) return;
647 var deck = btn.closest('.marco-sliders');
648 if (!deck) return;
649
650 var action = btn.getAttribute('data-action');
651 var st = getDeckState(deck);
652 if (!st) return;
653
654 if (action === 'prev') {{
655 showSlide(deck, st.index - 1);
656 }} else if (action === 'next') {{
657 showSlide(deck, st.index + 1);
658 }} else if (action === 'goto') {{
659 var idx = parseInt(btn.getAttribute('data-index'), 10);
660 if (!isNaN(idx)) showSlide(deck, idx);
661 }} else if (action === 'toggle') {{
662 slidersToggleDeck(deck.id);
663 }}
664 }} catch(e) {{
665 console.error('Click handler error:', e);
666 }}
667 }}, true);
668 }}
669
670 function installSliders(container) {{
671 try {{
672 // Stop existing timers and rebuild state for the new DOM.
673 slidersPauseAll();
674
675 // Disconnect any prior observers (they reference old DOM nodes).
676 try {{
677 Object.keys(sliderResizeObservers).forEach(function(deckId) {{
678 var ro = sliderResizeObservers[deckId];
679 if (ro && typeof ro.disconnect === 'function') ro.disconnect();
680 }});
681 }} catch(_e) {{
682 // ignore
683 }}
684
685 sliderDeckState = Object.create(null);
686 sliderResizeObservers = Object.create(null);
687 sliderMeasureScheduled = Object.create(null);
688 ensureSliderDelegationInstalled();
689
690 if (!container) return;
691 var decks = container.querySelectorAll('.marco-sliders');
692 for (var i = 0; i < decks.length; i++) {{
693 initSliderDeck(decks[i]);
694 }}
695 }} catch(e) {{
696 console.error('Failed to install sliders:', e);
697 }}
698 }}
699
700 function applyStoredTableSizes(container) {{
701 try {{
702 if (!container) return;
703 var tables = container.querySelectorAll('table');
704
705 function firstRowCellCount(tbl) {{
706 try {{
707 if (!tbl || !tbl.rows || tbl.rows.length === 0) return 0;
708 return (tbl.rows[0] && tbl.rows[0].cells) ? tbl.rows[0].cells.length : 0;
709 }} catch(_e) {{
710 return 0;
711 }}
712 }}
713
714 function ensureColGroup(tbl, colCount) {{
715 if (!tbl || colCount <= 0) return null;
716 var cg = tbl.querySelector('colgroup');
717 if (!cg) {{
718 cg = document.createElement('colgroup');
719
720 // Insert after caption if present, otherwise as the first child.
721 var first = tbl.firstElementChild;
722 if (first && first.tagName === 'CAPTION') {{
723 if (first.nextSibling) {{
724 tbl.insertBefore(cg, first.nextSibling);
725 }} else {{
726 tbl.appendChild(cg);
727 }}
728 }} else if (first) {{
729 tbl.insertBefore(cg, first);
730 }} else {{
731 tbl.appendChild(cg);
732 }}
733 }}
734
735 // Normalize number of <col> elements.
736 var cols = cg.querySelectorAll('col');
737 if (cols.length !== colCount) {{
738 cg.innerHTML = '';
739 for (var i = 0; i < colCount; i++) {{
740 cg.appendChild(document.createElement('col'));
741 }}
742 }}
743 return cg;
744 }}
745
746 for (var i = 0; i < tables.length; i++) {{
747 var tbl = tables[i];
748 var key = 't' + i;
749 var state = tableSizeState[key];
750 if (!state) continue;
751
752 // Apply stored column widths
753 if (state.cols) {{
754 var colCount = firstRowCellCount(tbl);
755 var wantCols = Math.max(colCount, state.cols.length || 0);
756 var cg = ensureColGroup(tbl, wantCols);
757 if (cg) {{
758 var cols = cg.querySelectorAll('col');
759 for (var ci = 0; ci < state.cols.length && ci < cols.length; ci++) {{
760 if (state.cols[ci]) {{
761 cols[ci].style.width = state.cols[ci];
762 }}
763 }}
764 try {{
765 tbl.classList.add('marco-resize-active');
766 tbl.style.tableLayout = 'fixed';
767 }} catch(_e) {{
768 // ignore
769 }}
770 }}
771 }}
772
773 // Apply stored table width (helps keep col widths stable)
774 if (state.tableWidth) {{
775 try {{
776 tbl.style.width = state.tableWidth;
777 }} catch(_e) {{
778 // ignore
779 }}
780 }}
781
782 // Apply stored row heights
783 if (state.rows) {{
784 var trs = tbl.querySelectorAll('tr');
785 for (var ri = 0; ri < state.rows.length && ri < trs.length; ri++) {{
786 if (state.rows[ri]) {{
787 trs[ri].style.height = state.rows[ri];
788 }}
789 }}
790 }}
791 }}
792 }} catch(e) {{
793 console.error('Error applying stored table sizes:', e);
794 }}
795 }}
796
797 // Interactive table row/column resizing (HTML preview only).
798 // - Column resize: near right edge of a TH/TD (priority over row)
799 // - Row resize: near bottom edge of a TR
800 // - Uses <colgroup> widths for column stability
801 // - Disables text selection while actively resizing
802 function installTableResizer() {{
803 var EDGE_PX = 5;
804 var MIN_COL_W = 40;
805 var MAX_COL_W = 2000;
806 var MIN_ROW_H = 18;
807 var MAX_ROW_H = 1200;
808
809 var active = false;
810 var mode = null; // 'col' | 'row'
811 var startX = 0;
812 var startY = 0;
813 var table = null;
814 var colIndex = -1;
815 var colEl = null;
816 var startColW = 0;
817 var startTableW = 0;
818 var rowEl = null;
819 var startRowH = 0;
820
821 function clamp(v, minV, maxV) {{
822 return Math.max(minV, Math.min(maxV, v));
823 }}
824
825 function setCursor(cursor) {{
826 try {{
827 if (document && document.body) {{
828 document.body.style.cursor = cursor || '';
829 }}
830 }} catch(_e) {{
831 // ignore
832 }}
833 }}
834
835 function closestCell(target) {{
836 if (!target) return null;
837 if (target.nodeType !== 1) return null;
838 if (target.tagName === 'TD' || target.tagName === 'TH') return target;
839 return target.closest ? target.closest('td, th') : null;
840 }}
841
842 function getTableFromCell(cell) {{
843 if (!cell) return null;
844 return cell.closest ? cell.closest('table') : null;
845 }}
846
847 function firstRowCellCount(tbl) {{
848 try {{
849 if (!tbl || !tbl.rows || tbl.rows.length === 0) return 0;
850 return (tbl.rows[0] && tbl.rows[0].cells) ? tbl.rows[0].cells.length : 0;
851 }} catch(_e) {{
852 return 0;
853 }}
854 }}
855
856 function ensureColGroup(tbl, colCount) {{
857 if (!tbl || colCount <= 0) return null;
858 var cg = tbl.querySelector('colgroup');
859 if (!cg) {{
860 cg = document.createElement('colgroup');
861
862 // Insert after caption if present, otherwise as the first child.
863 var first = tbl.firstElementChild;
864 if (first && first.tagName === 'CAPTION') {{
865 if (first.nextSibling) {{
866 tbl.insertBefore(cg, first.nextSibling);
867 }} else {{
868 tbl.appendChild(cg);
869 }}
870 }} else if (first) {{
871 tbl.insertBefore(cg, first);
872 }} else {{
873 tbl.appendChild(cg);
874 }}
875 }}
876
877 // Normalize number of <col> elements.
878 var cols = cg.querySelectorAll('col');
879 if (cols.length !== colCount) {{
880 cg.innerHTML = '';
881 for (var i = 0; i < colCount; i++) {{
882 cg.appendChild(document.createElement('col'));
883 }}
884 }}
885 return cg;
886 }}
887
888 function initColumnWidths(tbl) {{
889 var colCount = firstRowCellCount(tbl);
890 if (colCount <= 0) return null;
891 var cg = ensureColGroup(tbl, colCount);
892 if (!cg) return null;
893 var cols = cg.querySelectorAll('col');
894
895 // Lock initial widths only if not already explicit.
896 for (var i = 0; i < cols.length; i++) {{
897 if (!cols[i].style.width) {{
898 var cell = (tbl.rows[0] && tbl.rows[0].cells[i]) ? tbl.rows[0].cells[i] : null;
899 if (cell) {{
900 var r = cell.getBoundingClientRect();
901 cols[i].style.width = Math.max(MIN_COL_W, Math.round(r.width)) + 'px';
902 }}
903 }}
904 }}
905 return cg;
906 }}
907
908 function isInRightEdgeZone(cell, x) {{
909 if (!cell) return false;
910 var r = cell.getBoundingClientRect();
911 return Math.abs(r.right - x) <= EDGE_PX;
912 }}
913
914 function isInBottomEdgeZone(cell, y) {{
915 if (!cell) return false;
916 var r = cell.getBoundingClientRect();
917 return Math.abs(r.bottom - y) <= EDGE_PX;
918 }}
919
920 function findResizeTarget(ev) {{
921 var cell = closestCell(ev.target);
922 if (!cell) return null;
923 var tbl = getTableFromCell(cell);
924 if (!tbl) return null;
925
926 // Ignore nested tables (choose the closest table of the cell).
927 var x = ev.clientX;
928 var y = ev.clientY;
929
930 // Priority: column resize > row resize
931 if (isInRightEdgeZone(cell, x)) {{
932 return {{ mode: 'col', table: tbl, cell: cell }};
933 }}
934 if (isInBottomEdgeZone(cell, y)) {{
935 var tr = cell.parentElement;
936 if (tr && tr.tagName === 'TR') {{
937 return {{ mode: 'row', table: tbl, row: tr, cell: cell }};
938 }}
939 }}
940 return null;
941 }}
942
943 function startColResize(tbl, cell, ev) {{
944 var cg = initColumnWidths(tbl);
945 if (!cg) return false;
946
947 var idx = (typeof cell.cellIndex === 'number') ? cell.cellIndex : -1;
948 if (idx < 0) return false;
949
950 var cols = cg.querySelectorAll('col');
951 if (idx >= cols.length) return false;
952
953 table = tbl;
954 colIndex = idx;
955 colEl = cols[idx];
956 startX = ev.clientX;
957 var cellRect = cell.getBoundingClientRect();
958 startColW = Math.max(MIN_COL_W, Math.round(cellRect.width));
959 startTableW = Math.round(tbl.getBoundingClientRect().width);
960
961 // Freeze layout so only the target column changes.
962 try {{
963 tbl.classList.add('marco-resize-active');
964 tbl.style.tableLayout = 'fixed';
965 tbl.style.width = startTableW + 'px';
966 }} catch(_e) {{
967 // ignore
968 }}
969
970 // Ensure the col reflects our start width.
971 colEl.style.width = startColW + 'px';
972
973 mode = 'col';
974 active = true;
975 return true;
976 }}
977
978 function startRowResize(tr, ev) {{
979 rowEl = tr;
980 startY = ev.clientY;
981 startRowH = Math.round(tr.getBoundingClientRect().height);
982 mode = 'row';
983 active = true;
984 return true;
985 }}
986
987 function beginResize(ev, target) {{
988 if (!target) return false;
989 if (ev.button !== 0) return false;
990
991 // Prevent text selection / link activation while resizing.
992 ev.preventDefault();
993 ev.stopPropagation();
994
995 if (document && document.body) {{
996 document.body.classList.add('marco-table-resizing');
997 }}
998
999 if (target.mode === 'col') {{
1000 return startColResize(target.table, target.cell, ev);
1001 }}
1002 if (target.mode === 'row') {{
1003 return startRowResize(target.row, ev);
1004 }}
1005 return false;
1006 }}
1007
1008 function applyResize(ev) {{
1009 if (!active) return;
1010 ev.preventDefault();
1011 ev.stopPropagation();
1012
1013 if (mode === 'col' && table && colEl) {{
1014 var dx = ev.clientX - startX;
1015 var newW = clamp(startColW + dx, MIN_COL_W, MAX_COL_W);
1016 colEl.style.width = Math.round(newW) + 'px';
1017
1018 // Keep other columns stable by changing the overall table width.
1019 var newTableW = clamp(startTableW + (newW - startColW), MIN_COL_W, MAX_COL_W * 50);
1020 table.style.width = Math.round(newTableW) + 'px';
1021 return;
1022 }}
1023 if (mode === 'row' && rowEl) {{
1024 var dy = ev.clientY - startY;
1025 var newH = clamp(startRowH + dy, MIN_ROW_H, MAX_ROW_H);
1026 rowEl.style.height = Math.round(newH) + 'px';
1027 return;
1028 }}
1029 }}
1030
1031 function endResize() {{
1032 if (!active) return;
1033
1034 // Persist the last resize so it survives smooth preview updates.
1035 try {{
1036 function getTableKey(tbl) {{
1037 var container = document.getElementById("mc-content-container");
1038 if (!container || !tbl) return null;
1039 var tables = container.querySelectorAll('table');
1040 for (var i = 0; i < tables.length; i++) {{
1041 if (tables[i] === tbl) return 't' + i;
1042 }}
1043 return null;
1044 }}
1045
1046 function getRowIndex(tbl, tr) {{
1047 if (!tbl || !tr) return -1;
1048 var trs = tbl.querySelectorAll('tr');
1049 for (var i = 0; i < trs.length; i++) {{
1050 if (trs[i] === tr) return i;
1051 }}
1052 return -1;
1053 }}
1054
1055 if (mode === 'col' && table && colIndex >= 0 && colEl) {{
1056 var key = getTableKey(table);
1057 if (key) {{
1058 if (!tableSizeState[key]) tableSizeState[key] = {{ cols: [], rows: [] }};
1059 tableSizeState[key].cols[colIndex] = colEl.style.width || null;
1060 tableSizeState[key].tableWidth = (table.style && table.style.width) ? table.style.width : null;
1061 }}
1062 }} else if (mode === 'row' && rowEl) {{
1063 var t = rowEl.closest ? rowEl.closest('table') : null;
1064 var key2 = getTableKey(t);
1065 if (key2 && t) {{
1066 if (!tableSizeState[key2]) tableSizeState[key2] = {{ cols: [], rows: [] }};
1067 var idx = getRowIndex(t, rowEl);
1068 if (idx >= 0) {{
1069 tableSizeState[key2].rows[idx] = rowEl.style.height || null;
1070 }}
1071 }}
1072 }}
1073 }} catch(e) {{
1074 console.error('Error persisting table resize state:', e);
1075 }}
1076
1077 active = false;
1078 mode = null;
1079 colIndex = -1;
1080 colEl = null;
1081 rowEl = null;
1082
1083 if (document && document.body) {{
1084 document.body.classList.remove('marco-table-resizing');
1085 }}
1086 setCursor('');
1087 }}
1088
1089 function onMouseMove(ev) {{
1090 if (active) {{
1091 applyResize(ev);
1092 return;
1093 }}
1094
1095 var t = findResizeTarget(ev);
1096 if (t && t.mode === 'col') {{
1097 setCursor('col-resize');
1098 return;
1099 }}
1100 if (t && t.mode === 'row') {{
1101 setCursor('row-resize');
1102 return;
1103 }}
1104 setCursor('');
1105 }}
1106
1107 function onMouseDown(ev) {{
1108 if (active) return;
1109 var t = findResizeTarget(ev);
1110 if (t) {{
1111 beginResize(ev, t);
1112 }}
1113 }}
1114
1115 function onMouseUp(_ev) {{
1116 endResize();
1117 }}
1118
1119 function onKeyDown(ev) {{
1120 // Escape cancels an active resize.
1121 if (ev && ev.key === 'Escape') {{
1122 endResize();
1123 }}
1124 }}
1125
1126 // Install listeners once (event delegation; works across content updates).
1127 document.addEventListener('mousemove', onMouseMove, true);
1128 document.addEventListener('mousedown', onMouseDown, true);
1129 document.addEventListener('mouseup', onMouseUp, true);
1130 window.addEventListener('blur', endResize, true);
1131 document.addEventListener('keydown', onKeyDown, true);
1132
1133 return function uninstall() {{
1134 try {{
1135 document.removeEventListener('mousemove', onMouseMove, true);
1136 document.removeEventListener('mousedown', onMouseDown, true);
1137 document.removeEventListener('mouseup', onMouseUp, true);
1138 window.removeEventListener('blur', endResize, true);
1139 document.removeEventListener('keydown', onKeyDown, true);
1140 }} catch(_e) {{
1141 // ignore
1142 }}
1143 endResize();
1144 }};
1145 }}
1146
1147 // Install immediately (listeners are delegated, no per-table init required)
1148 try {{
1149 tableResizerCleanup = installTableResizer();
1150 }} catch(e) {{
1151 console.error('Failed to install table resizer:', e);
1152 }}
1153
1154 // Initialize any sliders that are already present in the initial HTML.
1155 // Without this, slider slides default to `display:none` and nothing shows
1156 // until the host app calls setContent()/updateContent().
1157 try {{
1158 document.addEventListener('DOMContentLoaded', function() {{
1159 var container = document.getElementById("mc-content-container");
1160 if (container) {{
1161 applyStoredTableSizes(container);
1162 installSliders(container);
1163 }}
1164 }});
1165 }} catch(e) {{
1166 console.error('Failed to auto-init sliders:', e);
1167 }}
1168
1169 return {{
1170 setCSS: function(css) {{
1171 try {{
1172 var el = document.getElementById("mc-preview-style");
1173 if (el) {{
1174 el.innerHTML = css;
1175 }}
1176 }} catch(e) {{
1177 console.error('Error setting CSS:', e);
1178 }}
1179 }},
1180
1181 setTheme: function(mode) {{
1182 try {{
1183 document.documentElement.className = mode;
1184 }} catch(e) {{
1185 console.error('Error setting theme:', e);
1186 }}
1187 }},
1188
1189 updateContent: function(htmlContent) {{
1190 try {{
1191 // Clean up any pending scroll restoration (keep interactions installed)
1192 cleanupScrollRestoration();
1193
1194 // Save current scroll position
1195 var scrollTop = document.documentElement.scrollTop || document.body.scrollTop;
1196
1197 // Update content container
1198 var container = document.getElementById("mc-content-container");
1199 if (container) {{
1200 container.innerHTML = htmlContent;
1201 applyStoredTableSizes(container);
1202 installSliders(container);
1203
1204 // Restore scroll position after a brief delay
1205 var timeoutId = setTimeout(function() {{
1206 document.documentElement.scrollTop = scrollTop;
1207 document.body.scrollTop = scrollTop;
1208 // Remove this timeout from tracking
1209 var index = scrollTimeouts.indexOf(timeoutId);
1210 if (index > -1) {{
1211 scrollTimeouts.splice(index, 1);
1212 }}
1213 }}, 10);
1214 scrollTimeouts.push(timeoutId);
1215 }}
1216 }} catch(e) {{
1217 console.error('Error updating content:', e);
1218 }}
1219 }},
1220
1221 setContent: function(htmlContent) {{
1222 try {{
1223 var container = document.getElementById("mc-content-container");
1224 if (container) {{
1225 container.innerHTML = htmlContent;
1226 applyStoredTableSizes(container);
1227 installSliders(container);
1228 }}
1229 }} catch(e) {{
1230 console.error('Error setting content:', e);
1231 }}
1232 }},
1233
1234 sliders: {{
1235 playAll: slidersPlayAll,
1236 pauseAll: slidersPauseAll,
1237 toggleAll: slidersToggleAll,
1238 playDeck: slidersPlayDeck,
1239 pauseDeck: slidersPauseDeck,
1240 toggleDeck: slidersToggleDeck
1241 }},
1242
1243 cleanup: cleanup
1244 }};
1245 }})();
1246
1247 // Cleanup on page unload
1248 window.addEventListener('beforeunload', function() {{
1249 if (window.MarcoCorePreview) {{
1250 MarcoCorePreview.cleanup();
1251 }}
1252 }});
1253 </script>
1254 </head>
1255 <body>
1256 <div id="mc-content-container">{}</div>
1257 </body>
1258</html>"#,
1259 theme_class, inline_bg_style, css, table_resize_css, body
1260 )
1261}
1262
1263pub struct PageViewOptions<'a> {
1267 pub paged_js_source: &'a str,
1269 pub paper: &'a str,
1271 pub orientation: &'a str,
1273 pub margin_mm: u8,
1275 pub show_page_numbers: bool,
1277 pub wheel_js: &'a str,
1280 pub columns_per_row: u8,
1282 pub for_export: bool,
1286 pub title: &'a str,
1289 pub standalone_export: bool,
1294}
1295
1296pub fn wrap_preview_html_document_paged(
1310 body: &str,
1311 css: &str,
1312 theme_class: &str,
1313 background_color: Option<&str>,
1314 page_opts: &PageViewOptions<'_>,
1315) -> String {
1316 let inline_bg_style = if let Some(bg) = background_color {
1322 format!("body {{ background-color: {} !important; }}\n", bg)
1323 } else {
1324 String::new()
1325 };
1326
1327 let css = format!(
1329 "{}\n\n/* ── Theme tokens ── */\n{}",
1330 base_css::base_css(),
1331 css
1332 );
1333
1334 let page_size_rule = format!(
1336 "@page {{ size: {} {}; margin: {}mm; }}\n",
1337 page_opts.paper, page_opts.orientation, page_opts.margin_mm
1338 );
1339
1340 let page_counter_rule = if page_opts.show_page_numbers {
1345 r#"@page {
1346 @bottom-center {
1347 content: counter(page) " / " counter(pages);
1348 font-size: 0.75em;
1349 color: var(--text-muted, #888);
1350 }
1351}
1352"#
1353 } else {
1354 ""
1355 };
1356
1357 let columns = page_opts.columns_per_row.clamp(1, 4);
1359
1360 let multi_col_css = if columns > 1 {
1363 format!(
1364 r#"
1365/* ── Multi-column layout: {cols} pages per row ─────────────────────────── */
1366.pagedjs_pages {{
1367 flex-direction: row !important;
1368 flex-wrap: wrap !important;
1369 justify-content: center !important;
1370 align-items: flex-start !important;
1371 gap: 2em !important;
1372 padding-left: 1em !important;
1373 padding-right: 1em !important;
1374}}
1375.pagedjs_page {{
1376 margin-bottom: 0 !important;
1377}}
1378"#,
1379 cols = columns
1380 )
1381 } else {
1382 String::new()
1383 };
1384
1385 let paged_body_css = format!(
1396 r#"
1397/* paged.js: reset every theme layout constraint on html/body */
1398html, body {{
1399 margin: 0 !important;
1400 padding: 0 !important;
1401 max-width: none !important;
1402 width: 100% !important;
1403 box-sizing: border-box !important;
1404}}
1405
1406/* ── Viewport / desk ──────────────────────────────────────────────────────
1407 The body background is the "desk" that surrounds the page boxes.
1408 It must be visually distinct from the paper so pages have contrast. */
1409body {{
1410 background-color: #d0d0d0 !important; /* light-mode desk (medium grey) */
1411 min-height: 100vh;
1412}}
1413
1414/* Dark-mode desk: dark grey, clearly separate from typical dark-theme papers */
1415html.theme-dark body {{
1416 background-color: #2b2b2b !important;
1417}}
1418
1419/* ── All-pages container ──────────────────────────────────────────────────
1420 Flex column centres pages horizontally and adds vertical breathing room. */
1421.pagedjs_pages {{
1422 display: flex !important;
1423 flex-direction: column !important;
1424 align-items: center !important;
1425 padding-top: 3em !important;
1426 padding-bottom: 3em !important;
1427 width: 100% !important;
1428 box-sizing: border-box !important;
1429}}
1430
1431/* ── Tell WebKit which color-scheme is active so scrollbars, form controls,
1432 and native UI elements render correctly in dark mode. */
1433html.theme-dark {{
1434 color-scheme: dark;
1435}}
1436html.theme-light {{
1437 color-scheme: light;
1438}}
1439
1440/* ── Individual page (paper) ──────────────────────────────────────────────
1441 Shadow and margins are structural — they stay here.
1442 background-color and color are owned by each theme's .pagedjs_page rule,
1443 which cascades from the active .theme-light / .theme-dark variables. */
1444.pagedjs_page {{
1445 box-shadow: 0 2px 12px rgba(0, 0, 0, 0.20) !important;
1446 margin-bottom: 2em !important;
1447 margin-top: 0 !important;
1448 margin-left: 0 !important;
1449 margin-right: 0 !important;
1450}}
1451
1452/* Dark-mode paper: stronger shadow to separate page from the dark desk. */
1453html.theme-dark .pagedjs_page {{
1454 box-shadow: 0 2px 14px rgba(0, 0, 0, 0.55) !important;
1455}}
1456{multi_col}
1457"#,
1458 multi_col = multi_col_css
1459 );
1460
1461 let export_css_block: &str = if page_opts.for_export {
1464 concat!(
1465 " <style id=\"mc-print-export-css\">\n",
1466 "@media print {\n",
1467 " @page { margin: 0 !important; }\n",
1468 " html, body { background-color: white !important; }\n",
1469 " body { background-color: white !important; }\n",
1470 " .pagedjs_page { box-shadow: none !important; margin-bottom: 0 !important;",
1471 " margin-top: 0 !important; }\n",
1472 " .pagedjs_pages { padding-top: 0 !important; padding-bottom: 0 !important; }\n",
1473 "}\n",
1474 " </style>\n",
1475 )
1476 } else {
1477 ""
1478 };
1479
1480 let title_tag = if page_opts.title.is_empty() {
1482 String::new()
1483 } else {
1484 let escaped = page_opts
1486 .title
1487 .replace('&', "&")
1488 .replace('<', "<")
1489 .replace('>', ">");
1490 format!(" <title>{}</title>\n", escaped)
1491 };
1492
1493 let integration_js = if page_opts.standalone_export {
1496 r#"window.PagedConfig = {
1499 auto: true,
1500 after: function() {
1501 document.body.style.transition = 'opacity 0.12s ease-in';
1502 document.body.style.opacity = '1';
1503 }
1504};
1505/* Safety net: reveal the page if after() never fires. */
1506setTimeout(function() {
1507 if (document.body.style.opacity !== '1') {
1508 document.body.style.opacity = '1';
1509 }
1510}, 8000);"#
1511 } else {
1512 r#"/* Must be set BEFORE paged.js script evaluates so Ym reads these values. */
1514window.__pagedJsReady = false;
1515window.PagedConfig = {
1516 auto: true,
1517 /* paged.js calls after() once layout is fully complete — this is the
1518 official hook and avoids all manual-preview() timing issues. */
1519 after: function() {
1520 document.body.style.transition = 'opacity 0.12s ease-in';
1521 document.body.style.opacity = '1';
1522 /* Arm baseline guard so the initial scroll-position-0 the webview
1523 reports right after layout is silently cached, not forwarded to
1524 the editor (which would yank the editor caret to the top). */
1525 window.__pagedJsJustReady = true;
1526 /* Tell Rust scroll-sync to restore the preview scroll to where the
1527 editor cursor currently is. */
1528 document.title = 'mc_paged_ready';
1529 window.__pagedJsReady = true;
1530 setTimeout(function() { window.__pagedJsJustReady = false; }, 500);
1531 }
1532};"#
1533 };
1534
1535 let safety_net_js = if page_opts.standalone_export {
1537 "" } else {
1539 r#"
1540 <script>
1541/* Safety net: reveal the page if the after() hook never fires (e.g. empty
1542 document or paged.js internal error). */
1543setTimeout(function() {
1544 if (!window.__pagedJsReady) {
1545 document.body.style.opacity = '1';
1546 window.__pagedJsJustReady = true;
1547 document.title = 'mc_paged_ready';
1548 window.__pagedJsReady = true;
1549 setTimeout(function() { window.__pagedJsJustReady = false; }, 500);
1550 }
1551}, 8000);
1552 </script>"#
1553 };
1554
1555 format!(
1556 r#"<!DOCTYPE html>
1557<html class="{}">
1558 <head>
1559 <meta charset="UTF-8">
1560 <meta name="viewport" content="width=device-width, initial-scale=1">
1561{} <style id="mc-paged-page-css">
1562{}{}{}
1563 </style>
1564 <style id="mc-preview-style">
1565{}{}
1566 </style>
1567{} </head>
1568 <body style="opacity:0">
1569 <div id="mc-content-container">{}</div>
1570 <script>
1571{}
1572 </script>
1573 <script>
1574{}
1575 </script>
1576 {}{}
1577 </body>
1578</html>"#,
1579 theme_class,
1580 title_tag,
1581 page_size_rule,
1582 page_counter_rule,
1583 paged_body_css,
1584 inline_bg_style,
1585 css,
1586 export_css_block,
1587 body,
1588 integration_js,
1589 page_opts.paged_js_source,
1590 page_opts.wheel_js,
1591 safety_net_js,
1592 )
1593}
1594
1595#[cfg(test)]
1596mod tests {
1597 use super::*;
1598
1599 #[test]
1600 fn smoke_wrap_preview_contains_expected_hooks() {
1601 let doc = wrap_preview_html_document(
1602 "<table><tr><td>a</td></tr></table>",
1603 "body { color: red; }",
1604 "dark",
1605 Some("#000000"),
1606 );
1607 assert!(doc.contains("id=\\\"mc-preview-style\\\""));
1608 assert!(doc.contains("id=\\\"mc-preview-internal-style\\\""));
1609 assert!(doc.contains("window.MarcoCorePreview"));
1610 assert!(doc.contains("installTableResizer"));
1611 assert!(doc.contains("installSliders"));
1612 assert!(doc.contains("sliders:"));
1613 assert!(doc.contains("content-container"));
1614 }
1615
1616 #[test]
1617 fn smoke_wrap_preview_paged_contains_page_css() {
1618 let opts = PageViewOptions {
1619 paged_js_source: "/* paged.js stub */",
1620 paper: "A4",
1621 orientation: "portrait",
1622 margin_mm: 20,
1623 show_page_numbers: true,
1624 wheel_js: "",
1625 columns_per_row: 1,
1626 for_export: false,
1627 title: "",
1628 standalone_export: false,
1629 };
1630 let doc = wrap_preview_html_document_paged(
1631 "<p>Hello</p>",
1632 "body { color: red; }",
1633 "light",
1634 None,
1635 &opts,
1636 );
1637 assert!(doc.contains("@page"));
1638 assert!(doc.contains("A4 portrait"));
1639 assert!(doc.contains("20mm"));
1640 assert!(doc.contains("counter(page)"));
1641 assert!(doc.contains("content-container"));
1642 assert!(doc.contains("paged.js stub"));
1643 }
1644
1645 #[test]
1646 fn smoke_paged_multi_column_css_injected() {
1647 let opts = PageViewOptions {
1648 paged_js_source: "/* paged.js stub */",
1649 paper: "A4",
1650 orientation: "portrait",
1651 margin_mm: 20,
1652 show_page_numbers: false,
1653 wheel_js: "",
1654 columns_per_row: 2,
1655 for_export: false,
1656 title: "",
1657 standalone_export: false,
1658 };
1659 let doc = wrap_preview_html_document_paged("<p>Test</p>", "", "light", None, &opts);
1660 assert!(
1662 doc.contains("flex-direction: row"),
1663 "expected flex-direction: row for multi-column"
1664 );
1665 assert!(
1666 doc.contains("flex-wrap: wrap"),
1667 "expected flex-wrap: wrap for multi-column"
1668 );
1669 }
1670
1671 #[test]
1672 fn smoke_paged_single_column_no_multi_col_css() {
1673 let opts = PageViewOptions {
1674 paged_js_source: "/* paged.js stub */",
1675 paper: "A4",
1676 orientation: "portrait",
1677 margin_mm: 20,
1678 show_page_numbers: false,
1679 wheel_js: "",
1680 columns_per_row: 1,
1681 for_export: false,
1682 title: "",
1683 standalone_export: false,
1684 };
1685 let doc = wrap_preview_html_document_paged("<p>Test</p>", "", "light", None, &opts);
1686 assert!(
1688 !doc.contains("pages per row"),
1689 "single-column should not have multi-column override"
1690 );
1691 }
1692}