Files
swiftui-math/Sources/SwiftMathRender/MathRender/MTMathListDisplay.swift
2023-01-03 13:26:38 -05:00

817 lines
26 KiB
Swift

//
// MTMathListDisplay.swift
// MathRenderSwift
//
// Created by Mike Griebling on 2022-12-31.
//
import Foundation
import QuartzCore
import CoreText
import SwiftUI
func isIos6Supported() -> Bool {
if !MTDisplay.initialized {
#if os(iOS)
let reqSysVer = "6.0"
let currSysVer = UIDevice.current.systemVersion
if currSysVer.compare(reqSysVer, options: .numeric) != .orderedAscending {
MTDisplay.supported = true
}
#else
MTDisplay.supported = true
#endif
MTDisplay.initialized = true
}
return MTDisplay.supported
}
protocol DownShift {
var shiftDown:CGFloat { set get }
}
// MARK: - MTDisplay
/// The base class for rendering a math equation.
class MTDisplay {
// needed for isIos6Supported() func above
static var initialized = false
static var supported = false
/// Draws itself in the given graphics context.
func draw(_ context:CGContext) {
if (self.localBackgroundColor != nil) {
context.saveGState()
context.setBlendMode(.normal)
context.setFillColor(self.localBackgroundColor!.cgColor)
context.fill(self.displayBounds())
context.restoreGState()
}
}
/// Gets the bounding rectangle for the MTDisplay
func displayBounds() -> CGRect {
return CGRectMake(self.position.x, self.position.y - self.descent, self.width, self.ascent + self.descent)
}
/// For debugging. Shows the object in quick look in Xcode.
#if os(iOS)
func debugQuickLookObject() -> Any {
let size = CGSizeMake(self.width, self.ascent + self.descent);
UIGraphicsBeginImageContext(size);
// get a reference to that context we created
let context = UIGraphicsGetCurrentContext()!
// translate/flip the graphics context (for transforming from CG* coords to UI* coords
context.translateBy(x: 0, y: size.height);
context.scaleBy(x: 1.0, y: -1.0);
// move the position to (0,0)
context.translateBy(x: -self.position.x, y: -self.position.y);
// Move the line up by self.descent
context.translateBy(x: 0, y: self.descent);
// Draw self on context
self.draw(context)
// generate a new UIImage from the graphics context we drew onto
let img = UIGraphicsGetImageFromCurrentImageContext()
return img as Any
}
#endif
/// The distance from the axis to the top of the display
var ascent:CGFloat = 0
/// The distance from the axis to the bottom of the display
var descent:CGFloat = 0
/// The width of the display
var width:CGFloat = 0
/// Position of the display with respect to the parent view or display.
var position=CGPoint.zero
/// The range of characters supported by this item
var range:NSRange=NSMakeRange(0, 0)
/// Whether the display has a subscript/superscript following it.
var hasScript:Bool = false
/// The text color for this display
var textColor: MTColor?
/// The local color, if the color was mutated local with the color command
var localTextColor: MTColor?
/// The background color for this display
var localBackgroundColor: MTColor?
}
class MTDisplayDS : MTDisplay, DownShift {
var shiftDown: CGFloat = 0
}
// MARK: - MTCTLineDisplay
/// A rendering of a single CTLine as an MTDisplay
class MTCTLineDisplay : MTDisplay {
/// The CTLine being displayed
var line:CTLine!
/// The attributed string used to generate the CTLineRef. Note setting this does not reset the dimensions of
/// the display. So set only when
var attributedString:NSAttributedString?
/// An array of MTMathAtoms that this CTLine displays. Used for indexing back into the MTMathList
var atoms = [MTMathAtom]()
init(withString attrString:NSAttributedString?, position:CGPoint, range:NSRange, font:MTFont?, atoms:[MTMathAtom]) {
super.init()
self.position = position
self.attributedString = attrString
self.range = range
self.atoms = atoms
// We can't use typographic bounds here as the ascent and descent returned are for the font and not for the line.
self.width = CTLineGetTypographicBounds(line, nil, nil, nil);
if isIos6Supported() {
let bounds = CTLineGetBoundsWithOptions(line, .useGlyphPathBounds)
self.ascent = max(0, CGRectGetMaxY(bounds) - 0);
self.descent = max(0, 0 - CGRectGetMinY(bounds));
// TODO: Should we use this width vs the typographic width? They are slightly different. Don't know why.
// _width = CGRectGetMaxX(bounds);
} else {
// Our own implementation of the ios6 function to get glyph path bounds.
self.computeDimensions(font)
}
}
func set(attrString: NSAttributedString?) {
attributedString = attrString
line = CTLineCreateWithAttributedString(attributedString!)
}
func set(textColor:MTColor) {
self.textColor = textColor
let attrStr = NSMutableAttributedString(attributedString: self.attributedString!)
let foregroundColor = NSAttributedString.Key(kCTForegroundColorAttributeName as String)
attrStr.addAttribute(foregroundColor, value:self.textColor!.cgColor, range:NSMakeRange(0, attrStr.length))
self.attributedString = attrStr
}
func computeDimensions(_ font:MTFont?) {
let runs = CTLineGetGlyphRuns(line) as NSArray
for obj in runs {
let run = obj as! CTRun?
let numGlyphs = CTRunGetGlyphCount(run!)
var glyphs = [CGGlyph]()
glyphs.reserveCapacity(numGlyphs)
CTRunGetGlyphs(run!, CFRangeMake(0, numGlyphs), &glyphs);
let bounds = CTFontGetBoundingRectsForGlyphs(font!.ctFont, .horizontal, glyphs, nil, numGlyphs);
let ascent = max(0, CGRectGetMaxY(bounds) - 0);
// Descent is how much the line goes below the origin. However if the line is all above the origin, then descent can't be negative.
let descent = max(0, 0 - CGRectGetMinY(bounds));
if (ascent > self.ascent) {
self.ascent = ascent;
}
if (descent > self.descent) {
self.descent = descent;
}
}
}
override func draw(_ context: CGContext) {
context.saveGState()
context.textPosition = self.position
CTLineDraw(line, context)
context.restoreGState()
}
}
// MARK: - MTMathListDisplay
/// An MTLine is a rendered form of MTMathList in one line.
/// It can render itself using the draw method.
class MTMathListDisplay : MTDisplay {
/**
@typedef MTLinePosition
@brief The type of position for a line, i.e. subscript/superscript or regular.
*/
enum LinePosition : Int {
/// Regular
case regular
/// Positioned at a subscript
case ssubscript
/// Positioned at a superscript
case superscript
}
/// Where the line is positioned
var type:LinePosition = .regular
/// An array of MTDisplays which are positioned relative to the position of the
/// the current display.
var subDisplays = [MTDisplay]()
/// If a subscript or superscript this denotes the location in the parent MTList. For a
/// regular list this is NSNotFound
var index: Int = 0
init(withDisplays displays:[MTDisplay], range:NSRange) {
super.init()
self.subDisplays = displays
self.position = CGPoint.zero
self.type = .regular //kMTLinePositionRegular;
self.index = NSNotFound
self.range = range
self.recomputeDimensions()
}
func setType(_ type:LinePosition) {
self.type = type
}
func setIndex(_ index:Int) {
self.index = index
}
func setTextColor(_ textColor:MTColor) {
// Set the color on all subdisplays
self.textColor = textColor
for displayAtom in self.subDisplays {
displayAtom.textColor = textColor
}
}
func draw(context: CGContext) {
context.saveGState()
// Make the current position the origin as all the positions of the sub atoms are relative to the origin.
context.translateBy(x: self.position.x, y: self.position.y)
context.textPosition = CGPoint.zero
// draw each atom separately
for displayAtom in self.subDisplays {
displayAtom.draw(context)
}
context.restoreGState()
}
func recomputeDimensions() {
var max_ascent:CGFloat = 0
var max_descent:CGFloat = 0
var max_width:CGFloat = 0
for atom in self.subDisplays {
let ascent = max(0, atom.position.y + atom.ascent);
if (ascent > max_ascent) {
max_ascent = ascent;
}
let descent = max(0, 0 - (atom.position.y - atom.descent));
if (descent > max_descent) {
max_descent = descent;
}
let width = atom.width + atom.position.x;
if (width > max_width) {
max_width = width;
}
}
self.ascent = max_ascent;
self.descent = max_descent;
self.width = max_width;
}
}
// MARK: - MTFractionDisplay
/// Rendering of an MTFraction as an MTDisplay
class MTFractionDisplay : MTDisplay {
/** A display representing the numerator of the fraction. Its position is relative
to the parent and is not treated as a sub-display.
*/
var numerator:MTMathListDisplay?
/** A display representing the denominator of the fraction. Its position is relative
to the parent is not treated as a sub-display.
*/
var denominator:MTMathListDisplay?
var numeratorUp:CGFloat=0 {
didSet { self.updateNumeratorPosition() }
}
var denominatorDown:CGFloat=0 {
didSet { self.updateDenominatorPosition() }
}
var linePosition:CGFloat=0
var lineThickness:CGFloat=0
init(withNumerator numerator:MTMathListDisplay?, denominator:MTMathListDisplay?, position:CGPoint, range:NSRange) {
super.init()
self.numerator = numerator;
self.denominator = denominator;
self.position = position;
self.range = range;
assert(self.range.length == 1, "Fraction range length not 1 - range (\(range.location), \(range.length)")
}
override var ascent:CGFloat {
set { super.ascent = newValue }
get { numerator!.ascent + self.numeratorUp }
}
override var descent:CGFloat {
set { super.descent = newValue }
get { denominator!.descent + self.denominatorDown }
}
override var width:CGFloat {
set { super.width = newValue }
get { max(numerator!.width, denominator!.width) }
}
func updateDenominatorPosition() {
denominator?.position = CGPointMake(self.position.x + (self.width - denominator!.width)/2, self.position.y - self.denominatorDown)
}
func updateNumeratorPosition() {
numerator?.position = CGPointMake(self.position.x + (self.width - numerator!.width)/2, self.position.y + self.numeratorUp)
}
func setPosition(_ position: CGPoint) {
super.position = position
self.updateDenominatorPosition()
self.updateNumeratorPosition()
}
func setTextColor(_ textColor:MTColor) {
super.textColor = textColor
numerator?.textColor = textColor
denominator?.textColor = textColor
}
override func draw(_ context:CGContext) {
numerator?.draw(context)
denominator?.draw(context)
context.saveGState()
self.textColor?.setStroke()
// draw the horizontal line
let path = MTBezierPath()
path.move(to: CGPointMake(self.position.x, self.position.y + self.linePosition))
path.addLine(to: CGPointMake(self.position.x + self.width, self.position.y + self.linePosition))
path.lineWidth = self.lineThickness
path.stroke()
context.restoreGState()
}
}
// MARK: - MTRadicalDisplay
/// Rendering of an MTRadical as an MTDisplay
class MTRadicalDisplay : MTDisplay {
/** A display representing the radicand of the radical. Its position is relative
to the parent is not treated as a sub-display.
*/
var radicand:MTMathListDisplay?
/** A display representing the degree of the radical. Its position is relative
to the parent is not treated as a sub-display.
*/
var degree:MTMathListDisplay?
private var _radicalGlyph:MTDisplay?
private var _radicalShift:CGFloat=0
var topKern:CGFloat=0
var lineThickness:CGFloat=0
init(withRadicand radicand:MTMathListDisplay?, glyph:MTDisplay, position:CGPoint, range:NSRange) {
super.init()
self.radicand = radicand
_radicalGlyph = glyph
_radicalShift = 0
self.position = position
self.range = range
}
func setDegree(_ degree:MTMathListDisplay?, fontMetrics:MTFontMathTable?) {
// sets up the degree of the radical
var kernBefore = fontMetrics!.radicalKernBeforeDegree;
let kernAfter = fontMetrics!.radicalKernAfterDegree;
let raise = fontMetrics!.radicalDegreeBottomRaisePercent * (self.ascent - self.descent);
// The layout is:
// kernBefore, raise, degree, kernAfter, radical
self.degree = degree;
// the radical is now shifted by kernBefore + degree.width + kernAfter
_radicalShift = kernBefore + degree!.width + kernAfter;
if _radicalShift < 0 {
// we can't have the radical shift backwards, so instead we increase the kernBefore such
// that _radicalShift will be 0.
kernBefore -= _radicalShift;
_radicalShift = 0;
}
// Note: position of degree is relative to parent.
self.degree!.position = CGPointMake(self.position.x + kernBefore, self.position.y + raise);
// Update the width by the _radicalShift
self.width = _radicalShift + _radicalGlyph!.width + self.radicand!.width;
// update the position of the radicand
self.updateRadicandPosition()
}
func setPosition(_ position:CGPoint) {
super.position = position
self.updateRadicandPosition()
}
func updateRadicandPosition() {
// The position of the radicand includes the position of the MTRadicalDisplay
// This is to make the positioning of the radical consistent with fractions and
// have the cursor position finding algorithm work correctly.
// move the radicand by the width of the radical sign
self.radicand!.position = CGPointMake(self.position.x + _radicalShift + _radicalGlyph!.width, self.position.y);
}
func setTextColor(textColor:MTColor) {
super.textColor = textColor
self.radicand!.textColor = textColor
self.degree!.textColor = textColor
}
func draw(context: CGContext) {
// draw the radicand & degree at its position
self.radicand?.draw(context)
self.degree?.draw(context)
context.saveGState();
self.textColor?.setStroke()
self.textColor?.setFill()
// Make the current position the origin as all the positions of the sub atoms are relative to the origin.
context.translateBy(x: self.position.x + _radicalShift, y: self.position.y);
context.textPosition = CGPoint.zero
// Draw the glyph.
_radicalGlyph?.draw(context)
// Draw the VBOX
// for the kern of, we don't need to draw anything.
let heightFromTop = topKern;
// draw the horizontal line with the given thickness
let path = MTBezierPath()
let lineStart = CGPointMake(_radicalGlyph!.width, self.ascent - heightFromTop - self.lineThickness / 2); // subtract half the line thickness to center the line
let lineEnd = CGPointMake(lineStart.x + self.radicand!.width, lineStart.y);
path.move(to: lineStart)
path.addLine(to: lineEnd)
path.lineWidth = lineThickness
path.lineCapStyle = .round
path.stroke()
context.restoreGState();
}
}
// MARK: - MTGlyphDisplay
/// Rendering a glyph as a display
class MTGlyphDisplay : MTDisplayDS {
var glyph:CGGlyph!
var font:MTFont?
init(withGlpyh glyph:CGGlyph, range:NSRange, font:MTFont?) {
super.init()
self.font = font
self.glyph = glyph
self.position = CGPoint.zero
self.range = range
}
func draw(context: CGContext) {
context.saveGState();
self.textColor?.setFill()
// Make the current position the origin as all the positions of the sub atoms are relative to the origin.
context.translateBy(x: self.position.x, y: self.position.y - self.shiftDown);
context.textPosition = CGPoint.zero
var pos = CGPoint.zero
CTFontDrawGlyphs(font!.ctFont, &glyph, &pos, 1, context);
context.restoreGState();
}
override var ascent:CGFloat {
set {
super.ascent = newValue
}
get {
return super.ascent - self.shiftDown;
}
}
override var descent:CGFloat {
set {
super.descent = newValue
}
get {
return super.descent + self.shiftDown;
}
}
}
// MARK: - MTGlyphConstructionDisplay
class MTGlyphConstructionDisplay:MTDisplayDS {
var glyphs = [CGGlyph]()
var positions = [CGPoint]()
var font:MTFont?
var numGlyphs:Int=0
init(withGlyphs glyphs:[NSNumber?], offsets:[NSNumber?], font:MTFont?) {
super.init()
assert(glyphs.count == offsets.count, "Glyphs and offsets need to match")
self.numGlyphs = glyphs.count;
self.glyphs = [CGGlyph](repeating: CGGlyph(), count: self.numGlyphs) //malloc(sizeof(CGGlyph) * _numGlyphs);
self.positions = [CGPoint](repeating: CGPoint.zero, count: self.numGlyphs) //malloc(sizeof(CGPoint) * _numGlyphs);
for i in 0 ..< self.numGlyphs {
self.glyphs[i] = glyphs[i]!.uint16Value
self.positions[i] = CGPointMake(0, CGFloat(offsets[i]!.floatValue))
}
self.font = font
self.position = CGPoint.zero
}
override func draw(_ context: CGContext) {
context.saveGState()
self.textColor?.setFill()
// Make the current position the origin as all the positions of the sub atoms are relative to the origin.
context.translateBy(x: self.position.x, y: self.position.y - self.shiftDown)
context.textPosition = CGPoint.zero
// Draw the glyphs.
CTFontDrawGlyphs(font!.ctFont, glyphs, positions, numGlyphs, context)
context.restoreGState()
}
override var ascent:CGFloat {
set {
super.ascent = newValue
}
get {
return super.ascent - self.shiftDown;
}
}
override var descent:CGFloat {
set {
super.descent = newValue
}
get {
return super.descent + self.shiftDown;
}
}
}
// MARK: - MTLargeOpLimitsDisplay
/// Rendering a large operator with limits as an MTDisplay
class MTLargeOpLimitsDisplay : MTDisplay {
/** A display representing the upper limit of the large operator. Its position is relative
to the parent is not treated as a sub-display.
*/
var upperLimit:MTMathListDisplay?
/** A display representing the lower limit of the large operator. Its position is relative
to the parent is not treated as a sub-display.
*/
var lowerLimit:MTMathListDisplay?
var limitShift:CGFloat=0
var upperLimitGap:CGFloat=0 {
didSet {
self.updateUpperLimitPosition()
}
}
var lowerLimitGap:CGFloat=0 {
didSet {
self.updateUpperLimitPosition()
}
}
var extraPadding:CGFloat=0
var nucleus:MTDisplay?
init(withNucleus nucleus:MTDisplay?, upperLimit:MTMathListDisplay?, lowerLimit:MTMathListDisplay?, limitShift:CGFloat, extraPadding:CGFloat) {
super.init()
self.upperLimit = upperLimit;
self.lowerLimit = lowerLimit;
self.nucleus = nucleus;
var maxWidth = max(nucleus!.width, upperLimit!.width);
maxWidth = max(maxWidth, lowerLimit!.width);
self.limitShift = limitShift;
self.upperLimitGap = 0;
self.lowerLimitGap = 0;
self.extraPadding = extraPadding; // corresponds to \xi_13 in TeX
self.width = maxWidth;
}
override var ascent:CGFloat {
set {
super.ascent = newValue
}
get {
if self.upperLimit != nil {
return nucleus!.ascent + extraPadding + self.upperLimit!.ascent + upperLimitGap + self.upperLimit!.descent
} else {
return nucleus!.ascent
}
}
}
override var descent:CGFloat {
set {
super.descent = newValue
}
get {
if self.lowerLimit != nil {
return nucleus!.descent + extraPadding + lowerLimitGap + self.lowerLimit!.descent + self.lowerLimit!.ascent;
} else {
return nucleus!.descent;
}
}
}
func setPosition(_ position:CGPoint) {
super.position = position;
self.updateLowerLimitPosition()
self.updateUpperLimitPosition()
self.updateNucleusPosition()
}
func updateLowerLimitPosition() {
if self.lowerLimit != nil {
// The position of the lower limit includes the position of the MTLargeOpLimitsDisplay
// This is to make the positioning of the radical consistent with fractions and radicals
// Move the starting point to below the nucleus leaving a gap of _lowerLimitGap and subtract
// the ascent to to get the baseline. Also center and shift it to the left by _limitShift.
self.lowerLimit!.position = CGPointMake(self.position.x - limitShift + (self.width - lowerLimit!.width)/2,
self.position.y - nucleus!.descent - lowerLimitGap - self.lowerLimit!.ascent);
}
}
func updateUpperLimitPosition() {
if self.upperLimit != nil {
// The position of the upper limit includes the position of the MTLargeOpLimitsDisplay
// This is to make the positioning of the radical consistent with fractions and radicals
// Move the starting point to above the nucleus leaving a gap of _upperLimitGap and add
// the descent to to get the baseline. Also center and shift it to the right by _limitShift.
self.upperLimit!.position = CGPointMake(self.position.x + limitShift + (self.width - self.upperLimit!.width)/2,
self.position.y + nucleus!.ascent + upperLimitGap + self.upperLimit!.descent);
}
}
func updateNucleusPosition() {
// Center the nucleus
nucleus?.position = CGPointMake(self.position.x + (self.width - nucleus!.width)/2, self.position.y);
}
func setTextColor(_ textColor:MTColor) {
super.textColor = textColor
self.upperLimit?.textColor = textColor
self.lowerLimit?.textColor = textColor
nucleus?.textColor = textColor
}
override func draw(_ context:CGContext) {
// Draw the elements.
self.upperLimit?.draw(context)
self.lowerLimit?.draw(context)
nucleus?.draw(context)
}
}
// MARK: - MTLineDisplay
/// Rendering of an list with an overline or underline
class MTLineDisplay : MTDisplay {
/** A display representing the inner list that is underlined. Its position is relative
to the parent is not treated as a sub-display.
*/
var inner:MTMathListDisplay?
var lineShiftUp:CGFloat=0
var lineThickness:CGFloat=0
init(withInner inner:MTMathListDisplay?, position:CGPoint, range:NSRange) {
super.init()
self.inner = inner;
self.position = position;
self.range = range;
}
func setTextColor(_ textColor:MTColor) {
super.textColor = textColor
inner?.textColor = textColor
}
override func draw(_ context:CGContext) {
self.inner?.draw(context)
context.saveGState();
self.textColor?.setStroke()
// draw the horizontal line
let path = MTBezierPath()
let lineStart = CGPointMake(self.position.x, self.position.y + self.lineShiftUp);
let lineEnd = CGPointMake(lineStart.x + self.inner!.width, lineStart.y);
path.move(to:lineStart)
path.addLine(to: lineEnd)
path.lineWidth = self.lineThickness;
path.stroke()
context.restoreGState();
}
func setPosition(_ position:CGPoint) {
super.position = position;
self.updateInnerPosition()
}
func updateInnerPosition() {
self.inner?.position = CGPointMake(self.position.x, self.position.y);
}
}
// MARK: - MTAccentDisplay
/// Rendering an accent as a display
class MTAccentDisplay : MTDisplay {
/** A display representing the inner list that is accented. Its position is relative
to the parent is not treated as a sub-display.
*/
var accentee:MTMathListDisplay?
/** A display representing the accent. Its position is relative to the current display.
*/
var accent:MTGlyphDisplay?
init(withAccent glyph:MTGlyphDisplay?, accentee:MTMathListDisplay?, range:NSRange) {
super.init()
self.accent = glyph
self.accentee = accentee
self.accentee?.position = CGPoint.zero
self.range = range
}
func setTextColor(_ textColor:MTColor) {
super.textColor = textColor
accentee?.textColor = textColor
accent?.textColor = textColor
}
func setPosition(_ position:CGPoint) {
super.position = position
self.updateAccenteePosition()
}
func updateAccenteePosition() {
self.accentee?.position = CGPointMake(self.position.x, self.position.y);
}
override func draw(_ context:CGContext) {
self.accentee?.draw(context)
context.saveGState();
context.translateBy(x: self.position.x, y: self.position.y);
context.textPosition = CGPoint.zero
self.accent?.draw(context: context)
context.restoreGState();
}
}