Skip to content

Commit d9e24c9

Browse files
kostubclaude
andauthored
Fix array rule corner overhang: outer vertical rules flush with horizontal rules (#261)
Fix array rule corner overhang: outer v-rules flush with h-rules Horizontal rules span the full content box [0, contentWidth], but columnOffsetsForTable: added padding on *both* sides of every vertical rule — including outside the outermost rules. That inset the outer verticals by `padding` from the box edges, so every \hline overhung the outer `|` by `padding` on each side (visible as horizontal lines poking past the box corners; MathJax/LaTeX meet flush). Drop the outside padding on the outer boundaries (i==0, i==numColumns): padding now lives only between a rule and the cell content, so the outermost rules sit flush at x==0 / x==contentWidth — the same edges the horizontal rules span. Interior boundaries keep padding on both sides. Matrix path is untouched (fires only when verticalLines[i] > 0). testArrayRuleGeometryIsDeterministic now pins the flush-corner invariant: the leftmost v-rule stroke edge is at x=0 and each h-rule's ends coincide with the outer v-rules' stroke edges instead of overhanging. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 57f867f commit d9e24c9

4 files changed

Lines changed: 72 additions & 29 deletions

File tree

iosMath/render/MTMathListDisplay.m

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -799,13 +799,19 @@ - (instancetype)initWithStart:(CGPoint)start length:(CGFloat)length thickness:(C
799799
_length = length;
800800
_thickness = thickness;
801801
_vertical = isVertical;
802-
self.position = start;
803802
self.range = range;
803+
// `start` is a point on the stroke's centre-line; a stroked path straddles it by
804+
// thickness/2 on each side of the thickness axis. Record `position` as the box's
805+
// lower-left origin so the display's bounds exactly cover the drawn stroke (else the
806+
// parent over-reports its width/ascent by thickness/2 — see -draw:, which re-derives
807+
// the centre-line). The length axis uses a butt cap, so it isn't inset.
804808
if (isVertical) {
809+
self.position = CGPointMake(start.x - thickness / 2, start.y);
805810
self.width = thickness;
806811
self.ascent = length;
807812
self.descent = 0;
808813
} else {
814+
self.position = CGPointMake(start.x, start.y - thickness / 2);
809815
self.width = length;
810816
self.ascent = thickness;
811817
self.descent = 0;
@@ -820,10 +826,15 @@ - (void)draw:(CGContextRef)context
820826
CGContextSaveGState(context);
821827
[self.textColor setStroke];
822828
MTBezierPath* path = [MTBezierPath bezierPath];
823-
[path moveToPoint:self.position];
829+
// position is the box origin; the stroke runs along the centre-line, offset thickness/2
830+
// from the box edge on the thickness axis (the axis init inset when recording position).
831+
CGPoint begin = _vertical
832+
? CGPointMake(self.position.x + _thickness / 2, self.position.y)
833+
: CGPointMake(self.position.x, self.position.y + _thickness / 2);
834+
[path moveToPoint:begin];
824835
CGPoint end = _vertical
825-
? CGPointMake(self.position.x, self.position.y + _length)
826-
: CGPointMake(self.position.x + _length, self.position.y);
836+
? CGPointMake(begin.x, begin.y + _length)
837+
: CGPointMake(begin.x + _length, begin.y);
827838
[path addLineToPoint:end];
828839
path.lineWidth = _thickness;
829840
[path stroke];

iosMath/render/internal/MTMathListDisplayInternal.h

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -142,9 +142,11 @@ NS_ASSUME_NONNULL_BEGIN
142142

143143
- (instancetype)init NS_UNAVAILABLE;
144144

145-
// `start` is in table-local coordinates. For a horizontal rule `length` extends
146-
// rightward (+x); for a vertical rule it extends upward (+y). Stroke is centred on
147-
// the path, so callers offset `start` by thickness/2 where edge alignment matters.
145+
// `start` is a point on the stroke's centre-line, in table-local coordinates. For a
146+
// horizontal rule `length` extends rightward (+x); for a vertical rule it extends upward
147+
// (+y). The stroke straddles the centre-line by thickness/2, so the initializer records
148+
// `position` as the box's lower-left origin (inset by thickness/2 on the thickness axis);
149+
// callers pass the raw centre-line and must not pre-offset for edge alignment.
148150
- (instancetype) initWithStart:(CGPoint) start
149151
length:(CGFloat) length
150152
thickness:(CGFloat) thickness

iosMath/render/internal/MTTypesetter.m

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2343,8 +2343,12 @@ - (MTDisplay*) makeTable:(MTMathTable*) table
23432343

23442344
// Compute per-column start x-offsets shared by cells and vertical rules, so they cannot
23452345
// drift. For each vertical boundary i (0..numColumns) with verticalLines[i] > 0, widen the
2346-
// gap by 2·padding + the rule block and record each rule's centre x in vRuleXs[i]. With no
2347-
// rules this reduces to -makeRowWithColumns:'s exact running sum (matrix path unchanged).
2346+
// gap by the rule block plus padding and record each rule's centre x in vRuleXs[i]. Interior
2347+
// boundaries get padding on both sides; the outer boundaries (i==0, i==numColumns) get padding
2348+
// only on the inside so the outermost rules sit flush at x==0 / x==contentWidth — the same box
2349+
// edges the horizontal rules span, so corners meet instead of the h-rules overhanging (LaTeX
2350+
// behaviour). With no rules this reduces to -makeRowWithColumns:'s exact running sum (matrix
2351+
// path unchanged).
23482352
- (NSArray<NSNumber*>*) columnOffsetsForTable:(MTMathTable*) table
23492353
columnWidths:(CGFloat[]) columnWidths
23502354
thickness:(CGFloat) thickness
@@ -2364,13 +2368,16 @@ - (MTDisplay*) makeTable:(MTMathTable*) table
23642368
NSInteger count = (i < vLines.count) ? vLines[i].integerValue : 0;
23652369
NSMutableArray<NSNumber*>* xs = [NSMutableArray array];
23662370
if (count > 0) {
2367-
CGFloat ruleAreaStart = x + padding + base / 2;
2371+
// No padding outside the outermost rules so they sit flush at the box edges.
2372+
CGFloat padLeft = (i == 0) ? 0 : padding;
2373+
CGFloat padRight = (i == numColumns) ? 0 : padding;
2374+
CGFloat ruleAreaStart = x + padLeft + base / 2;
23682375
for (NSInteger k = 0; k < count; k++) {
23692376
CGFloat cx = ruleAreaStart + k * (thickness + ruleGap) + thickness / 2;
23702377
[xs addObject:@(cx)];
23712378
}
23722379
CGFloat ruleBlock = count * thickness + (count - 1) * ruleGap;
2373-
x += base + 2 * padding + ruleBlock;
2380+
x += base + padLeft + padRight + ruleBlock;
23742381
} else {
23752382
x += base;
23762383
}

iosMathTests/MTTypesetterTest.m

Lines changed: 41 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3441,26 +3441,28 @@ - (void) testAlignedatLayout
34413441

34423442
- (void)testRuleDisplayHorizontalAndVerticalMetrics
34433443
{
3444+
// `start` is a point on the stroke's centre-line. The box straddles it by thickness/2
3445+
// on the thickness axis, so position (the box origin) is inset by thickness/2 there.
34443446
MTRuleDisplay* h = [[MTRuleDisplay alloc] initWithStart:CGPointMake(2, 5)
34453447
length:10
34463448
thickness:0.6
34473449
vertical:NO
34483450
range:NSMakeRange(0, 1)];
3449-
XCTAssertEqual(h.position.x, 2);
3450-
XCTAssertEqual(h.position.y, 5);
3451+
XCTAssertEqual(h.position.x, 2); // length axis: start unchanged
3452+
XCTAssertEqual(h.position.y, 5 - 0.3); // thickness axis: centre-line 5 → box bottom 4.7
34513453
XCTAssertEqual(h.width, 10);
3452-
XCTAssertEqual(h.ascent, 0.6);
3454+
XCTAssertEqual(h.ascent, 0.6); // box spans y ∈ [4.7, 5.3], centred on 5
34533455
XCTAssertEqual(h.descent, 0);
34543456

34553457
MTRuleDisplay* v = [[MTRuleDisplay alloc] initWithStart:CGPointMake(3, -4)
34563458
length:12
34573459
thickness:0.6
34583460
vertical:YES
34593461
range:NSMakeRange(0, 1)];
3460-
XCTAssertEqual(v.position.x, 3);
3461-
XCTAssertEqual(v.position.y, -4);
3462-
XCTAssertEqual(v.width, 0.6);
3463-
XCTAssertEqual(v.ascent, 12); // top end = position.y + ascent
3462+
XCTAssertEqual(v.position.x, 3 - 0.3); // thickness axis: centre-line 3 → box left 2.7
3463+
XCTAssertEqual(v.position.y, -4); // length axis: start unchanged
3464+
XCTAssertEqual(v.width, 0.6); // box spans x ∈ [2.7, 3.3], centred on 3
3465+
XCTAssertEqual(v.ascent, 12); // top end = position.y + ascent
34643466
XCTAssertEqual(v.descent, 0);
34653467
}
34663468

@@ -3568,20 +3570,41 @@ - (void)testArrayRuleGeometryIsDeterministic
35683570
XCTAssertEqual(verticals.count, 2u);
35693571
XCTAssertEqual(horizontals.count, 2u);
35703572

3571-
// Leftmost vertical (boundary 0, base 0): x == padding + thickness/2 (edge-centred stroke).
3572-
CGFloat leftmostX = CGFLOAT_MAX;
3573-
for (MTRuleDisplay* v in verticals) { leftmostX = MIN(leftmostX, v.position.x); }
3574-
XCTAssertEqualWithAccuracy(leftmostX, padding + thickness / 2, 0.01);
3573+
// A rule's box is [position.x, position.x+width] x [position.y, position.y+ascent] and
3574+
// exactly covers the drawn stroke (position is the box origin, stroke straddles the
3575+
// centre-line). Outer vertical rules sit flush with the box edges: no padding *outside*
3576+
// the outer rules, so the leftmost box-left is at x=0 and the rightmost box-right is at
3577+
// the content edge. Padding lives only *inside*, between rule and content.
3578+
CGFloat vLeft = CGFLOAT_MAX;
3579+
CGFloat vRight = -CGFLOAT_MAX;
3580+
for (MTRuleDisplay* v in verticals) {
3581+
vLeft = MIN(vLeft, v.position.x);
3582+
vRight = MAX(vRight, v.position.x + v.width);
3583+
}
3584+
XCTAssertEqualWithAccuracy(vLeft, 0, 0.01);
3585+
// The table's reported width matches the true content edge — no phantom half-thickness
3586+
// of trailing space (the vertical-rule box straddles its centre-line, so position.x+width
3587+
// is the real right edge, not centre+thickness).
3588+
XCTAssertEqualWithAccuracy(vRight, tableDisp.width, 0.01);
3589+
3590+
// Corners meet flush: each horizontal rule spans exactly the box edges [0, width], so its
3591+
// ends coincide with the outer verticals rather than overhanging them (the overhang bug).
3592+
for (MTRuleDisplay* h in horizontals) {
3593+
XCTAssertEqualWithAccuracy(h.position.x, vLeft, 0.01);
3594+
XCTAssertEqualWithAccuracy(h.position.x + h.width, vRight, 0.01);
3595+
}
35753596

3576-
// Top \hline sits padding above the content top; bottom \hline padding below content bottom.
3577-
CGFloat topY = -CGFLOAT_MAX;
3578-
CGFloat botY = CGFLOAT_MAX;
3597+
// Top \hline sits padding above the content top; bottom \hline padding below content
3598+
// bottom. The stroke centre-line is position.y + thickness/2 (box straddles the line).
3599+
CGFloat topLine = -CGFLOAT_MAX;
3600+
CGFloat botLine = CGFLOAT_MAX;
35793601
for (MTRuleDisplay* h in horizontals) {
3580-
topY = MAX(topY, h.position.y);
3581-
botY = MIN(botY, h.position.y);
3602+
CGFloat line = h.position.y + thickness / 2;
3603+
topLine = MAX(topLine, line);
3604+
botLine = MIN(botLine, line);
35823605
}
3583-
XCTAssertEqualWithAccuracy(topY, contentTop + padding, 0.01);
3584-
XCTAssertEqualWithAccuracy(botY, contentBot - padding, 0.01);
3606+
XCTAssertEqualWithAccuracy(topLine, contentTop + padding, 0.01);
3607+
XCTAssertEqualWithAccuracy(botLine, contentBot - padding, 0.01);
35853608
}
35863609

35873610
@end

0 commit comments

Comments
 (0)