[multiple lines] inter atoms line breaking support
This commit is contained in:
@@ -479,6 +479,75 @@ class MTTypesetter {
|
|||||||
}
|
}
|
||||||
self.currentPosition.x += interElementSpace
|
self.currentPosition.x += interElementSpace
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Interatom Line Breaking
|
||||||
|
|
||||||
|
/// Calculate the width that would result from adding this atom to the current line
|
||||||
|
/// Returns the approximate width including inter-element spacing
|
||||||
|
func calculateAtomWidth(_ atom: MTMathAtom, prevNode: MTMathAtom?) -> CGFloat {
|
||||||
|
// Calculate inter-element spacing
|
||||||
|
var interElementSpace: CGFloat = 0
|
||||||
|
if prevNode != nil {
|
||||||
|
interElementSpace = getInterElementSpace(prevNode!.type, right: atom.type)
|
||||||
|
} else if self.spaced {
|
||||||
|
interElementSpace = getInterElementSpace(.open, right: atom.type)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate the width of the atom's nucleus
|
||||||
|
let atomString = NSAttributedString(string: atom.nucleus, attributes: [
|
||||||
|
kCTFontAttributeName as NSAttributedString.Key: styleFont.ctFont as Any
|
||||||
|
])
|
||||||
|
let ctLine = CTLineCreateWithAttributedString(atomString as CFAttributedString)
|
||||||
|
let atomWidth = CGFloat(CTLineGetTypographicBounds(ctLine, nil, nil, nil))
|
||||||
|
|
||||||
|
return interElementSpace + atomWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate the current line width
|
||||||
|
func getCurrentLineWidth() -> CGFloat {
|
||||||
|
if currentLine.length == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
let attrString = currentLine.mutableCopy() as! NSMutableAttributedString
|
||||||
|
attrString.addAttribute(kCTFontAttributeName as NSAttributedString.Key, value: styleFont.ctFont as Any, range: NSMakeRange(0, attrString.length))
|
||||||
|
let ctLine = CTLineCreateWithAttributedString(attrString)
|
||||||
|
return CGFloat(CTLineGetTypographicBounds(ctLine, nil, nil, nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if we should break to a new line before adding this atom
|
||||||
|
/// Returns true if a line break was performed
|
||||||
|
@discardableResult
|
||||||
|
func checkAndPerformInteratomLineBreak(_ atom: MTMathAtom, prevNode: MTMathAtom?) -> Bool {
|
||||||
|
// Only perform interatom breaking when maxWidth is set
|
||||||
|
guard maxWidth > 0 else { return false }
|
||||||
|
|
||||||
|
// Don't break if current line is empty
|
||||||
|
guard currentLine.length > 0 else { return false }
|
||||||
|
|
||||||
|
// Calculate what the width would be if we add this atom
|
||||||
|
let currentLineWidth = getCurrentLineWidth()
|
||||||
|
let atomWidth = calculateAtomWidth(atom, prevNode: prevNode)
|
||||||
|
let projectedWidth = currentLineWidth + atomWidth
|
||||||
|
|
||||||
|
// If projected width exceeds max width, flush current line and start new one
|
||||||
|
if projectedWidth > maxWidth {
|
||||||
|
// Flush the current line
|
||||||
|
self.addDisplayLine()
|
||||||
|
|
||||||
|
// Move down for new line
|
||||||
|
currentPosition.y -= styleFont.fontSize * 1.5
|
||||||
|
currentPosition.x = 0
|
||||||
|
|
||||||
|
// Reset for new line
|
||||||
|
currentLine = NSMutableAttributedString()
|
||||||
|
currentAtoms = []
|
||||||
|
currentLineIndexRange = NSMakeRange(NSNotFound, NSNotFound)
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func createDisplayAtoms(_ preprocessed:[MTMathAtom]) {
|
func createDisplayAtoms(_ preprocessed:[MTMathAtom]) {
|
||||||
// items should contain all the nodes that need to be layed out.
|
// items should contain all the nodes that need to be layed out.
|
||||||
@@ -772,6 +841,10 @@ class MTTypesetter {
|
|||||||
case .ordinary, .binaryOperator, .relation, .open, .close, .placeholder, .punctuation:
|
case .ordinary, .binaryOperator, .relation, .open, .close, .placeholder, .punctuation:
|
||||||
// the rendering for all the rest is pretty similar
|
// the rendering for all the rest is pretty similar
|
||||||
// All we need is render the character and set the interelement space.
|
// All we need is render the character and set the interelement space.
|
||||||
|
|
||||||
|
// INTERATOM LINE BREAKING: Check if we need to break before adding this atom
|
||||||
|
checkAndPerformInteratomLineBreak(atom, prevNode: prevNode)
|
||||||
|
|
||||||
if prevNode != nil {
|
if prevNode != nil {
|
||||||
let interElementSpace = self.getInterElementSpace(prevNode!.type, right:atom.type)
|
let interElementSpace = self.getInterElementSpace(prevNode!.type, right:atom.type)
|
||||||
if currentLine.length > 0 {
|
if currentLine.length > 0 {
|
||||||
|
|||||||
@@ -1572,5 +1572,142 @@ final class MTTypesetterTests: XCTestCase {
|
|||||||
XCTAssertEqual(display.width, 44.86, accuracy: 0.01)
|
XCTAssertEqual(display.width, 44.86, accuracy: 0.01)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Interatom Line Breaking Tests
|
||||||
|
|
||||||
|
func testInteratomLineBreaking_SimpleEquation() throws {
|
||||||
|
// Simple equation that should break between atoms when width is constrained
|
||||||
|
let latex = "a=1, b=2, c=3, d=4"
|
||||||
|
let mathList = MTMathListBuilder.build(fromString: latex)
|
||||||
|
XCTAssertNotNil(mathList, "Failed to parse LaTeX")
|
||||||
|
|
||||||
|
// Create display with narrow width constraint (should force multiple lines)
|
||||||
|
let maxWidth: CGFloat = 100
|
||||||
|
let display = MTTypesetter.createLineForMathList(mathList, font: self.font, style: .display, maxWidth: maxWidth)
|
||||||
|
XCTAssertNotNil(display)
|
||||||
|
|
||||||
|
// Should have multiple sub-displays (lines)
|
||||||
|
XCTAssertGreaterThan(display!.subDisplays.count, 1, "Expected multiple lines with width constraint of \(maxWidth)")
|
||||||
|
|
||||||
|
// Verify that each line respects the width constraint
|
||||||
|
for (index, subDisplay) in display!.subDisplays.enumerated() {
|
||||||
|
XCTAssertLessThanOrEqual(subDisplay.width, maxWidth * 1.1, "Line \(index) width \(subDisplay.width) exceeds maxWidth \(maxWidth)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify vertical positioning - lines should be below each other
|
||||||
|
if display!.subDisplays.count > 1 {
|
||||||
|
let firstLine = display!.subDisplays[0]
|
||||||
|
let secondLine = display!.subDisplays[1]
|
||||||
|
XCTAssertLessThan(secondLine.position.y, firstLine.position.y, "Second line should be positioned below first line")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testInteratomLineBreaking_TextAndMath() throws {
|
||||||
|
// The user's specific example: text mixed with math
|
||||||
|
let latex = "\\text{Calculer le discriminant }\\Delta=b^{2}-4ac\\text{ avec }a=1\\text{, }b=-1\\text{, }c=-5"
|
||||||
|
let mathList = MTMathListBuilder.build(fromString: latex)
|
||||||
|
XCTAssertNotNil(mathList, "Failed to parse LaTeX")
|
||||||
|
|
||||||
|
// Create display with width constraint of 235 as specified by user
|
||||||
|
let maxWidth: CGFloat = 235
|
||||||
|
let display = MTTypesetter.createLineForMathList(mathList, font: self.font, style: .display, maxWidth: maxWidth)
|
||||||
|
XCTAssertNotNil(display)
|
||||||
|
|
||||||
|
// Should have multiple lines
|
||||||
|
XCTAssertGreaterThan(display!.subDisplays.count, 1, "Expected multiple lines with width \(maxWidth) for the given LaTeX")
|
||||||
|
|
||||||
|
// Verify each line respects width constraint
|
||||||
|
for (index, subDisplay) in display!.subDisplays.enumerated() {
|
||||||
|
// Allow 10% tolerance for spacing and rounding
|
||||||
|
XCTAssertLessThanOrEqual(subDisplay.width, maxWidth * 1.1,
|
||||||
|
"Line \(index) width \(subDisplay.width) exceeds maxWidth \(maxWidth)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify vertical spacing between lines
|
||||||
|
if display!.subDisplays.count >= 2 {
|
||||||
|
let firstLine = display!.subDisplays[0]
|
||||||
|
let secondLine = display!.subDisplays[1]
|
||||||
|
let verticalSpacing = abs(firstLine.position.y - secondLine.position.y)
|
||||||
|
XCTAssertGreaterThan(verticalSpacing, 0, "Lines should have vertical spacing")
|
||||||
|
// Typical line height is around 1.5 * font size
|
||||||
|
XCTAssertGreaterThan(verticalSpacing, self.font.fontSize * 0.5, "Vertical spacing seems too small")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testInteratomLineBreaking_BreaksAtAtomBoundaries() throws {
|
||||||
|
// Test that breaking happens between atoms, not within them
|
||||||
|
// Using mathematical atoms separated by operators
|
||||||
|
let latex = "a+b+c+d+e+f+g+h+i+j+k+l+m+n+o+p"
|
||||||
|
let mathList = MTMathListBuilder.build(fromString: latex)
|
||||||
|
XCTAssertNotNil(mathList, "Failed to parse LaTeX")
|
||||||
|
|
||||||
|
// Create display with narrow width that should force breaking
|
||||||
|
let maxWidth: CGFloat = 120
|
||||||
|
let display = MTTypesetter.createLineForMathList(mathList, font: self.font, style: .display, maxWidth: maxWidth)
|
||||||
|
XCTAssertNotNil(display)
|
||||||
|
|
||||||
|
// Should have multiple lines
|
||||||
|
XCTAssertGreaterThan(display!.subDisplays.count, 1, "Expected line breaking with narrow width")
|
||||||
|
|
||||||
|
// Each line should respect the width constraint (with some tolerance)
|
||||||
|
// since we break at atom boundaries, not mid-atom
|
||||||
|
for (index, subDisplay) in display!.subDisplays.enumerated() {
|
||||||
|
XCTAssertLessThanOrEqual(subDisplay.width, maxWidth * 1.2,
|
||||||
|
"Line \(index) width \(subDisplay.width) exceeds maxWidth \(maxWidth) by too much")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testInteratomLineBreaking_WithSuperscripts() throws {
|
||||||
|
// Test breaking with atoms that have superscripts
|
||||||
|
let latex = "a^{2}+b^{2}+c^{2}+d^{2}+e^{2}"
|
||||||
|
let mathList = MTMathListBuilder.build(fromString: latex)
|
||||||
|
XCTAssertNotNil(mathList, "Failed to parse LaTeX")
|
||||||
|
|
||||||
|
let maxWidth: CGFloat = 100
|
||||||
|
let display = MTTypesetter.createLineForMathList(mathList, font: self.font, style: .display, maxWidth: maxWidth)
|
||||||
|
XCTAssertNotNil(display)
|
||||||
|
|
||||||
|
// Should handle superscripts properly and create multiple lines if needed
|
||||||
|
for (index, subDisplay) in display!.subDisplays.enumerated() {
|
||||||
|
XCTAssertLessThanOrEqual(subDisplay.width, maxWidth * 1.1,
|
||||||
|
"Line \(index) with superscripts exceeds width")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testInteratomLineBreaking_NoBreakingWhenNotNeeded() throws {
|
||||||
|
// Test that short content doesn't break unnecessarily
|
||||||
|
let latex = "a=b"
|
||||||
|
let mathList = MTMathListBuilder.build(fromString: latex)
|
||||||
|
XCTAssertNotNil(mathList, "Failed to parse LaTeX")
|
||||||
|
|
||||||
|
let maxWidth: CGFloat = 200
|
||||||
|
let display = MTTypesetter.createLineForMathList(mathList, font: self.font, style: .display, maxWidth: maxWidth)
|
||||||
|
XCTAssertNotNil(display)
|
||||||
|
|
||||||
|
// Should stay on single line since content is short
|
||||||
|
// Note: The number of subDisplays might be 1 or more depending on internal structure,
|
||||||
|
// but the total width should be well under maxWidth
|
||||||
|
XCTAssertLessThan(display!.width, maxWidth, "Short content should fit without breaking")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testInteratomLineBreaking_BreaksAfterOperators() throws {
|
||||||
|
// Test that breaking prefers to happen after operators (good break points)
|
||||||
|
let latex = "a+b+c+d+e+f+g+h"
|
||||||
|
let mathList = MTMathListBuilder.build(fromString: latex)
|
||||||
|
XCTAssertNotNil(mathList, "Failed to parse LaTeX")
|
||||||
|
|
||||||
|
let maxWidth: CGFloat = 80
|
||||||
|
let display = MTTypesetter.createLineForMathList(mathList, font: self.font, style: .display, maxWidth: maxWidth)
|
||||||
|
XCTAssertNotNil(display)
|
||||||
|
|
||||||
|
// Should break into multiple lines
|
||||||
|
XCTAssertGreaterThan(display!.subDisplays.count, 1, "Expected multiple lines")
|
||||||
|
|
||||||
|
// Verify width constraints
|
||||||
|
for (index, subDisplay) in display!.subDisplays.enumerated() {
|
||||||
|
XCTAssertLessThanOrEqual(subDisplay.width, maxWidth * 1.1,
|
||||||
|
"Line \(index) exceeds width")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user