Skip to content

Commit

Permalink
Merge pull request #5850 from saulshanabrook/print-notebook
Browse files Browse the repository at this point in the history
Add support for printing widgets
  • Loading branch information
ian-r-rose committed May 13, 2019
2 parents 8a5a4f5 + 208aa83 commit 3670ef1
Show file tree
Hide file tree
Showing 17 changed files with 333 additions and 24 deletions.
6 changes: 5 additions & 1 deletion buildutils/src/utils.ts
Expand Up @@ -54,7 +54,11 @@ export function writePackageData(pkgJsonPath: string, data: any): boolean {
* Read a json file.
*/
export function readJSONFile(filePath: string): any {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
try {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
} catch (e) {
throw `Cannot read JSON for path ${filePath}: ${e}`;
}
}

/**
Expand Down
14 changes: 14 additions & 0 deletions packages/apputils-extension/schema/print.json
@@ -0,0 +1,14 @@
{
"title": "Print",
"description": "Print settings.",
"jupyter.lab.shortcuts": [
{
"command": "apputils:print",
"keys": ["Accel P"],
"selector": "body"
}
],
"additionalProperties": false,
"properties": {},
"type": "object"
}
29 changes: 27 additions & 2 deletions packages/apputils-extension/src/index.ts
Expand Up @@ -17,7 +17,8 @@ import {
IThemeManager,
IWindowResolver,
ThemeManager,
WindowResolver
WindowResolver,
Printing
} from '@jupyterlab/apputils';

import {
Expand Down Expand Up @@ -69,6 +70,8 @@ namespace CommandIDs {
export const resetOnLoad = 'apputils:reset-on-load';

export const saveState = 'apputils:save-statedb';

export const print = 'apputils:print';
}

/**
Expand Down Expand Up @@ -298,6 +301,27 @@ const splash: JupyterFrontEndPlugin<ISplashScreen> = {
}
};

const print: JupyterFrontEndPlugin<void> = {
id: '@jupyterlab/apputils-extension:print',
autoStart: true,
activate: (app: JupyterFrontEnd) => {
app.commands.addCommand(CommandIDs.print, {
label: 'Print...',
isEnabled: () => {
const widget = app.shell.currentWidget;
return Printing.getPrintFunction(widget) !== null;
},
execute: async () => {
const widget = app.shell.currentWidget;
const printFunction = Printing.getPrintFunction(widget);
if (printFunction) {
await printFunction();
}
}
});
}
};

/**
* The default state database for storing application state.
*/
Expand Down Expand Up @@ -567,7 +591,8 @@ const plugins: JupyterFrontEndPlugin<any>[] = [
state,
splash,
themes,
themesPaletteMenu
themesPaletteMenu,
print
];
export default plugins;

Expand Down
1 change: 1 addition & 0 deletions packages/apputils/src/index.ts
Expand Up @@ -15,6 +15,7 @@ export * from './iframe';
export * from './inputdialog';
export * from './instancetracker';
export * from './mainareawidget';
export * from './printing';
export * from './sanitizer';
export * from './spinner';
export * from './splash';
Expand Down
12 changes: 11 additions & 1 deletion packages/apputils/src/mainareawidget.ts
Expand Up @@ -11,6 +11,8 @@ import { Toolbar } from './toolbar';

import { DOMUtils } from './domutils';

import { Printing } from './printing';

/**
* A widget meant to be contained in the JupyterLab main area.
*
Expand All @@ -20,7 +22,8 @@ import { DOMUtils } from './domutils';
* This widget is automatically disposed when closed.
* This widget ensures its own focus when activated.
*/
export class MainAreaWidget<T extends Widget = Widget> extends Widget {
export class MainAreaWidget<T extends Widget = Widget> extends Widget
implements Printing.IPrintable {
/**
* Construct a new main area widget.
*
Expand Down Expand Up @@ -96,6 +99,13 @@ export class MainAreaWidget<T extends Widget = Widget> extends Widget {
}
}

/**
* Print method. Defered to content.
*/
[Printing.symbol](): Printing.OptionalAsyncThunk {
return Printing.getPrintFunction(this._content);
}

/**
* The content hosted by the widget.
*/
Expand Down
178 changes: 178 additions & 0 deletions packages/apputils/src/printing.ts
@@ -0,0 +1,178 @@
/**
* Any object is "printable" if it implements the `IPrintable` interface.
*
* To do this it, it must have a method called `Printing.symbol` which returns either a function
* to print the object or null if it cannot be printed.
*
* One way of printing is to use the `printWidget` function, which creates a hidden iframe
* and copies the DOM nodes from your widget to that iframe and printing just that iframe.
*
* Another way to print is to use the `printURL` function, which takes a URL and prints that page.
*/

import { Widget } from '@phosphor/widgets';
import { ServerConnection } from '@jupyterlab/services';

export namespace Printing {
/**
* Function that takes no arguments and when invoked prints out some object or null if printing is not defined.
*/
export type OptionalAsyncThunk = () => Promise<void> | null;

/**
* Symbol to use for a method that returns a function to print an object.
*/
export const symbol = Symbol('printable');

/**
* Objects who provide a custom way of printing themselves
* should implement this interface.
*/
export interface IPrintable {
/**
* Returns a function to print this object or null if it cannot be printed.
*/
[symbol]: () => OptionalAsyncThunk;
}

/**
* Returns whether an object implements a print method.
*/
export function isPrintable(a: unknown): a is IPrintable {
if (typeof a !== 'object' || !a) {
return false;
}
return symbol in a;
}

/**
* Returns the print function for an object, or null if it does not provide a handler.
*/
export function getPrintFunction(val: unknown): OptionalAsyncThunk {
if (isPrintable(val)) {
return val[symbol]();
}
return null;
}

/**
* Prints a widget by copying it's DOM node
* to a hidden iframe and printing that iframe.
*/
export function printWidget(widget: Widget): Promise<void> {
return printContent(widget.node);
}

const settings = ServerConnection.makeSettings();
/**
* Prints a URL by loading it into an iframe.
*
* @param url URL to load into an iframe.
*/
export async function printURL(url: string): Promise<void> {
const text = await (await ServerConnection.makeRequest(
url,
{},
settings
)).text();
return printContent(text);
}

/**
* Prints a URL or an element in an iframe and then removes the iframe after printing.
*/
async function printContent(textOrEl: string | HTMLElement): Promise<void> {
const isText = typeof textOrEl === 'string';
const iframe = createIFrame();

const parent = window.document.body;
parent.appendChild(iframe);
if (isText) {
iframe.srcdoc = textOrEl as string;
await resolveWhenLoaded(iframe);
} else {
iframe.src = 'about:blank';
setIFrameNode(iframe, textOrEl as HTMLElement);
}
const printed = resolveAfterEvent();
launchPrint(iframe.contentWindow);
// Once the print dialog has been dismissed, we regain event handling,
// and it should be safe to discard the hidden iframe.
await printed;
parent.removeChild(iframe);
}

/**
* Creates a new hidden iframe and appends it to the document
*
* Modified from
* https://github.com/joseluisq/printd/blob/eb7948d602583c055ab6dee3ee294b6a421da4b6/src/index.ts#L24
*/
function createIFrame(): HTMLIFrameElement {
const el = window.document.createElement('iframe');

// We need both allow-modals and allow-same-origin to be able to
// call print in the iframe.
// We intentionally do not allow scripts:
// https://github.com/jupyterlab/jupyterlab/pull/5850#pullrequestreview-230899790
el.setAttribute('sandbox', 'allow-modals allow-same-origin');
const css =
'visibility:hidden;width:0;height:0;position:absolute;z-index:-9999;bottom:0;';
el.setAttribute('style', css);
el.setAttribute('width', '0');
el.setAttribute('height', '0');

return el;
}

/**
* Copies a node from the base document to the iframe.
*/
function setIFrameNode(iframe: HTMLIFrameElement, node: HTMLElement) {
iframe.contentDocument.body.appendChild(node.cloneNode(true));
iframe.contentDocument.close();
}

/**
* Promise that resolves when all resources are loaded in the window.
*/
function resolveWhenLoaded(iframe: HTMLIFrameElement): Promise<void> {
return new Promise(resolve => {
iframe.onload = () => resolve();
});
}

/**
* A promise that resolves after the next mousedown, mousemove, or
* keydown event. We use this as a proxy for determining when the
* main window has regained control after the print dialog is removed.
*
* We can't use the usual window.onafterprint handler because we
* disallow Javascript execution in the print iframe.
*/
function resolveAfterEvent(): Promise<void> {
return new Promise(resolve => {
const onEvent = () => {
document.removeEventListener('mousemove', onEvent, true);
document.removeEventListener('mousedown', onEvent, true);
document.removeEventListener('keydown', onEvent, true);
resolve();
};
document.addEventListener('mousemove', onEvent, true);
document.addEventListener('mousedown', onEvent, true);
document.addEventListener('keydown', onEvent, true);
});
}

/**
* Prints a content window.
*/
function launchPrint(contentWindow: Window) {
const result = contentWindow.document.execCommand('print', false, null);
// execCommand won't work in firefox so we call the `print` method instead if it fails
// https://github.com/joseluisq/printd/blob/eb7948d602583c055ab6dee3ee294b6a421da4b6/src/index.ts#L148
if (!result) {
contentWindow.print();
}
}
}
21 changes: 21 additions & 0 deletions packages/coreutils/src/pageconfig.ts
Expand Up @@ -154,6 +154,27 @@ export namespace PageConfig {
return URLExt.normalize(wsUrl);
}

/**
* Returns the URL converting this notebook to a certain
* format with nbconvert.
*/
export function getNBConvertURL({
path,
format,
download
}: {
path: string;
format: string;
download: boolean;
}): string {
const notebookPath = URLExt.encodeParts(path);
const url = URLExt.join(getBaseUrl(), 'nbconvert', format, notebookPath);
if (download) {
return url + '?download=true';
}
return url;
}

/**
* Get the authorization token for a Jupyter application.
*/
Expand Down
9 changes: 8 additions & 1 deletion packages/docregistry/src/mimedocument.ts
@@ -1,7 +1,7 @@
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.

import { showErrorMessage } from '@jupyterlab/apputils';
import { showErrorMessage, Printing } from '@jupyterlab/apputils';

import { ActivityMonitor } from '@jupyterlab/coreutils';

Expand Down Expand Up @@ -78,6 +78,13 @@ export class MimeContent extends Widget {
*/
readonly mimeType: string;

/**
* Print method. Defered to the renderer.
*/
[Printing.symbol]() {
return Printing.getPrintFunction(this.renderer);
}

/**
* A promise that resolves when the widget is ready.
*/
Expand Down
11 changes: 10 additions & 1 deletion packages/imageviewer/src/widget.ts
Expand Up @@ -3,6 +3,8 @@

import { PathExt } from '@jupyterlab/coreutils';

import { Printing } from '@jupyterlab/apputils';

import {
ABCWidgetFactory,
DocumentRegistry,
Expand All @@ -24,7 +26,7 @@ const IMAGE_CLASS = 'jp-ImageViewer';
/**
* A widget for images.
*/
export class ImageViewer extends Widget {
export class ImageViewer extends Widget implements Printing.IPrintable {
/**
* Construct a new image widget.
*/
Expand Down Expand Up @@ -54,6 +56,13 @@ export class ImageViewer extends Widget {
});
}

/**
* Print in iframe.
*/
[Printing.symbol]() {
return () => Printing.printWidget(this);
}

/**
* The image widget's context.
*/
Expand Down

0 comments on commit 3670ef1

Please sign in to comment.