Skip to content

Commit 69be6f4

Browse files
committed
Add support for targeting specific cell outputs
1 parent 193428d commit 69be6f4

File tree

5 files changed

+119
-32
lines changed

5 files changed

+119
-32
lines changed

src/core/handlers/embed.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,13 @@ const kHandlers: EmbedHandler[] = [
4545
// Resolve the filename into a path
4646
const notebookAddress = parseNotebookAddress(filename);
4747
if (notebookAddress) {
48+
const outputs = params.outputs as string | undefined;
4849
const placeHolder = notebookMarkdownPlaceholder(filename, {
4950
echo: params.echo !== undefined ? params.echo as boolean : false,
5051
warning: false,
5152
asis: true,
5253
eval: false,
53-
});
54+
}, outputs);
5455

5556
markdownFragments.push(placeHolder);
5657
return Promise.resolve({
@@ -87,7 +88,7 @@ const embedHandler: LanguageHandler = {
8788
// The first parameter is a path to a file
8889
const filename = directive.shortcode.params[0];
8990
if (!filename) {
90-
throw new Error("Embed directive needs filename as a parameter");
91+
throw new Error("Embed directive requires a filename as a parameter");
9192
}
9293

9394
// Go through handlers until one handles the embed by returning markdown

src/core/handlers/include.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,10 @@ const includeHandler: LanguageHandler = {
5151

5252
const notebookAddress = parseNotebookAddress(filename);
5353
if (notebookAddress) {
54-
const placeHolder = notebookMarkdownPlaceholder(filename, {});
54+
const outputs = directive.shortcode.namedParams.outputs as
55+
| string
56+
| undefined;
57+
const placeHolder = notebookMarkdownPlaceholder(filename, {}, outputs);
5558
textFragments.push(placeHolder);
5659
} else {
5760
let includeSrc;

src/core/jupyter/jupyter-embed.ts

Lines changed: 87 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import {
1212
jupyterAssets,
1313
jupyterFromFile,
1414
jupyterToMarkdown,
15+
kQuartoOutputDisplay,
16+
kQuartoOutputOrder,
1517
} from "../jupyter/jupyter.ts";
1618

1719
import {
@@ -69,18 +71,13 @@ const kOutput = "output";
6971

7072
const kHashRegex = /(.*?)#(.*)/;
7173
const kIndexRegex = /(.*)\[([0-9,-]*)\]/;
72-
const kPlaceholderRegex = /<!-- 12A0366C:(.*?) \| (.*?) -->/;
74+
const kPlaceholderRegex = /<!-- 12A0366C:(.*?) \| (.*?) \| (.*?) -->/;
7375

7476
const kNotebookCache = "notebook-cache";
7577
const kRenderFileLifeTime = "render-file";
7678

77-
// notebook.ipynb#cellid1
78-
// notebook.ipynb#cellid1
79-
// notebook.ipynb#cellid1,cellid2,cellid3
80-
// notebook.ipynb[0]
81-
// notebook.ipynb[0,1]
82-
// notebook.ipynb[0-2]
83-
// notebook.ipynb[2,0-1]
79+
// Parses a notebook address string into a file path with
80+
// an optional list of ids or list of cell indexes.
8481
export function parseNotebookAddress(
8582
path: string,
8683
): JupyterNotebookAddress | undefined {
@@ -109,7 +106,7 @@ export function parseNotebookAddress(
109106
if (isNotebook(path)) {
110107
return {
111108
path,
112-
indexes: resolveCellRange(indexResult[2]),
109+
indexes: resolveRange(indexResult[2]),
113110
};
114111
} else {
115112
return undefined;
@@ -126,13 +123,22 @@ export function parseNotebookAddress(
126123
}
127124
}
128125

126+
// Creates a placeholder that will later be replaced with
127+
// rendered notebook markdown. Note that the placeholder
128+
// must stipulate all the information required to generate the
129+
// markdown (e.g. options, output indexes and so on)
129130
export function notebookMarkdownPlaceholder(
130131
path: string,
131132
options: JupyterMarkdownOptions,
133+
outputs?: string,
132134
) {
133-
return `<!-- 12A0366C:${path} | ${optionsToPlaceholder(options)} -->`;
135+
return `<!-- 12A0366C:${path} | ${outputs || ""} | ${
136+
optionsToPlaceholder(options)
137+
} -->`;
134138
}
135139

140+
// Replaces any notebook markdown placeholders with the
141+
// rendered contents.
136142
export async function replaceNotebookPlaceholders(
137143
to: string,
138144
input: string,
@@ -143,22 +149,29 @@ export async function replaceNotebookPlaceholders(
143149
let match = kPlaceholderRegex.exec(markdown);
144150
let includes;
145151
while (match) {
146-
// Find and parse the placeholders
147-
const nbPath = match[1];
148-
const optionPlaceholder = match[2];
149-
const nbOptions = optionPlaceholder
150-
? placeholderToOptions(optionPlaceholder)
151-
: {};
152-
153-
// Parse the address
154-
const nbAddress = parseNotebookAddress(nbPath);
152+
// Parse the address and if this is a notebook
153+
// then proceed with the replacement
154+
const nbAddressStr = match[1];
155+
const nbAddress = parseNotebookAddress(nbAddressStr);
155156
if (nbAddress) {
157+
// If a list of outputs are provided, resolve that range
158+
const outputsStr = match[2];
159+
const nbOutputs = outputsStr ? resolveRange(outputsStr) : undefined;
160+
161+
// If cell options are provided, resolve those
162+
const placeholderStr = match[3];
163+
const nbOptions = placeholderStr
164+
? placeholderToOptions(placeholderStr)
165+
: {};
166+
156167
// Assets
157168
const assets = jupyterAssets(
158169
input,
159170
to,
160171
);
161172

173+
// Compute appropriate includes based upon the note
174+
// dependendencies
162175
const notebookIncludes = () => {
163176
const notebook = jupyterFromFile(resolveNbPath(input, nbAddress.path));
164177
const dependencies = isHtmlOutput(context.format.pandoc)
@@ -183,12 +196,12 @@ export async function replaceNotebookPlaceholders(
183196
context,
184197
flags,
185198
nbOptions,
199+
nbOutputs,
186200
);
187201

188202
// Replace the placeholders with the rendered markdown
189203
markdown = markdown.replaceAll(match[0], nbMarkdown);
190204
}
191-
192205
match = kPlaceholderRegex.exec(markdown);
193206
}
194207
kPlaceholderRegex.lastIndex = 0;
@@ -206,12 +219,14 @@ function resolveNbPath(input: string, path: string) {
206219
}
207220
}
208221

222+
// Gets the markdown for a specific notebook and set of options
209223
async function notebookMarkdown(
210224
nbAddress: JupyterNotebookAddress,
211225
assets: JupyterAssets,
212226
context: RenderContext,
213227
flags: RenderFlags,
214228
options?: JupyterMarkdownOptions,
229+
outputs?: number[],
215230
) {
216231
// Get the cell outputs for this notebook
217232
const notebookInfo = await getCachedNotebookInfo(
@@ -220,6 +235,7 @@ async function notebookMarkdown(
220235
context,
221236
flags,
222237
options,
238+
outputs,
223239
);
224240

225241
// Wrap any injected cells with a div that includes a back link to
@@ -271,12 +287,17 @@ async function notebookMarkdown(
271287
}
272288
}
273289

290+
// Caches the notebook info for a a particular notebook and
291+
// set of options. Since the markdown is what is cached,
292+
// the cache will include options that control markdown output
293+
// when determining whether it can use cached contents.
274294
async function getCachedNotebookInfo(
275295
nbAddress: JupyterNotebookAddress,
276296
assets: JupyterAssets,
277297
context: RenderContext,
278298
flags: RenderFlags,
279299
options?: JupyterMarkdownOptions,
300+
outputs?: number[],
280301
) {
281302
// We can cache outputs on a per rendered file basis to
282303
// improve performance
@@ -293,7 +314,7 @@ async function getCachedNotebookInfo(
293314
};
294315

295316
// Compute a cache key
296-
const cacheKey = notebookCacheKey(nbAddress, options);
317+
const cacheKey = notebookCacheKey(nbAddress, options, outputs);
297318
if (!nbCache.cache[cacheKey]) {
298319
// Render the notebook and place it in the cache
299320
// Read and filter notebook
@@ -313,6 +334,23 @@ async function getCachedNotebookInfo(
313334
if (options.asis !== undefined) {
314335
cell.metadata[kOutput] = options.asis ? true : false;
315336
}
337+
338+
// Filter outputs if so desired
339+
if (outputs && cell.outputs) {
340+
cell.outputs = cell.outputs.map((output, index) => {
341+
const oneBasedIdx = index + 1;
342+
output.metadata = output.metadata || {};
343+
if (!outputs.includes(oneBasedIdx)) {
344+
output.metadata[kQuartoOutputDisplay] = false;
345+
} else {
346+
const explicitOrder = outputs.indexOf(oneBasedIdx);
347+
if (explicitOrder > -1) {
348+
output.metadata[kQuartoOutputOrder] = explicitOrder;
349+
}
350+
}
351+
return output;
352+
});
353+
}
316354
return cell;
317355
});
318356
}
@@ -363,6 +401,7 @@ async function getCachedNotebookInfo(
363401
return nbCache.cache[cacheKey];
364402
}
365403

404+
// Tries to find a title within a Notebook
366405
function findTitle(cells: JupyterCellOutput[]) {
367406
for (const cell of cells) {
368407
const partitioned = partitionMarkdown(cell.markdown);
@@ -375,9 +414,13 @@ function findTitle(cells: JupyterCellOutput[]) {
375414
return undefined;
376415
}
377416

417+
// Create a notebook hash key for the cache
418+
// that incorporates options that affect markdown
419+
// output
378420
function notebookCacheKey(
379421
nbAddress: JupyterNotebookAddress,
380422
nbOptions?: JupyterMarkdownOptions,
423+
nbOutputs?: number[],
381424
) {
382425
const optionsKey = nbOptions
383426
? Object.keys(nbOptions).reduce((key, current) => {
@@ -388,7 +431,13 @@ function notebookCacheKey(
388431
}
389432
}, "")
390433
: "";
391-
return optionsKey ? `${nbAddress.path}-${optionsKey}` : nbAddress.path;
434+
435+
const coreKey = optionsKey
436+
? `${nbAddress.path}-${optionsKey}`
437+
: nbAddress.path;
438+
439+
const outputsKey = nbOutputs ? nbOutputs.join(",") : "";
440+
return `${coreKey}:${outputsKey}`;
392441
}
393442

394443
function optionsToPlaceholder(options: JupyterMarkdownOptions) {
@@ -413,6 +462,9 @@ function placeholderToOptions(placeholder: string) {
413462
return options;
414463
}
415464

465+
// Finds a cell matching an id. It will first try an explicit
466+
// matching cell Id, then a cell label (in the code chunk front matter),
467+
// and otherwise look for a cell(s) with that tag
416468
function cellForId(id: string, cells: JupyterCellOutput[]) {
417469
for (const cell of cells) {
418470
// cellId can either by a literal cell Id, or a tag with that value
@@ -442,6 +494,10 @@ function cellForId(id: string, cells: JupyterCellOutput[]) {
442494
}
443495
}
444496

497+
// Parses a string into one or more cellids.
498+
// Syntax like:
499+
// notebook.ipynb#cellid1
500+
// notebook.ipynb#cellid1,cellid2,cellid3
445501
function resolveCellIds(hash?: string) {
446502
if (hash && hash.indexOf(",") > 0) {
447503
return hash.split(",");
@@ -452,7 +508,15 @@ function resolveCellIds(hash?: string) {
452508
}
453509
}
454510

455-
function resolveCellRange(rangeRaw?: string) {
511+
// Parses a string with one more numbers or ranges into
512+
// a list of numbers, in order.
513+
// Syntax like:
514+
// notebook.ipynb[0]
515+
// notebook.ipynb[0,1]
516+
// notebook.ipynb[0-2]
517+
// notebook.ipynb[2,0-1]
518+
// notebook.ipynb[2,6-4]
519+
function resolveRange(rangeRaw?: string) {
456520
if (rangeRaw) {
457521
const result: number[] = [];
458522

src/core/jupyter/jupyter.ts

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -152,10 +152,13 @@ import { mergeConfigs } from "../config.ts";
152152
import { encode as encodeBase64 } from "encoding/base64.ts";
153153

154154
export const kQuartoMimeType = "quarto_mimetype";
155+
export const kQuartoOutputOrder = "quarto_order";
156+
export const kQuartoOutputDisplay = "quarto_display";
155157

156158
export const kJupyterNotebookExtensions = [
157159
".ipynb",
158160
];
161+
159162
export function isJupyterNotebook(file: string) {
160163
return kJupyterNotebookExtensions.includes(extname(file).toLowerCase());
161164
}
@@ -1252,17 +1255,32 @@ async function mdFromCodeCell(
12521255
const outputName = pandocAutoIdentifier(labelName, true) + "-output";
12531256

12541257
let nextOutputSuffix = 1;
1255-
for (
1256-
const { index, output } of outputs.map((value, index) => ({
1257-
index,
1258-
output: value,
1259-
}))
1260-
) {
1258+
const sortedOutputs = outputs.map((value, index) => ({
1259+
index,
1260+
output: value,
1261+
})).sort((a, b) => {
1262+
// Sort any explicitly ordered cells
1263+
const aIdx = a.output.metadata?.[kQuartoOutputOrder] !== undefined
1264+
? a.output.metadata?.[kQuartoOutputOrder] as number
1265+
: Number.MAX_SAFE_INTEGER;
1266+
const bIdx = b.output.metadata?.[kQuartoOutputOrder] !== undefined
1267+
? b.output.metadata?.[kQuartoOutputOrder] as number
1268+
: Number.MAX_SAFE_INTEGER;
1269+
return aIdx - bIdx;
1270+
});
1271+
1272+
for (const { index, output } of sortedOutputs) {
12611273
// compute output label
12621274
const outputLabel = label && labelCellContainer && isDisplayData(output)
12631275
? (label + "-" + nextOutputSuffix++)
12641276
: label;
12651277

1278+
// If this output has been marked to not be displayed
1279+
// just continue
1280+
if (output.metadata?.[kQuartoOutputDisplay] === false) {
1281+
continue;
1282+
}
1283+
12661284
// leading newline and beginning of div
12671285
if (!asis) {
12681286
md.push("\n::: {");

src/core/jupyter/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ export interface JupyterOutput {
143143
output_type: "stream" | "display_data" | "execute_result" | "error";
144144
execution_count?: null | number;
145145
isolated?: boolean;
146+
metadata?: Record<string, unknown>;
146147
}
147148

148149
export interface JupyterOutputStream extends JupyterOutput {

0 commit comments

Comments
 (0)