// Copyright (c) Jupyter Development Team. // Distributed under the terms of the Modified BSD License. /*----------------------------------------------------------------------------- | Copyright (c) 2014-2019, PhosphorJS Contributors | | Distributed under the terms of the BSD 3-Clause License. | | The full license is in the file LICENSE, distributed with this software. |----------------------------------------------------------------------------*/ import { CellRenderer } from './cellrenderer'; import { GraphicsContext } from './graphicscontext'; /** * A cell renderer which renders data values as text. */ export class TextRenderer extends CellRenderer { /** * Construct a new text renderer. * * @param options - The options for initializing the renderer. */ constructor(options: TextRenderer.IOptions = {}) { super(); this.font = options.font || '12px sans-serif'; this.textColor = options.textColor || '#000000'; this.backgroundColor = options.backgroundColor || ''; this.verticalAlignment = options.verticalAlignment || 'center'; this.horizontalAlignment = options.horizontalAlignment || 'left'; this.horizontalPadding = options.horizontalPadding || 8; this.format = options.format || TextRenderer.formatGeneric(); this.elideDirection = options.elideDirection || 'none'; this.wrapText = options.wrapText || false; } /** * The CSS shorthand font for drawing the text. */ readonly font: CellRenderer.ConfigOption; /** * The CSS color for drawing the text. */ readonly textColor: CellRenderer.ConfigOption; /** * The CSS color for the cell background. */ readonly backgroundColor: CellRenderer.ConfigOption; /** * The vertical alignment for the cell text. */ readonly verticalAlignment: CellRenderer.ConfigOption; /** * The horizontal alignment for the cell text. */ readonly horizontalAlignment: CellRenderer.ConfigOption; /** * The horizontal alignment for the cell text. */ readonly horizontalPadding: number; /** * The format function for the cell value. */ readonly format: TextRenderer.FormatFunc; /** * Which side to draw the ellipsis. Set to 'none' to disable ellipsis. */ readonly elideDirection: CellRenderer.ConfigOption; /** * Boolean flag for applying text wrapping. */ readonly wrapText: CellRenderer.ConfigOption; /** * Paint the content for a cell. * * @param gc - The graphics context to use for drawing. * * @param config - The configuration data for the cell. */ paint(gc: GraphicsContext, config: CellRenderer.CellConfig): void { this.drawBackground(gc, config); this.drawText(gc, config); } /** * Draw the background for the cell. * * @param gc - The graphics context to use for drawing. * * @param config - The configuration data for the cell. */ drawBackground(gc: GraphicsContext, config: CellRenderer.CellConfig): void { // Resolve the background color for the cell. let color = CellRenderer.resolveOption(this.backgroundColor, config); // Bail if there is no background color to draw. if (!color) { return; } // Fill the cell with the background color. gc.fillStyle = color; gc.fillRect(config.x, config.y, config.width, config.height); } /** * Get the full text to be rendered by the cell. */ getText(config: CellRenderer.CellConfig): string { return this.format(config); } /** * Draw the text for the cell. * * @param gc - The graphics context to use for drawing. * * @param config - The configuration data for the cell. */ drawText(gc: GraphicsContext, config: CellRenderer.CellConfig): void { // Resolve the font for the cell. let font = CellRenderer.resolveOption(this.font, config); // Bail if there is no font to draw. if (!font) { return; } // Resolve the text color for the cell. let color = CellRenderer.resolveOption(this.textColor, config); // Bail if there is no text color to draw. if (!color) { return; } // Format the cell value to text. let text = this.getText(config); // Bail if there is no text to draw. if (!text) { return; } // Resolve the vertical and horizontal alignment. let vAlign = CellRenderer.resolveOption(this.verticalAlignment, config); let hAlign = CellRenderer.resolveOption(this.horizontalAlignment, config); // Resolve the elision direction let elideDirection = CellRenderer.resolveOption( this.elideDirection, config ); // Resolve the text wrapping flag let wrapText = CellRenderer.resolveOption(this.wrapText, config); // Compute the padded text box height for the specified alignment. let boxHeight = config.height - (vAlign === 'center' ? 1 : 2); // Bail if the text box has no effective size. if (boxHeight <= 0) { return; } // Compute the text height for the gc font. let textHeight = TextRenderer.measureFontHeight(font); // Set up the text position variables. let textX: number; let textY: number; let boxWidth: number; // Compute the Y position for the text. switch (vAlign) { case 'top': textY = config.y + 2 + textHeight; break; case 'center': textY = config.y + config.height / 2 + textHeight / 2; break; case 'bottom': textY = config.y + config.height - 2; break; default: throw 'unreachable'; } // Compute the X position for the text. switch (hAlign) { case 'left': textX = config.x + this.horizontalPadding; boxWidth = config.width - 14; break; case 'center': textX = config.x + config.width / 2; boxWidth = config.width; break; case 'right': textX = config.x + config.width - this.horizontalPadding; boxWidth = config.width - 14; break; default: throw 'unreachable'; } // Clip the cell if the text is taller than the text box height. if (textHeight > boxHeight) { gc.beginPath(); gc.rect(config.x, config.y, config.width, config.height - 1); gc.clip(); } // Set the gc state. gc.font = font; gc.fillStyle = color; gc.textAlign = hAlign; gc.textBaseline = 'bottom'; // Terminate call here if we're not eliding or wrapping text if (elideDirection === 'none' && !wrapText) { gc.fillText(text, textX, textY); return; } // The current text width in pixels. let textWidth = gc.measureText(text).width; // Apply text wrapping if enabled. if (wrapText && textWidth > boxWidth) { // Make sure box clipping happens. gc.beginPath(); gc.rect(config.x, config.y, config.width, config.height - 1); gc.clip(); // Split column name to words based on // whitespace preceding a word boundary. // "Hello world" --> ["Hello ", "world"] const wordsInColumn = text.split(/\s(?=\b)/); // Y-coordinate offset for any additional lines let curY = textY; let textInCurrentLine = wordsInColumn.shift()!; // Single word. Applying text wrap on word by splitting // it into characters and fitting the maximum number of // characters possible per line (box width). if (wordsInColumn.length === 0) { let curLineTextWidth = gc.measureText(textInCurrentLine).width; while (curLineTextWidth > boxWidth && textInCurrentLine !== '') { // Iterating from the end of the string until we find a // substring (0,i) which has a width less than the box width. for (let i = textInCurrentLine.length; i > 0; i--) { const curSubString = textInCurrentLine.substring(0, i); const curSubStringWidth = gc.measureText(curSubString).width; if (curSubStringWidth < boxWidth || curSubString.length === 1) { // Found a substring which has a width less than the current // box width. Rendering that substring on the current line // and setting the remainder of the parent string as the next // string to iterate on for the next line. const nextLineText = textInCurrentLine.substring( i, textInCurrentLine.length ); textInCurrentLine = nextLineText; curLineTextWidth = gc.measureText(textInCurrentLine).width; gc.fillText(curSubString, textX, curY); curY += textHeight; // No need to continue iterating after we identified // an index to break the string on. break; } } } } // Multiple words in column header. Fitting maximum // number of words possible per line (box width). else { while (wordsInColumn.length !== 0) { // Processing the next word in the queue. const curWord = wordsInColumn.shift(); // Joining that word with the existing text for // the current line. const incrementedText = [textInCurrentLine, curWord].join(' '); const incrementedTextWidth = gc.measureText(incrementedText).width; if (incrementedTextWidth > boxWidth) { // If the newly combined text has a width larger than // the box width, we render the line before the current // word was added. We set the current word as the next // line. gc.fillText(textInCurrentLine, textX, curY); curY += textHeight; textInCurrentLine = curWord!; } else { // The combined text hasd a width less than the box width. We // set the the current line text to be the new combined text. textInCurrentLine = incrementedText; } } } gc.fillText(textInCurrentLine!, textX, curY); // Terminating the call here as we don't want // to apply text eliding when wrapping is active. return; } // Elide text that is too long const elide = '\u2026'; // Loop until text width fits box or only one character remains while (textWidth > boxWidth && text.length > 1) { // Convert text string to array for dealing with astral symbols const textArr = [...text]; if (elideDirection === 'right') { // If text width is substantially bigger, take half the string if (textArr.length > 4 && textWidth >= 2 * boxWidth) { text = textArr.slice(0, Math.floor(textArr.length / 2 + 1)).join('') + elide; } else { // Otherwise incrementally remove the last character text = textArr.slice(0, textArr.length - 2).join('') + elide; } } else { // If text width is substantially bigger, take half the string if (textArr.length > 4 && textWidth >= 2 * boxWidth) { text = elide + textArr.slice(Math.floor(textArr.length / 2)).join(''); } else { // Otherwise incrementally remove the last character text = elide + textArr.slice(2).join(''); } } // Measure new text width textWidth = gc.measureText(text).width; } // Draw the text for the cell. gc.fillText(text, textX, textY); } } /** * The namespace for the `TextRenderer` class statics. */ export namespace TextRenderer { /** * A type alias for the supported vertical alignment modes. */ export type VerticalAlignment = 'top' | 'center' | 'bottom'; /** * A type alias for the supported horizontal alignment modes. */ export type HorizontalAlignment = 'left' | 'center' | 'right'; /** * A type alias for the supported ellipsis sides. */ export type ElideDirection = 'left' | 'right' | 'none'; /** * An options object for initializing a text renderer. */ export interface IOptions { /** * The font for drawing the cell text. * * The default is `'12px sans-serif'`. */ font?: CellRenderer.ConfigOption; /** * The color for the drawing the cell text. * * The default `'#000000'`. */ textColor?: CellRenderer.ConfigOption; /** * The background color for the cells. * * The default is `''`. */ backgroundColor?: CellRenderer.ConfigOption; /** * The vertical alignment for the cell text. * * The default is `'center'`. */ verticalAlignment?: CellRenderer.ConfigOption; /** * The horizontal alignment for the cell text. * * The default is `'left'`. */ horizontalAlignment?: CellRenderer.ConfigOption; /** * The horizontal padding for the cell text in pixels. * * The default is `8`. */ horizontalPadding?: number; /** * The format function for the renderer. * * The default is `TextRenderer.formatGeneric()`. */ format?: FormatFunc; /** * The ellipsis direction for the cell text. * * The default is `'none'`. */ elideDirection?: CellRenderer.ConfigOption; /** * Whether or not to apply text wrapping. * * The default is `'false'`. */ wrapText?: CellRenderer.ConfigOption; } /** * A type alias for a format function. */ export type FormatFunc = CellRenderer.ConfigFunc; /** * Create a generic text format function. * * @param options - The options for creating the format function. * * @returns A new generic text format function. * * #### Notes * This formatter uses the builtin `String()` to coerce any value * to a string. */ export function formatGeneric( options: formatGeneric.IOptions = {} ): FormatFunc { let missing = options.missing || ''; return ({ value }) => { if (value === null || value === undefined) { return missing; } return String(value); }; } /** * The namespace for the `formatGeneric` function statics. */ export namespace formatGeneric { /** * The options for creating a generic format function. */ export interface IOptions { /** * The text to use for a `null` or `undefined` data value. * * The default is `''`. */ missing?: string; } } /** * Create a fixed decimal format function. * * @param options - The options for creating the format function. * * @returns A new fixed decimal format function. * * #### Notes * This formatter uses the builtin `Number()` and `toFixed()` to * coerce values. * * The `formatIntlNumber()` formatter is more flexible, but slower. */ export function formatFixed(options: formatFixed.IOptions = {}): FormatFunc { let digits = options.digits; let missing = options.missing || ''; return ({ value }) => { if (value === null || value === undefined) { return missing; } return Number(value).toFixed(digits); }; } /** * The namespace for the `formatFixed` function statics. */ export namespace formatFixed { /** * The options for creating a fixed format function. */ export interface IOptions { /** * The number of digits to include after the decimal point. * * The default is determined by the user agent. */ digits?: number; /** * The text to use for a `null` or `undefined` data value. * * The default is `''`. */ missing?: string; } } /** * Create a significant figure format function. * * @param options - The options for creating the format function. * * @returns A new significant figure format function. * * #### Notes * This formatter uses the builtin `Number()` and `toPrecision()` * to coerce values. * * The `formatIntlNumber()` formatter is more flexible, but slower. */ export function formatPrecision( options: formatPrecision.IOptions = {} ): FormatFunc { let digits = options.digits; let missing = options.missing || ''; return ({ value }) => { if (value === null || value === undefined) { return missing; } return Number(value).toPrecision(digits); }; } /** * The namespace for the `formatPrecision` function statics. */ export namespace formatPrecision { /** * The options for creating a precision format function. */ export interface IOptions { /** * The number of significant figures to include in the value. * * The default is determined by the user agent. */ digits?: number; /** * The text to use for a `null` or `undefined` data value. * * The default is `''`. */ missing?: string; } } /** * Create a scientific notation format function. * * @param options - The options for creating the format function. * * @returns A new scientific notation format function. * * #### Notes * This formatter uses the builtin `Number()` and `toExponential()` * to coerce values. * * The `formatIntlNumber()` formatter is more flexible, but slower. */ export function formatExponential( options: formatExponential.IOptions = {} ): FormatFunc { let digits = options.digits; let missing = options.missing || ''; return ({ value }) => { if (value === null || value === undefined) { return missing; } return Number(value).toExponential(digits); }; } /** * The namespace for the `formatExponential` function statics. */ export namespace formatExponential { /** * The options for creating an exponential format function. */ export interface IOptions { /** * The number of digits to include after the decimal point. * * The default is determined by the user agent. */ digits?: number; /** * The text to use for a `null` or `undefined` data value. * * The default is `''`. */ missing?: string; } } /** * Create an international number format function. * * @param options - The options for creating the format function. * * @returns A new international number format function. * * #### Notes * This formatter uses the builtin `Intl.NumberFormat` object to * coerce values. * * This is the most flexible (but slowest) number formatter. */ export function formatIntlNumber( options: formatIntlNumber.IOptions = {} ): FormatFunc { let missing = options.missing || ''; let nft = new Intl.NumberFormat(options.locales, options.options); return ({ value }) => { if (value === null || value === undefined) { return missing; } return nft.format(value); }; } /** * The namespace for the `formatIntlNumber` function statics. */ export namespace formatIntlNumber { /** * The options for creating an intl number format function. */ export interface IOptions { /** * The locales to pass to the `Intl.NumberFormat` constructor. * * The default is determined by the user agent. */ locales?: string | string[]; /** * The options to pass to the `Intl.NumberFormat` constructor. * * The default is determined by the user agent. */ options?: Intl.NumberFormatOptions; /** * The text to use for a `null` or `undefined` data value. * * The default is `''`. */ missing?: string; } } /** * Create a date format function. * * @param options - The options for creating the format function. * * @returns A new date format function. * * #### Notes * This formatter uses `Date.toDateString()` to format the values. * * If a value is not a `Date` object, `new Date(value)` is used to * coerce the value to a date. * * The `formatIntlDateTime()` formatter is more flexible, but slower. */ export function formatDate(options: formatDate.IOptions = {}): FormatFunc { let missing = options.missing || ''; return ({ value }) => { if (value === null || value === undefined) { return missing; } if (value instanceof Date) { return value.toDateString(); } return new Date(value).toDateString(); }; } /** * The namespace for the `formatDate` function statics. */ export namespace formatDate { /** * The options for creating a date format function. */ export interface IOptions { /** * The text to use for a `null` or `undefined` data value. * * The default is `''`. */ missing?: string; } } /** * Create a time format function. * * @param options - The options for creating the format function. * * @returns A new time format function. * * #### Notes * This formatter uses `Date.toTimeString()` to format the values. * * If a value is not a `Date` object, `new Date(value)` is used to * coerce the value to a date. * * The `formatIntlDateTime()` formatter is more flexible, but slower. */ export function formatTime(options: formatTime.IOptions = {}): FormatFunc { let missing = options.missing || ''; return ({ value }) => { if (value === null || value === undefined) { return missing; } if (value instanceof Date) { return value.toTimeString(); } return new Date(value).toTimeString(); }; } /** * The namespace for the `formatTime` function statics. */ export namespace formatTime { /** * The options for creating a time format function. */ export interface IOptions { /** * The text to use for a `null` or `undefined` data value. * * The default is `''`. */ missing?: string; } } /** * Create an ISO datetime format function. * * @param options - The options for creating the format function. * * @returns A new ISO datetime format function. * * #### Notes * This formatter uses `Date.toISOString()` to format the values. * * If a value is not a `Date` object, `new Date(value)` is used to * coerce the value to a date. * * The `formatIntlDateTime()` formatter is more flexible, but slower. */ export function formatISODateTime( options: formatISODateTime.IOptions = {} ): FormatFunc { let missing = options.missing || ''; return ({ value }) => { if (value === null || value === undefined) { return missing; } if (value instanceof Date) { return value.toISOString(); } return new Date(value).toISOString(); }; } /** * The namespace for the `formatISODateTime` function statics. */ export namespace formatISODateTime { /** * The options for creating an ISO datetime format function. */ export interface IOptions { /** * The text to use for a `null` or `undefined` data value. * * The default is `''`. */ missing?: string; } } /** * Create a UTC datetime format function. * * @param options - The options for creating the format function. * * @returns A new UTC datetime format function. * * #### Notes * This formatter uses `Date.toUTCString()` to format the values. * * If a value is not a `Date` object, `new Date(value)` is used to * coerce the value to a date. * * The `formatIntlDateTime()` formatter is more flexible, but slower. */ export function formatUTCDateTime( options: formatUTCDateTime.IOptions = {} ): FormatFunc { let missing = options.missing || ''; return ({ value }) => { if (value === null || value === undefined) { return missing; } if (value instanceof Date) { return value.toUTCString(); } return new Date(value).toUTCString(); }; } /** * The namespace for the `formatUTCDateTime` function statics. */ export namespace formatUTCDateTime { /** * The options for creating a UTC datetime format function. */ export interface IOptions { /** * The text to use for a `null` or `undefined` data value. * * The default is `''`. */ missing?: string; } } /** * Create an international datetime format function. * * @param options - The options for creating the format function. * * @returns A new international datetime format function. * * #### Notes * This formatter uses the builtin `Intl.DateTimeFormat` object to * coerce values. * * This is the most flexible (but slowest) datetime formatter. */ export function formatIntlDateTime( options: formatIntlDateTime.IOptions = {} ): FormatFunc { let missing = options.missing || ''; let dtf = new Intl.DateTimeFormat(options.locales, options.options); return ({ value }) => { if (value === null || value === undefined) { return missing; } return dtf.format(value); }; } /** * The namespace for the `formatIntlDateTime` function statics. */ export namespace formatIntlDateTime { /** * The options for creating an intl datetime format function. */ export interface IOptions { /** * The locales to pass to the `Intl.DateTimeFormat` constructor. * * The default is determined by the user agent. */ locales?: string | string[]; /** * The options to pass to the `Intl.DateTimeFormat` constructor. * * The default is determined by the user agent. */ options?: Intl.DateTimeFormatOptions; /** * The text to use for a `null` or `undefined` data value. * * The default is `''`. */ missing?: string; } } /** * Measure the height of a font. * * @param font - The CSS font string of interest. * * @returns The height of the font bounding box. * * #### Notes * This function uses a temporary DOM node to measure the text box * height for the specified font. The first call for a given font * will incur a DOM reflow, but the return value is cached, so any * subsequent call for the same font will return the cached value. */ export function measureFontHeight(font: string): number { // Look up the cached font height. let height = Private.fontHeightCache[font]; // Return the cached font height if it exists. if (height !== undefined) { return height; } // Normalize the font. Private.fontMeasurementGC.font = font; let normFont = Private.fontMeasurementGC.font; // Set the font on the measurement node. Private.fontMeasurementNode.style.font = normFont; // Add the measurement node to the document. document.body.appendChild(Private.fontMeasurementNode); // Measure the node height. height = Private.fontMeasurementNode.offsetHeight; // Remove the measurement node from the document. document.body.removeChild(Private.fontMeasurementNode); // Cache the measured height for the font and norm font. Private.fontHeightCache[font] = height; Private.fontHeightCache[normFont] = height; // Return the measured height. return height; } } /** * The namespace for the module implementation details. */ namespace Private { /** * A cache of measured font heights. */ export const fontHeightCache: { [font: string]: number } = Object.create(null); /** * The DOM node used for font height measurement. */ export const fontMeasurementNode = (() => { let node = document.createElement('div'); node.style.position = 'absolute'; node.style.top = '-99999px'; node.style.left = '-99999px'; node.style.visibility = 'hidden'; node.textContent = 'M'; return node; })(); /** * The GC used for font measurement. */ export const fontMeasurementGC = (() => { let canvas = document.createElement('canvas'); canvas.width = 0; canvas.height = 0; return canvas.getContext('2d')!; })(); }