Skip to content

Commit 29e4ace

Browse files
cscheidclaude
andauthored
Printing/PDF export from the quarto-hub preview (issue #315) (#379)
* feat(preview): add makeSelfContainedHtml inliner for printable export (issue #315) Phase 1 of bd-vhdknrvl. Adds a pure, DI-based transform that turns the WASM HTML-pipeline output (standalone document, but with /.quarto/… <link>/<script> refs and relative <img> srcs) into a single self-contained document suitable for opening as a bare top-level tab: - CSS <link> → inline <style> (with url() font/image refs → data: URIs) - <script src> → executing inline <script> (reveal.js must run) - <img src> (relative + /.quarto) → base64 data: URIs VFS access is injected (SelfContainedReaders) so it is decoupled from the WASM singleton; production binding is deferred to the Phase 3 glue. Adds web-font MIME types to the shared vfsPaths.guessMimeType. Tested via 9 jsdom cases; full preview-renderer suite stays green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(wasm): add render_printable export for printable document export (issue #315) Phase 2 of bd-vhdknrvl. Adds a path-aware WASM render that produces the standalone printable form of an in-scope preview document: - coerce_format_for_print: inverse of map_format_for_preview (q2-preview→html, q2-slides→revealjs, q2-debug/q2-raw→html, else passthrough) so the HTML pipeline (and reveal deck assembler) runs. - render_single_doc_to_response gains a format_override param (existing 4 callers pass None); render_printable(path) reads the VFS file, coerces the detected format, and renders through the HTML branch with the real path so relative image srcs resolve against the doc dir. - renderPrintable wrapper + type decls in preview-runtime. The output still links /.quarto/ artifacts; the JS makeSelfContainedHtml inliner (Phase 1) embeds them before the doc opens in a top-level tab. Load-bearing render contract pinned natively in a new quarto-core integration test (the WASM crate is cdylib-only): a q2-preview-frontmatter doc renders to full HTML with its relative image src preserved, and a revealjs doc renders to a standalone deck. WASM rebuilt and verified. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(wasm): sync Cargo.lock (quarto-util edge) after wasm build Lockfile catch-up materialized by the WASM build; unrelated to the render_printable change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(hub-client): "Open printable version" button for preview (issue #315) Phases 3-4 of bd-vhdknrvl. Wires the printable-export end to end and exposes it in the preview UI: - forceRevealPrintMode (preview-renderer): inject view:"print" into a deck's own Reveal.initialize config, so reveal enters its paginated PDF layout when opened from a blob: URL (whose query string is not reliably exposed via location.search). - printableDocument service (hub-client): renderPrintable → makeSelfContainedHtml (readers bound to the WASM VFS) → forceRevealPrintMode for slides → Blob URL → window.open in a new top-level tab, with pop-up-block detection and delayed revoke. - Floating 🖨 button in the preview pane (Editor.tsx/.css), shown for q2-preview / q2-slides / revealjs, with a dismissible error toast. Pure logic unit-tested (revealPrintMode, isPrintableSlidesFormat, buildPrintableHtml throw path). hub-client production build (tsc -b && vite build) passes. Browser E2E still pending. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(hub-client): changelog entry for printable-version button (issue #315) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(hub-client): end-to-end WASM test for printable export (issue #315) Drives the real render_printable WASM export against a VFS project and runs makeSelfContainedHtml + forceRevealPrintMode over the output — the production pipeline minus window.open. Verifies a q2-preview doc renders to full HTML (image src preserved) and inlines to a self-contained document (no /.quarto refs, image as data URI), and a revealjs doc renders to a standalone deck forced into reveal print layout. Both artifacts were also opened in Chrome and visually confirmed: the doc renders themed/self-contained; the deck shows reveal's print-pdf stacked-slide layout. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(preview): print-quality stylesheet for printable documents (issue #315) Phase 5 of bd-vhdknrvl. q2's HTML template inlines only the theme's @media print rules and omits pandoc's default print partial, so a printable document lacked heading break-avoidance, orphan/widow control, and figure/code/table break-inside protection. injectPrintStylesheet appends a conservative @media print block (before </head>) covering those basics plus @page margins and a white-background reset. Wired into buildPrintableHtml's document branch; reveal decks skip it (they ship their own precise per-slide print CSS). Injected printable-only in the JS glue, so q2 render and the live preview are untouched. The E2E wasm test now drives the full buildPrintableHtml glue and asserts data-q2-print present for docs / absent for decks. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(plan): update issue #315 plan — phases 0-5 done, phase 6 status Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(preview): strip BOM when inlining CSS/JS so themes apply in printable export (issue #315) Field bug: the printable document opened with no theme styling in both Firefox and Chrome. Root cause found by reproducing the exact browser pipeline: the compiled Bootstrap theme CSS artifact carries a leading UTF-8 BOM (U+FEFF). A <link> load strips it from the byte stream, but makeSelfContainedHtml injected the bytes verbatim into a <style>, where the BOM prefixes the first selector (`:root,[data-bs-theme=light]`) — an invalid selector in a comma list drops the WHOLE rule, so Bootstrap's `--bs-*` variables were never defined and the theme silently fell back to unstyled serif defaults. Fix: strip a leading BOM from inlined CSS and JS text. Verified end-to-end in a real browser (Chrome via CDP): stripping the BOM makes `--bs-body-font-family` resolve and the cosmo theme render correctly. Tests: 2 unit cases (BOM strip for CSS + JS) in makeSelfContainedHtml, and a themed real-WASM regression in printableDocument.wasm.test.ts that fails without the fix (asserts no inlined <style> begins with a BOM). This was missed originally because the first E2E fixture used a minimal themeless doc with no BOM-prefixed theme CSS to inline. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: changelog + plan for BOM theme fix (issue #315) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(plan): record revealjs deck browser verification (issue #315) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(hub-client): move "Open printable version" button to the Files panel (issue #315) Relocates the 🖨 print affordance out of the floating preview-pane overlay into the Files sidebar header (next to New / Upload), per user request. The entire printable concern (button, in-flight + error state, handler) now lives in FileSidebar, driven by a new optional `currentFormat` prop; Editor just threads that prop through. Removes the preview-pane button, error toast, and associated CSS. Adds FileSidebar integration tests: the button is shown only for printable formats (q2-preview / q2-slides / revealjs), hidden otherwise, and clicking opens the printable document for the current file. Verified in an isolated browser mount: "🖨 Print" renders in the Files header matching the New/Upload button style. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(hub-client): equalize Files-panel button sizes (issue #315) Adding the Print button made three buttons compete for the narrow sidebar header; flex-shrink squeezed New/Upload below their content width so their labels wrapped to a second line, making them taller than the single-line Print button. Give all header buttons white-space:nowrap + flex-shrink:0 and a fixed line-height (so an emoji's taller line box doesn't inflate one button), and let the header flex-wrap so whole buttons drop to a second row when the width is too small — instead of one button going two-line. All three are now the same height (28px); verified at 250px (one row) and 190px (Upload wraps as a whole button). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 09a0382 commit 29e4ace

22 files changed

Lines changed: 1737 additions & 6 deletions

claude-notes/plans/2026-07-06-issue-315-preview-printing.md

Lines changed: 420 additions & 0 deletions
Large diffs are not rendered by default.

crates/quarto-core/tests/integration/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ pub mod navigation_e2e;
3434
pub mod navigation_merge;
3535
pub mod page_navigation_pipeline;
3636
pub mod preview_render_css_parity;
37+
pub mod printable_render;
3738
pub mod project_pipeline;
3839
pub mod project_resources;
3940
pub mod render_page_in_project;
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
/*
2+
* tests/integration/printable_render.rs
3+
* Copyright (c) 2026 Posit, PBC
4+
*
5+
* bd-vhdknrvl (issue #315): the "open printable version" feature renders
6+
* the current preview document through the HTML pipeline so it can be
7+
* opened as a self-contained, paginatable top-level tab.
8+
*/
9+
10+
//! Render-level contract for the printable-export feature.
11+
//!
12+
//! The WASM `render_printable(path)` export coerces the document's
13+
//! *preview* format to its HTML-output equivalent (the inverse of
14+
//! `map_format_for_preview`): `q2-preview → html`, `q2-slides →
15+
//! revealjs`. It then renders through the HTML pipeline
16+
//! (`render_qmd_to_html`) with the document's real path.
17+
//!
18+
//! These tests pin the two load-bearing assumptions of that coercion,
19+
//! natively and in-process (the WASM crate itself is `cdylib`-only and
20+
//! not native-testable):
21+
//!
22+
//! 1. A document whose frontmatter literally says `format: q2-preview`
23+
//! still renders to a **full HTML document** when driven with an
24+
//! HTML [`Format`], and a relative user image's `src` survives
25+
//! verbatim (so the JS `makeSelfContainedHtml` inliner can resolve
26+
//! and inline it).
27+
//! 2. A `format: revealjs` document renders to a **standalone reveal
28+
//! deck** (the `reveal`/`slides` shell) — i.e. the coercion target
29+
//! `q2-slides → revealjs` reaches the deck assembler, not a bare
30+
//! HTML page.
31+
32+
use std::path::PathBuf;
33+
use std::sync::Arc;
34+
35+
use quarto_core::format::Format;
36+
use quarto_core::pipeline::{HtmlRenderConfig, render_qmd_to_html};
37+
use quarto_core::project::{DocumentInfo, ProjectContext};
38+
use quarto_core::render::{BinaryDependencies, RenderContext};
39+
use quarto_system_runtime::{NativeRuntime, SystemRuntime};
40+
41+
fn test_project() -> ProjectContext {
42+
ProjectContext {
43+
dir: PathBuf::from("/project"),
44+
config: quarto_core::project::ProjectConfig::default(),
45+
is_single_file: true,
46+
files: vec![DocumentInfo::from_path("/project/test.qmd")],
47+
output_dir: PathBuf::from("/project"),
48+
}
49+
}
50+
51+
fn fresh_runtime() -> Arc<dyn SystemRuntime> {
52+
Arc::new(NativeRuntime::new())
53+
}
54+
55+
/// Render `content` through the HTML pipeline with `format`, returning
56+
/// the full HTML string — mirroring what `render_printable` does after
57+
/// coercing the preview format to its HTML-output equivalent.
58+
fn render_html(content: &[u8], format: Format) -> String {
59+
let project = test_project();
60+
let doc = DocumentInfo::from_path("/project/test.qmd");
61+
let binaries = BinaryDependencies::new();
62+
let mut ctx = RenderContext::new(&project, &doc, &format, &binaries);
63+
pollster::block_on(render_qmd_to_html(
64+
content,
65+
"test.qmd",
66+
&mut ctx,
67+
&HtmlRenderConfig::default(),
68+
fresh_runtime(),
69+
))
70+
.expect("html pipeline render")
71+
.html
72+
}
73+
74+
#[test]
75+
fn q2_preview_frontmatter_renders_as_html_with_image_preserved() {
76+
// A document authored with the preview pseudo-format, referencing a
77+
// relative image in a subdirectory. `render_printable` coerces
78+
// `q2-preview → html`; here we drive `render_qmd_to_html` with the
79+
// coerced HTML format directly.
80+
let content = br#"---
81+
title: "Printable Doc"
82+
format: q2-preview
83+
---
84+
85+
# Heading
86+
87+
Some text and an image.
88+
89+
![A plot](figures/plot.png)
90+
91+
More text after the image.
92+
"#;
93+
94+
let html = render_html(content, Format::html());
95+
96+
// A full HTML document (not preview AST JSON).
97+
assert!(
98+
html.contains("<body") && html.contains("</html>"),
99+
"expected a full HTML document, got:\n{html}"
100+
);
101+
// Document content is present.
102+
assert!(html.contains("Heading"), "heading missing:\n{html}");
103+
// The relative image src is preserved verbatim so the JS inliner can
104+
// resolve and inline it against the document directory.
105+
assert!(
106+
html.contains(r#"src="figures/plot.png""#),
107+
"relative image src not preserved:\n{html}"
108+
);
109+
}
110+
111+
#[test]
112+
fn revealjs_renders_standalone_deck() {
113+
// The coercion target for slides is `q2-slides → revealjs`, which
114+
// must reach the reveal deck assembler.
115+
let content = br#"---
116+
title: "Printable Deck"
117+
format: revealjs
118+
---
119+
120+
## Slide One
121+
122+
Hello.
123+
124+
## Slide Two
125+
126+
- a
127+
- b
128+
"#;
129+
130+
let html = render_html(
131+
content,
132+
Format::from_format_string("revealjs").expect("revealjs format"),
133+
);
134+
135+
assert!(
136+
html.contains(r#"class="reveal"#),
137+
"expected a reveal deck shell, got:\n{html}"
138+
);
139+
assert!(
140+
html.contains(r#"class="slides"#),
141+
"expected the reveal slides container, got:\n{html}"
142+
);
143+
}

crates/wasm-quarto-hub-client/Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/wasm-quarto-hub-client/src/lib.rs

Lines changed: 88 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -673,6 +673,24 @@ fn map_format_for_preview(format_str: &str) -> &str {
673673
}
674674
}
675675

676+
/// Coerce a *preview* pseudo-format to the HTML-output format its
677+
/// printable version should render as — the inverse of
678+
/// [`map_format_for_preview`], used by [`render_printable`].
679+
///
680+
/// `q2-preview → html` (a normal HTML page) and `q2-slides → revealjs`
681+
/// (so the reveal deck **assembler** runs — note this is *not*
682+
/// `builtin_pseudo_format`'s `html` base, which would drop the deck).
683+
/// The developer views `q2-debug`/`q2-raw` fall back to `html`.
684+
/// Non-preview formats (including `revealjs` and `html` themselves)
685+
/// pass through unchanged so they render via their own HTML pipeline.
686+
fn coerce_format_for_print(format_str: &str) -> &str {
687+
match format_str {
688+
"q2-preview" | "q2-debug" | "q2-raw" => "html",
689+
"q2-slides" => "revealjs",
690+
other => other,
691+
}
692+
}
693+
676694
/// Detect the format string from QMD content's YAML frontmatter.
677695
/// Returns the format name (e.g., "q2-slides", "q2-debug", "html", "acm-html")
678696
/// or "html" as default when no format key is present.
@@ -1000,6 +1018,59 @@ pub async fn render_qmd(path: &str, user_grammars: Option<JsUserGrammars>) -> St
10001018
false,
10011019
Vec::new(),
10021020
None,
1021+
None,
1022+
)
1023+
.await
1024+
}
1025+
1026+
/// Render the **printable** form of a document: a standalone HTML page
1027+
/// (or reveal deck) suitable for opening as a bare top-level browser tab
1028+
/// and printing (issue #315, bd-vhdknrvl).
1029+
///
1030+
/// The live preview renders `format: q2-preview` / `format: q2-slides`
1031+
/// through the React/AST pipeline (no standalone document to print).
1032+
/// This export instead **coerces the preview format to its HTML-output
1033+
/// equivalent** (`q2-preview → html`, `q2-slides → revealjs`; see
1034+
/// [`coerce_format_for_print`]) and renders through the HTML pipeline
1035+
/// **with the document's real path**, so relative image `src`s resolve
1036+
/// against the document directory (they are preserved verbatim in the
1037+
/// output for the JS-side `makeSelfContainedHtml` inliner to embed).
1038+
///
1039+
/// The returned document still references its generated artifacts
1040+
/// (theme CSS, reveal.js, fonts) as `/.quarto/project-artifacts/…` VFS
1041+
/// URLs; the caller runs `makeSelfContainedHtml` to inline them before
1042+
/// opening the tab.
1043+
///
1044+
/// # Returns
1045+
/// JSON: `{ "success": true, "html": "...", "is_slides": <bool>, ... }`
1046+
/// or `{ "success": false, "error": "...", "diagnostics": [...] }`.
1047+
#[wasm_bindgen]
1048+
pub async fn render_printable(path: &str, user_grammars: Option<JsUserGrammars>) -> String {
1049+
let runtime = get_runtime();
1050+
let path = Path::new(path);
1051+
1052+
let content = match runtime.file_read(path) {
1053+
Ok(bytes) => bytes,
1054+
Err(e) => return error_response(format!("Failed to read file: {}", e)),
1055+
};
1056+
1057+
let project = match ProjectContext::discover(path, runtime) {
1058+
Ok(p) => p,
1059+
Err(e) => return error_response(format!("Failed to discover project context: {}", e)),
1060+
};
1061+
1062+
let detected = detect_format_from_content(std::str::from_utf8(&content).unwrap_or(""));
1063+
let printable_format = coerce_format_for_print(&detected).to_string();
1064+
1065+
render_single_doc_to_response(
1066+
path,
1067+
&content,
1068+
&project,
1069+
user_grammars,
1070+
false,
1071+
Vec::new(),
1072+
None,
1073+
Some(&printable_format),
10031074
)
10041075
.await
10051076
}
@@ -1032,6 +1103,7 @@ pub async fn render_qmd_content(
10321103
false,
10331104
Vec::new(),
10341105
None,
1106+
None,
10351107
)
10361108
.await
10371109
}
@@ -1164,6 +1236,7 @@ pub async fn render_page_in_project_with_attribution(
11641236
false,
11651237
captures,
11661238
attribution_json,
1239+
None,
11671240
)
11681241
.await;
11691242
}
@@ -1257,6 +1330,7 @@ pub async fn render_page_for_preview(
12571330
true,
12581331
captures,
12591332
None,
1333+
None,
12601334
)
12611335
.await;
12621336
}
@@ -1352,16 +1426,26 @@ async fn render_single_doc_to_response(
13521426
// `q2 render` natively runs engines instead).
13531427
captures: Vec<quarto_trace::EngineCapture>,
13541428
attribution_json: Option<String>,
1429+
// When `Some`, use this format string verbatim instead of detecting
1430+
// it from the content (and instead of `prefer_preview_format`
1431+
// mapping). Used by `render_printable`, which coerces a preview
1432+
// pseudo-format to its HTML-output equivalent so the HTML branch
1433+
// runs. `None` preserves the detect-and-maybe-map behavior.
1434+
format_override: Option<&str>,
13551435
) -> String {
13561436
let doc = DocumentInfo::from_path(path);
13571437
let binaries = BinaryDependencies::new();
13581438

13591439
let content_str = std::str::from_utf8(content).unwrap_or("");
1360-
let detected = detect_format_from_content(content_str);
1361-
let format_str = if prefer_preview_format {
1362-
map_format_for_preview(&detected).to_string()
1440+
let format_str = if let Some(ov) = format_override {
1441+
ov.to_string()
13631442
} else {
1364-
detected
1443+
let detected = detect_format_from_content(content_str);
1444+
if prefer_preview_format {
1445+
map_format_for_preview(&detected).to_string()
1446+
} else {
1447+
detected
1448+
}
13651449
};
13661450
let format = match Format::from_format_string(&format_str) {
13671451
Ok(f) => f,

hub-client/changelog.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ be in reverse chronological order (latest first).
1717

1818
### 2026-07-06
1919

20+
- [`5aa3ee0a`](https://github.com/quarto-dev/q2/commits/5aa3ee0a): Added an "Open printable version" (🖨 Print) button to the Files panel — it opens a standalone, self-contained copy of the current document (or slide deck) in a new tab so you can Print / Save-as-PDF with correct pagination, working around the broken in-frame print behavior.
2021
- [`550aaeb8`](https://github.com/quarto-dev/q2/commits/550aaeb8): Fixed a rich-text editor bug where committing with Cmd/Ctrl+Enter while text was selected (for example, select-all then bold) could delete the block's content.
2122

2223
### 2026-07-01

hub-client/src/components/Editor.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1000,6 +1000,7 @@ export default function Editor({ project, files, fileContents, onDisconnect, onC
10001000
onRenameFile={handleRenameFile}
10011001
onOpenInNewTab={handleOpenInNewTab}
10021002
onCopyLink={handleCopyLink}
1003+
currentFormat={currentFormat}
10031004
searchFiles={searchFiles}
10041005
fileContents={fileContents}
10051006
/>

hub-client/src/components/FileSidebar.css

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,29 +15,69 @@
1515
display: flex;
1616
align-items: center;
1717
justify-content: flex-end;
18+
flex-wrap: wrap;
1819
gap: 6px;
1920
padding: 8px 12px;
2021
border-bottom: 1px solid var(--sidebar-border);
2122
}
2223

2324
.sidebar-header .new-file-btn,
24-
.sidebar-header .upload-asset-btn {
25+
.sidebar-header .upload-asset-btn,
26+
.sidebar-header .print-file-btn {
2527
padding: 4px 10px;
2628
background: var(--editor-accent-bg);
2729
border: 1px solid var(--editor-accent-border);
2830
border-radius: 4px;
2931
color: var(--editor-text-muted);
3032
font-size: 12px;
33+
/* Fixed line-height so an emoji glyph's taller line box (e.g. ⬆)
34+
doesn't make one button taller than the others. */
35+
line-height: 1.5;
3136
cursor: pointer;
3237
transition: all 0.2s;
38+
/* Keep every button a single line so all three are the same height;
39+
when the header is too narrow for all of them, they wrap as whole
40+
buttons to a second row rather than one squeezing to two lines. */
41+
white-space: nowrap;
42+
flex-shrink: 0;
3343
}
3444

3545
.sidebar-header .new-file-btn:hover,
36-
.sidebar-header .upload-asset-btn:hover {
46+
.sidebar-header .upload-asset-btn:hover,
47+
.sidebar-header .print-file-btn:hover:not(:disabled) {
3748
background: var(--editor-accent-border);
3849
color: var(--editor-text);
3950
}
4051

52+
.sidebar-header .print-file-btn:disabled {
53+
cursor: default;
54+
opacity: 0.6;
55+
}
56+
57+
.sidebar-printable-error {
58+
position: relative;
59+
margin: 6px 12px;
60+
padding: 8px 26px 8px 10px;
61+
background: #b3261e;
62+
color: #fff;
63+
font-size: 12px;
64+
line-height: 1.35;
65+
border-radius: 4px;
66+
}
67+
68+
.sidebar-printable-error-dismiss {
69+
position: absolute;
70+
top: 4px;
71+
right: 4px;
72+
background: transparent;
73+
border: none;
74+
color: #fff;
75+
font-size: 12px;
76+
cursor: pointer;
77+
padding: 2px 4px;
78+
line-height: 1;
79+
}
80+
4181
.file-list {
4282
flex: 1;
4383
overflow-y: auto;

0 commit comments

Comments
 (0)