Skip to content

Commit 1f0e9e3

Browse files
authored
fix: footnotes rendering causing lines to shift, other footnote rendering issues (#2625)
* fix: normalize footnote reference markers before superscript layout * fix: render footnote markers as scaled superscript digits * fix: treat zero baseline shift as a no-op for superscript rendering
1 parent 288d369 commit 1f0e9e3

25 files changed

Lines changed: 1265 additions & 217 deletions

File tree

devtools/visual-testing/pnpm-lock.yaml

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/layout-engine/contracts/src/index.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,16 @@ export {
3939
formatInsetClipPathTransform,
4040
type InsetClipPathScale,
4141
} from './clip-path-inset.js';
42+
export {
43+
SUBSCRIPT_SUPERSCRIPT_SCALE,
44+
normalizeBaselineShift,
45+
hasExplicitBaselineShift,
46+
isSuperscriptOrSubscript,
47+
usesDefaultScriptLayout,
48+
scaleFontSizeForVerticalText,
49+
resolveBaseFontSizeForVerticalText,
50+
type VerticalTextAlign,
51+
} from './vertical-text.js';
4252

4353
export { computeFragmentPmRange, computeLinePmRange, type LinePmRange } from './pm-range.js';
4454
export { cloneColumnLayout, normalizeColumnLayout, widthsEqual } from './column-layout.js';
@@ -200,7 +210,10 @@ export type RunMarks = {
200210
textTransform?: 'uppercase' | 'lowercase' | 'capitalize' | 'none';
201211
/** Vertical alignment for superscript/subscript text. */
202212
vertAlign?: 'superscript' | 'subscript' | 'baseline';
203-
/** Custom baseline shift in points (positive = raise, negative = lower). Takes precedence over vertAlign for positioning. */
213+
/**
214+
* Explicit baseline shift in points (positive = raise, negative = lower).
215+
* Rendering normalizes a shift of zero to "no explicit shift".
216+
*/
204217
baselineShift?: number;
205218
};
206219

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import { describe, expect, it } from 'vitest';
2+
import {
3+
SUBSCRIPT_SUPERSCRIPT_SCALE,
4+
normalizeBaselineShift,
5+
hasExplicitBaselineShift,
6+
isSuperscriptOrSubscript,
7+
usesDefaultScriptLayout,
8+
scaleFontSizeForVerticalText,
9+
resolveBaseFontSizeForVerticalText,
10+
} from './vertical-text.js';
11+
12+
describe('normalizeBaselineShift', () => {
13+
it('returns undefined for null', () => {
14+
expect(normalizeBaselineShift(null)).toBeUndefined();
15+
});
16+
17+
it('returns undefined for undefined', () => {
18+
expect(normalizeBaselineShift(undefined)).toBeUndefined();
19+
});
20+
21+
it('returns undefined for NaN', () => {
22+
expect(normalizeBaselineShift(NaN)).toBeUndefined();
23+
});
24+
25+
it('returns undefined for Infinity', () => {
26+
expect(normalizeBaselineShift(Infinity)).toBeUndefined();
27+
});
28+
29+
it('returns undefined for zero (identity value)', () => {
30+
expect(normalizeBaselineShift(0)).toBeUndefined();
31+
});
32+
33+
it('returns undefined for near-zero values within epsilon', () => {
34+
expect(normalizeBaselineShift(1e-7)).toBeUndefined();
35+
expect(normalizeBaselineShift(-1e-7)).toBeUndefined();
36+
});
37+
38+
it('returns the value for positive shifts', () => {
39+
expect(normalizeBaselineShift(3)).toBe(3);
40+
});
41+
42+
it('returns the value for negative shifts', () => {
43+
expect(normalizeBaselineShift(-1.5)).toBe(-1.5);
44+
});
45+
46+
it('returns the value for small but non-zero shifts', () => {
47+
expect(normalizeBaselineShift(0.01)).toBe(0.01);
48+
});
49+
});
50+
51+
describe('hasExplicitBaselineShift', () => {
52+
it('returns false for null/undefined/zero', () => {
53+
expect(hasExplicitBaselineShift(null)).toBe(false);
54+
expect(hasExplicitBaselineShift(undefined)).toBe(false);
55+
expect(hasExplicitBaselineShift(0)).toBe(false);
56+
});
57+
58+
it('returns true for non-zero finite values', () => {
59+
expect(hasExplicitBaselineShift(3)).toBe(true);
60+
expect(hasExplicitBaselineShift(-1.5)).toBe(true);
61+
});
62+
});
63+
64+
describe('isSuperscriptOrSubscript', () => {
65+
it('returns true for superscript', () => {
66+
expect(isSuperscriptOrSubscript('superscript')).toBe(true);
67+
});
68+
69+
it('returns true for subscript', () => {
70+
expect(isSuperscriptOrSubscript('subscript')).toBe(true);
71+
});
72+
73+
it('returns false for baseline', () => {
74+
expect(isSuperscriptOrSubscript('baseline')).toBe(false);
75+
});
76+
77+
it('returns false for null/undefined', () => {
78+
expect(isSuperscriptOrSubscript(null)).toBe(false);
79+
expect(isSuperscriptOrSubscript(undefined)).toBe(false);
80+
});
81+
});
82+
83+
describe('usesDefaultScriptLayout', () => {
84+
it('returns true for superscript without explicit shift', () => {
85+
expect(usesDefaultScriptLayout({ vertAlign: 'superscript' })).toBe(true);
86+
});
87+
88+
it('returns true for subscript without explicit shift', () => {
89+
expect(usesDefaultScriptLayout({ vertAlign: 'subscript' })).toBe(true);
90+
});
91+
92+
it('returns false for superscript with explicit shift', () => {
93+
expect(usesDefaultScriptLayout({ vertAlign: 'superscript', baselineShift: 3 })).toBe(false);
94+
});
95+
96+
it('returns true for superscript with zero shift (identity)', () => {
97+
expect(usesDefaultScriptLayout({ vertAlign: 'superscript', baselineShift: 0 })).toBe(true);
98+
});
99+
100+
it('returns false for baseline', () => {
101+
expect(usesDefaultScriptLayout({ vertAlign: 'baseline' })).toBe(false);
102+
});
103+
104+
it('returns false when no vertAlign', () => {
105+
expect(usesDefaultScriptLayout({})).toBe(false);
106+
expect(usesDefaultScriptLayout({ baselineShift: 3 })).toBe(false);
107+
});
108+
});
109+
110+
describe('scaleFontSizeForVerticalText', () => {
111+
it('scales font size for default superscript', () => {
112+
expect(scaleFontSizeForVerticalText(16, { vertAlign: 'superscript' })).toBeCloseTo(
113+
16 * SUBSCRIPT_SUPERSCRIPT_SCALE,
114+
);
115+
});
116+
117+
it('scales font size for default subscript', () => {
118+
expect(scaleFontSizeForVerticalText(16, { vertAlign: 'subscript' })).toBeCloseTo(16 * SUBSCRIPT_SUPERSCRIPT_SCALE);
119+
});
120+
121+
it('does not scale when explicit shift is present', () => {
122+
expect(scaleFontSizeForVerticalText(16, { vertAlign: 'superscript', baselineShift: 3 })).toBe(16);
123+
});
124+
125+
it('scales when shift is zero (identity)', () => {
126+
expect(scaleFontSizeForVerticalText(16, { vertAlign: 'superscript', baselineShift: 0 })).toBeCloseTo(
127+
16 * SUBSCRIPT_SUPERSCRIPT_SCALE,
128+
);
129+
});
130+
131+
it('does not scale for baseline', () => {
132+
expect(scaleFontSizeForVerticalText(16, { vertAlign: 'baseline' })).toBe(16);
133+
});
134+
135+
it('does not scale when no vertAlign', () => {
136+
expect(scaleFontSizeForVerticalText(16, {})).toBe(16);
137+
});
138+
139+
it('passes through non-finite values unchanged', () => {
140+
expect(scaleFontSizeForVerticalText(NaN, { vertAlign: 'superscript' })).toBeNaN();
141+
expect(scaleFontSizeForVerticalText(Infinity, { vertAlign: 'superscript' })).toBe(Infinity);
142+
});
143+
});
144+
145+
describe('resolveBaseFontSizeForVerticalText', () => {
146+
it('un-scales default superscript font size', () => {
147+
const scaled = 16 * SUBSCRIPT_SUPERSCRIPT_SCALE;
148+
expect(resolveBaseFontSizeForVerticalText(scaled, { vertAlign: 'superscript' })).toBeCloseTo(16);
149+
});
150+
151+
it('un-scales default subscript font size', () => {
152+
const scaled = 16 * SUBSCRIPT_SUPERSCRIPT_SCALE;
153+
expect(resolveBaseFontSizeForVerticalText(scaled, { vertAlign: 'subscript' })).toBeCloseTo(16);
154+
});
155+
156+
it('returns font size unchanged when explicit shift is present', () => {
157+
expect(resolveBaseFontSizeForVerticalText(16, { vertAlign: 'superscript', baselineShift: 3 })).toBe(16);
158+
});
159+
160+
it('un-scales when shift is zero (identity)', () => {
161+
const scaled = 16 * SUBSCRIPT_SUPERSCRIPT_SCALE;
162+
expect(resolveBaseFontSizeForVerticalText(scaled, { vertAlign: 'superscript', baselineShift: 0 })).toBeCloseTo(16);
163+
});
164+
165+
it('returns font size unchanged for baseline', () => {
166+
expect(resolveBaseFontSizeForVerticalText(16, { vertAlign: 'baseline' })).toBe(16);
167+
});
168+
169+
it('passes through non-finite values unchanged', () => {
170+
expect(resolveBaseFontSizeForVerticalText(NaN, { vertAlign: 'superscript' })).toBeNaN();
171+
});
172+
173+
it('roundtrips with scaleFontSizeForVerticalText', () => {
174+
const formatting = { vertAlign: 'superscript' as const };
175+
const original = 24;
176+
const scaled = scaleFontSizeForVerticalText(original, formatting);
177+
expect(resolveBaseFontSizeForVerticalText(scaled, formatting)).toBeCloseTo(original);
178+
});
179+
});
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/**
2+
* Shared vertical-text helpers for superscript, subscript, and explicit baseline shifts.
3+
*
4+
* OOXML allows both semantic vertical alignment (`vertAlign`) and an explicit
5+
* position offset (`position`). During rendering, a zero offset is an identity
6+
* value and should behave the same as an absent offset.
7+
*/
8+
9+
export type VerticalTextAlign = 'superscript' | 'subscript' | 'baseline';
10+
11+
type VerticalTextFormatting = {
12+
vertAlign?: VerticalTextAlign | null;
13+
baselineShift?: number | null;
14+
};
15+
16+
/**
17+
* Font size scaling factor for default superscript/subscript rendering.
18+
* Matches Microsoft Word's default visual behavior closely enough for layout.
19+
*/
20+
export const SUBSCRIPT_SUPERSCRIPT_SCALE = 0.65;
21+
22+
const BASELINE_SHIFT_EPSILON = 1e-6;
23+
24+
/**
25+
* Normalizes explicit baseline shifts for rendering.
26+
*
27+
* A numeric shift of zero is a no-op and should not override semantic
28+
* superscript/subscript styling. This preserves the raw OOXML value for
29+
* round-tripping while giving the renderer a clean intent model.
30+
*/
31+
export function normalizeBaselineShift(baselineShift: number | null | undefined): number | undefined {
32+
if (!Number.isFinite(baselineShift)) {
33+
return undefined;
34+
}
35+
36+
const normalizedShift = baselineShift as number;
37+
return Math.abs(normalizedShift) <= BASELINE_SHIFT_EPSILON ? undefined : normalizedShift;
38+
}
39+
40+
export function hasExplicitBaselineShift(baselineShift: number | null | undefined): boolean {
41+
return normalizeBaselineShift(baselineShift) != null;
42+
}
43+
44+
export function isSuperscriptOrSubscript(vertAlign: VerticalTextAlign | null | undefined): boolean {
45+
return vertAlign === 'superscript' || vertAlign === 'subscript';
46+
}
47+
48+
/**
49+
* Returns true when the run should use the default superscript/subscript
50+
* presentation path: scaled font size plus the renderer's default raise/lower.
51+
*/
52+
export function usesDefaultScriptLayout(formatting: VerticalTextFormatting): boolean {
53+
return isSuperscriptOrSubscript(formatting.vertAlign) && !hasExplicitBaselineShift(formatting.baselineShift);
54+
}
55+
56+
/**
57+
* Applies default superscript/subscript font scaling when the run uses the
58+
* default semantic layout path.
59+
*/
60+
export function scaleFontSizeForVerticalText(fontSize: number, formatting: VerticalTextFormatting): number {
61+
if (!Number.isFinite(fontSize)) {
62+
return fontSize;
63+
}
64+
65+
return usesDefaultScriptLayout(formatting) ? fontSize * SUBSCRIPT_SUPERSCRIPT_SCALE : fontSize;
66+
}
67+
68+
/**
69+
* Returns the original base font size for runs that already carry scaled
70+
* superscript/subscript text metrics.
71+
*/
72+
export function resolveBaseFontSizeForVerticalText(fontSize: number, formatting: VerticalTextFormatting): number {
73+
if (!Number.isFinite(fontSize)) {
74+
return fontSize;
75+
}
76+
77+
return usesDefaultScriptLayout(formatting) ? fontSize / SUBSCRIPT_SUPERSCRIPT_SCALE : fontSize;
78+
}

0 commit comments

Comments
 (0)