site/node_modules/mathjax-full/ts/output/svg/Wrapper.ts

560 lines
19 KiB
TypeScript
Raw Normal View History

2024-10-14 06:09:33 +00:00
/*************************************************************
*
* Copyright (c) 2018-2022 The MathJax Consortium
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @fileoverview Implements the SVGWrapper class
*
* @author dpvc@mathjax.org (Davide Cervone)
*/
import {OptionList} from '../../util/Options.js';
import {BBox} from '../../util/BBox.js';
import {CommonWrapper, AnyWrapperClass, Constructor} from '../common/Wrapper.js';
import {SVG, XLINKNS} from '../svg.js';
import {SVGWrapperFactory} from './WrapperFactory.js';
import {SVGFontData, SVGDelimiterData, SVGCharOptions} from './FontData.js';
export {Constructor, StringMap} from '../common/Wrapper.js';
/*****************************************************************/
/**
* Shorthand for makeing a SVGWrapper constructor
*/
export type SVGConstructor<N, T, D> = Constructor<SVGWrapper<N, T, D>>;
/*****************************************************************/
/**
* The type of the SVGWrapper class (used when creating the wrapper factory for this class)
*/
export interface SVGWrapperClass extends AnyWrapperClass {
kind: string;
}
/*****************************************************************/
/**
* The base SVGWrapper class
*
* @template N The HTMLElement node class
* @template T The Text node class
* @template D The Document class
*/
export class SVGWrapper<N, T, D> extends
CommonWrapper<
SVG<N, T, D>,
SVGWrapper<N, T, D>,
SVGWrapperClass,
SVGCharOptions,
SVGDelimiterData,
SVGFontData
> {
/**
* The kind of wrapper
*/
public static kind: string = 'unknown';
/**
* A fuzz factor for borders to avoid anti-alias problems at the edges
*/
public static borderFuzz = 0.005;
/**
* The factory used to create more SVGWrappers
*/
protected factory: SVGWrapperFactory<N, T, D>;
/**
* @override
*/
public parent: SVGWrapper<N, T, D>;
/**
* @override
*/
public childNodes: SVGWrapper<N, T, D>[];
/**
* The SVG element generated for this wrapped node
*/
public element: N = null;
/**
* Offset due to border/padding
*/
public dx: number = 0;
/**
* @override
*/
public font: SVGFontData;
/*******************************************************************/
/**
* Create the HTML for the wrapped node.
*
* @param {N} parent The HTML node where the output is added
*/
public toSVG(parent: N) {
this.addChildren(this.standardSVGnode(parent));
}
/**
* @param {N} parent The element in which to add the children
*/
public addChildren(parent: N) {
let x = 0;
for (const child of this.childNodes) {
child.toSVG(parent);
const bbox = child.getOuterBBox();
if (child.element) {
child.place(x + bbox.L * bbox.rscale, 0);
}
x += (bbox.L + bbox.w + bbox.R) * bbox.rscale;
}
}
/*******************************************************************/
/**
* Create the standard SVG element for the given wrapped node.
*
* @param {N} parent The HTML element in which the node is to be created
* @returns {N} The root of the HTML tree for the wrapped node's output
*/
protected standardSVGnode(parent: N): N {
const svg = this.createSVGnode(parent);
this.handleStyles();
this.handleScale();
this.handleBorder();
this.handleColor();
this.handleAttributes();
return svg;
}
/**
* @param {N} parent The HTML element in which the node is to be created
* @returns {N} The root of the HTML tree for the wrapped node's output
*/
protected createSVGnode(parent: N): N {
this.element = this.svg('g', {'data-mml-node': this.node.kind});
const href = this.node.attributes.get('href');
if (href) {
parent = this.adaptor.append(parent, this.svg('a', {href: href})) as N;
const {h, d, w} = this.getOuterBBox();
this.adaptor.append(this.element, this.svg('rect', {
'data-hitbox': true, fill: 'none', stroke: 'none', 'pointer-events': 'all',
width: this.fixed(w), height: this.fixed(h + d), y: this.fixed(-d)
}));
}
this.adaptor.append(parent, this.element) as N;
return this.element;
}
/**
* Set the CSS styles for the svg element
*/
protected handleStyles() {
if (!this.styles) return;
const styles = this.styles.cssText;
if (styles) {
this.adaptor.setAttribute(this.element, 'style', styles);
}
BBox.StyleAdjust.forEach(([name, , lr]) => {
if (lr !== 0) return;
const x = this.styles.get(name);
if (x) {
this.dx += this.length2em(x, 1, this.bbox.rscale);
}
});
}
/**
* Set the (relative) scaling factor for the node
*/
protected handleScale() {
if (this.bbox.rscale !== 1) {
const scale = 'scale(' + this.fixed(this.bbox.rscale / 1000, 3) + ')';
this.adaptor.setAttribute(this.element, 'transform', scale);
}
}
/**
* Add the foreground and background colors
* (Only look at explicit attributes, since inherited ones will
* be applied to a parent element, and we will inherit from that)
*/
protected handleColor() {
const adaptor = this.adaptor;
const attributes = this.node.attributes;
const mathcolor = attributes.getExplicit('mathcolor') as string;
const color = attributes.getExplicit('color') as string;
const mathbackground = attributes.getExplicit('mathbackground') as string;
const background = attributes.getExplicit('background') as string;
const bgcolor = (this.styles?.get('background-color') || '');
if (mathcolor || color) {
adaptor.setAttribute(this.element, 'fill', mathcolor || color);
adaptor.setAttribute(this.element, 'stroke', mathcolor || color);
}
if (mathbackground || background || bgcolor) {
let {h, d, w} = this.getOuterBBox();
let rect = this.svg('rect', {
fill: mathbackground || background || bgcolor,
x: this.fixed(-this.dx), y: this.fixed(-d),
width: this.fixed(w),
height: this.fixed(h + d),
'data-bgcolor': true
});
let child = adaptor.firstChild(this.element);
if (child) {
adaptor.insert(rect, child);
} else {
adaptor.append(this.element, rect);
}
}
}
/**
* Create the borders, if any are requested.
*/
protected handleBorder() {
if (!this.styles) return;
const width = Array(4).fill(0);
const style = Array(4);
const color = Array(4);
for (const [name, i] of [['Top', 0], ['Right', 1], ['Bottom', 2], ['Left', 3]] as [string, number][]) {
const key = 'border' + name;
const w = this.styles.get(key + 'Width');
if (!w) continue;
width[i] = Math.max(0, this.length2em(w, 1, this.bbox.rscale));
style[i] = this.styles.get(key + 'Style') || 'solid';
color[i] = this.styles.get(key + 'Color') || 'currentColor';
}
const f = SVGWrapper.borderFuzz;
const bbox = this.getOuterBBox();
const [h, d, w] = [bbox.h + f, bbox.d + f, bbox.w + f];
const outerRT = [w, h];
const outerLT = [-f, h];
const outerRB = [w, -d];
const outerLB = [-f, -d];
const innerRT = [w - width[1], h - width[0]];
const innerLT = [-f + width[3], h - width[0]];
const innerRB = [w - width[1], -d + width[2]];
const innerLB = [-f + width[3], -d + width[2]];
const paths: number[][][] = [
[outerLT, outerRT, innerRT, innerLT],
[outerRB, outerRT, innerRT, innerRB],
[outerLB, outerRB, innerRB, innerLB],
[outerLB, outerLT, innerLT, innerLB]
];
const adaptor = this.adaptor;
const child = adaptor.firstChild(this.element) as N;
for (const i of [0, 1, 2, 3]) {
if (!width[i]) continue;
const path = paths[i];
if (style[i] === 'dashed' || style[i] === 'dotted') {
this.addBorderBroken(path, color[i], style[i], width[i], i);
} else {
this.addBorderSolid(path, color[i], child);
}
}
}
/**
* Create a solid border piece with the given color
*
* @param {[number, number][]} path The points for the border segment
* @param {string} color The color to use
* @param {N} child Insert the border before this child, if any
*/
protected addBorderSolid(path: number[][], color: string, child: N) {
const border = this.svg('polygon', {
points: path.map(([x, y]) => `${this.fixed(x - this.dx)},${this.fixed(y)}`).join(' '),
stroke: 'none',
fill: color
});
if (child) {
this.adaptor.insert(border, child);
} else {
this.adaptor.append(this.element, border);
}
}
/**
* Create a dashed or dotted border line with the given width and color
*
* @param {[number, number][]} path The points for the border segment
* @param {string} color The color to use
* @param {string} style Either 'dotted' or 'dashed'
* @param {number} t The thickness for the border line
* @param {number} i The side being drawn
*/
protected addBorderBroken(path: number[][], color: string, style: string, t: number, i: number) {
const dot = (style === 'dotted');
const t2 = t / 2;
const [tx1, ty1, tx2, ty2] = [[t2, -t2, -t2, -t2], [-t2, t2, -t2, -t2], [t2, t2, -t2, t2], [t2, t2, t2, -t2]][i];
const [A, B] = path;
const x1 = A[0] + tx1 - this.dx, y1 = A[1] + ty1;
const x2 = B[0] + tx2 - this.dx, y2 = B[1] + ty2;
const W = Math.abs(i % 2 ? y2 - y1 : x2 - x1);
const n = (dot ? Math.ceil(W / (2 * t)) : Math.ceil((W - t) / (4 * t)));
const m = W / (4 * n + 1);
const line = this.svg('line', {
x1: this.fixed(x1), y1: this.fixed(y1),
x2: this.fixed(x2), y2: this.fixed(y2),
'stroke-width': this.fixed(t), stroke: color, 'stroke-linecap': dot ? 'round' : 'square',
'stroke-dasharray': dot ? [1, this.fixed(W / n - .002)].join(' ') : [this.fixed(m), this.fixed(3 * m)].join(' ')
});
const adaptor = this.adaptor;
const child = adaptor.firstChild(this.element);
if (child) {
adaptor.insert(line, child);
} else {
adaptor.append(this.element, line);
}
}
/**
* Copy RDFa, aria, and other tags from the MathML to the SVG output nodes.
* Don't copy those in the skipAttributes list, or anything that already exists
* as a property of the node (e.g., no "onlick", etc.). If a name in the
* skipAttributes object is set to false, then the attribute WILL be copied.
* Add the class to any other classes already in use.
*/
protected handleAttributes() {
const attributes = this.node.attributes;
const defaults = attributes.getAllDefaults();
const skip = SVGWrapper.skipAttributes;
for (const name of attributes.getExplicitNames()) {
if (skip[name] === false || (!(name in defaults) && !skip[name] &&
!this.adaptor.hasAttribute(this.element, name))) {
this.adaptor.setAttribute(this.element, name, attributes.getExplicit(name) as string);
}
}
if (attributes.get('class')) {
const names = (attributes.get('class') as string).trim().split(/ +/);
for (const name of names) {
this.adaptor.addClass(this.element, name);
}
}
}
/*******************************************************************/
/**
* @param {number} x The x-offset for the element
* @param {number} y The y-offset for the element
* @param {N} element The element to be placed
*/
public place(x: number, y: number, element: N = null) {
x += this.dx;
if (!(x || y)) return;
if (!element) {
element = this.element;
y = this.handleId(y);
}
const translate = `translate(${this.fixed(x)},${this.fixed(y)})`;
const transform = this.adaptor.getAttribute(element, 'transform') || '';
this.adaptor.setAttribute(element, 'transform', translate + (transform ? ' ' + transform : ''));
}
/**
* Firefox and Safari don't scroll to the top of the element with an Id, so
* we shift the element up and then translate its contents down in order to
* correct for their positioning. Also, Safari will go to the baseline of
* a <text> element (e.g., when mtextInheritFont is true), so add a text
* element to help Safari get the right location.
*
* @param {number} y The current offset of the element
* @return {number} The new offset for the element if it has an id
*/
protected handleId(y: number): number {
if (!this.node.attributes || !this.node.attributes.get('id')) {
return y;
}
const adaptor = this.adaptor;
const h = this.getBBox().h;
//
// Remove the element's children and put them into a <g> with transform
//
const children = adaptor.childNodes(this.element);
children.forEach(child => adaptor.remove(child));
const g = this.svg('g', {'data-idbox': true, transform: `translate(0,${this.fixed(-h)})`}, children);
//
// Add the text element (not transformed) and the transformed <g>
//
adaptor.append(this.element, this.svg('text', {'data-id-align': true} , [this.text('')]));
adaptor.append(this.element, g);
return y + h;
}
/**
* Return the first child element, skipping id align boxes and href hit boxes
*
* @return {N} The first "real" child element
*/
public firstChild(): N {
const adaptor = this.adaptor;
let child = adaptor.firstChild(this.element);
if (child && adaptor.kind(child) === 'text' && adaptor.getAttribute(child, 'data-id-align')) {
child = adaptor.firstChild(adaptor.next(child));
}
if (child && adaptor.kind(child) === 'rect' && adaptor.getAttribute(child, 'data-hitbox')) {
child = adaptor.next(child);
}
return child;
}
/**
* @param {number} n The character number
* @param {number} x The x-position of the character
* @param {number} y The y-position of the character
* @param {N} parent The container for the character
* @param {string} variant The variant to use for the character
* @return {number} The width of the character
*/
public placeChar(n: number, x: number, y: number, parent: N, variant: string = null): number {
if (variant === null) {
variant = this.variant;
}
const C = n.toString(16).toUpperCase();
const [ , , w, data] = this.getVariantChar(variant, n);
if ('p' in data) {
const path = (data.p ? 'M' + data.p + 'Z' : '');
this.place(x, y, this.adaptor.append(parent, this.charNode(variant, C, path)));
} else if ('c' in data) {
const g = this.adaptor.append(parent, this.svg('g', {'data-c': C}));
this.place(x, y, g);
x = 0;
for (const n of this.unicodeChars(data.c, variant)) {
x += this.placeChar(n, x, y, g, variant);
}
} else if (data.unknown) {
const char = String.fromCodePoint(n);
const text = this.adaptor.append(parent, this.jax.unknownText(char, variant));
this.place(x, y, text);
return this.jax.measureTextNodeWithCache(text, char, variant).w;
}
return w;
}
/**
* @param {string} variant The name of the variant being used
* @param {string} C The hex string for the character code
* @param {string} path The data from the character
* @return {N} The <path> or <use> node for the glyph
*/
protected charNode(variant: string, C: string, path: string): N {
const cache = this.jax.options.fontCache;
return (cache !== 'none' ? this.useNode(variant, C, path) : this.pathNode(C, path));
}
/**
* @param {string} C The hex string for the character code
* @param {string} path The data from the character
* @return {N} The <path> for the glyph
*/
protected pathNode(C: string, path: string): N {
return this.svg('path', {'data-c': C, d: path});
}
/**
* @param {string} variant The name of the variant being used
* @param {string} C The hex string for the character code
* @param {string} path The data from the character
* @return {N} The <use> node for the glyph
*/
protected useNode(variant: string, C: string, path: string): N {
const use = this.svg('use', {'data-c': C});
const id = '#' + this.jax.fontCache.cachePath(variant, C, path);
this.adaptor.setAttribute(use, 'href', id, XLINKNS);
return use;
}
/*******************************************************************/
/**
* For debugging
*/
public drawBBox() {
let {w, h, d} = this.getBBox();
const box = this.svg('g', {style: {
opacity: .25
}}, [
this.svg('rect', {
fill: 'red',
height: this.fixed(h),
width: this.fixed(w)
}),
this.svg('rect', {
fill: 'green',
height: this.fixed(d),
width: this.fixed(w),
y: this.fixed(-d)
})
] as N[]);
const node = this.element || this.parent.element;
this.adaptor.append(node, box);
}
/*******************************************************************/
/*
* Easy access to some utility routines
*/
/**
* @param {string} type The tag name of the HTML node to be created
* @param {OptionList} def The properties to set for the created node
* @param {(N|T)[]} content The child nodes for the created HTML node
* @return {N} The generated HTML tree
*/
public html(type: string, def: OptionList = {}, content: (N | T)[] = []): N {
return this.jax.html(type, def, content);
}
/**
* @param {string} type The tag name of the svg node to be created
* @param {OptionList} def The properties to set for the created node
* @param {(N|T)[]} content The child nodes for the created SVG node
* @return {N} The generated SVG tree
*/
public svg(type: string, def: OptionList = {}, content: (N | T)[] = []): N {
return this.jax.svg(type, def, content);
}
/**
* @param {string} text The text from which to create an HTML text node
* @return {T} The generated text node with the given text
*/
public text(text: string): T {
return this.jax.text(text);
}
/**
* @param {number} x The dimension to display
* @param {number=} n The number of digits to display
* @return {string} The dimension with the given number of digits (minus trailing zeros)
*/
public fixed(x: number, n: number = 1): string {
return this.jax.fixed(x * 1000, n);
}
}