Skip to content

Commit 7e9c24f

Browse files
fix(rendering): apply superscript/subscript font-size scaling during layout (#2340)
* fix(rendering): apply superscript/subscript font-size scaling during layout The 65% font-size reduction for superscript/subscript was applied as a post-processing DOM patch, but text was measured at full size — causing oversized rendering. Move vertAlign and baselineShift into the TextRun contract so the layout engine measures at the correct scaled size, and the DOM painter applies vertical-align CSS directly. * fix(rendering): address PR review feedback for vertAlign support - Extract 0.65 to SUBSCRIPT_SUPERSCRIPT_SCALE constant in constants.ts - Add vertAlign/baselineShift to deriveBlockVersion() hash for both paragraph and table runs so changes trigger DOM updates - Clear vertAlign/baselineShift in resetRunFormatting() for tracked changes original mode - Add tests for computeRunAttrs, applyTextStyleMark, and DOM painter vertical-align CSS rendering * test(rendering): add requested vertAlign edge-case tests - resetRunFormatting clears vertAlign and baselineShift - baselineShift takes precedence over vertAlign on the same run - negative baselineShift renders correctly at the painter level
1 parent d298093 commit 7e9c24f

12 files changed

Lines changed: 341 additions & 116 deletions

File tree

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,10 @@ export type RunMarks = {
180180
highlight?: string;
181181
/** Text transformation (case modification). */
182182
textTransform?: 'uppercase' | 'lowercase' | 'capitalize' | 'none';
183+
/** Vertical alignment for superscript/subscript text. */
184+
vertAlign?: 'superscript' | 'subscript' | 'baseline';
185+
/** Custom baseline shift in points (positive = raise, negative = lower). Takes precedence over vertAlign for positioning. */
186+
baselineShift?: number;
183187
};
184188

185189
export type TextRun = RunMarks & {

packages/layout-engine/painters/dom/src/renderer.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6594,6 +6594,8 @@ const deriveBlockVersion = (block: FlowBlock): string => {
65946594
textRun.strike ? 1 : 0,
65956595
textRun.highlight ?? '',
65966596
textRun.letterSpacing != null ? textRun.letterSpacing : '',
6597+
textRun.vertAlign ?? '',
6598+
textRun.baselineShift != null ? textRun.baselineShift : '',
65976599
// Note: pmStart/pmEnd intentionally excluded to prevent O(n) change detection
65986600
textRun.token ?? '',
65996601
// Tracked changes - force re-render when added or removed tracked change
@@ -6835,6 +6837,8 @@ const deriveBlockVersion = (block: FlowBlock): string => {
68356837
hash = hashString(hash, getRunUnderlineStyle(run));
68366838
hash = hashString(hash, getRunUnderlineColor(run));
68376839
hash = hashString(hash, getRunBooleanProp(run, 'strike') ? '1' : '');
6840+
hash = hashString(hash, getRunStringProp(run, 'vertAlign'));
6841+
hash = hashNumber(hash, getRunNumberProp(run, 'baselineShift'));
68386842
}
68396843
}
68406844
}
@@ -6933,6 +6937,17 @@ const applyRunStyles = (element: HTMLElement, run: Run, _isLink = false): void =
69336937
if (decorations.length > 0) {
69346938
element.style.textDecorationLine = decorations.join(' ');
69356939
}
6940+
6941+
// Vertical alignment: custom baseline offset takes precedence over vertAlign
6942+
if (run.baselineShift != null && Number.isFinite(run.baselineShift)) {
6943+
element.style.verticalAlign = `${run.baselineShift}pt`;
6944+
} else if (run.vertAlign === 'superscript') {
6945+
element.style.verticalAlign = 'super';
6946+
} else if (run.vertAlign === 'subscript') {
6947+
element.style.verticalAlign = 'sub';
6948+
} else if (run.vertAlign === 'baseline') {
6949+
element.style.verticalAlign = 'baseline';
6950+
}
69366951
};
69376952

69386953
interface CommentHighlightResult {

packages/layout-engine/painters/dom/src/text-style-rendering.test.ts

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,169 @@ describe('DomPainter text style CSS rendering', () => {
322322
expectCssColor(span?.style.color ?? '', '#ff0000');
323323
});
324324

325+
it('should apply vertical-align super for superscript', () => {
326+
const block = createParagraphBlock('para-va-1', [
327+
{
328+
text: '1st',
329+
fontFamily: 'Arial',
330+
fontSize: 10.4,
331+
vertAlign: 'superscript' as const,
332+
pmStart: 0,
333+
pmEnd: 3,
334+
},
335+
]);
336+
337+
const measure = createParagraphMeasure();
338+
const layout = createParagraphLayout('para-va-1');
339+
340+
const painter = createDomPainter({
341+
blocks: [block],
342+
measures: [measure],
343+
});
344+
345+
painter.paint(layout, container);
346+
347+
const span = container.querySelector('span');
348+
expect(span).toBeTruthy();
349+
expect(span?.style.verticalAlign).toBe('super');
350+
});
351+
352+
it('should apply vertical-align sub for subscript', () => {
353+
const block = createParagraphBlock('para-va-2', [
354+
{
355+
text: '2',
356+
fontFamily: 'Arial',
357+
fontSize: 10.4,
358+
vertAlign: 'subscript' as const,
359+
pmStart: 0,
360+
pmEnd: 1,
361+
},
362+
]);
363+
364+
const measure = createParagraphMeasure();
365+
const layout = createParagraphLayout('para-va-2');
366+
367+
const painter = createDomPainter({
368+
blocks: [block],
369+
measures: [measure],
370+
});
371+
372+
painter.paint(layout, container);
373+
374+
const span = container.querySelector('span');
375+
expect(span).toBeTruthy();
376+
expect(span?.style.verticalAlign).toBe('sub');
377+
});
378+
379+
it('should apply vertical-align with pt offset for baselineShift', () => {
380+
const block = createParagraphBlock('para-va-3', [
381+
{
382+
text: 'shifted',
383+
fontFamily: 'Arial',
384+
fontSize: 16,
385+
baselineShift: 3,
386+
pmStart: 0,
387+
pmEnd: 7,
388+
},
389+
]);
390+
391+
const measure = createParagraphMeasure();
392+
const layout = createParagraphLayout('para-va-3');
393+
394+
const painter = createDomPainter({
395+
blocks: [block],
396+
measures: [measure],
397+
});
398+
399+
painter.paint(layout, container);
400+
401+
const span = container.querySelector('span');
402+
expect(span).toBeTruthy();
403+
expect(span?.style.verticalAlign).toBe('3pt');
404+
});
405+
406+
it('should not apply vertical-align when neither vertAlign nor baselineShift is set', () => {
407+
const block = createParagraphBlock('para-va-4', [
408+
{
409+
text: 'normal',
410+
fontFamily: 'Arial',
411+
fontSize: 16,
412+
pmStart: 0,
413+
pmEnd: 6,
414+
},
415+
]);
416+
417+
const measure = createParagraphMeasure();
418+
const layout = createParagraphLayout('para-va-4');
419+
420+
const painter = createDomPainter({
421+
blocks: [block],
422+
measures: [measure],
423+
});
424+
425+
painter.paint(layout, container);
426+
427+
const span = container.querySelector('span');
428+
expect(span).toBeTruthy();
429+
expect(span?.style.verticalAlign).toBe('');
430+
});
431+
432+
it('should use baselineShift over vertAlign when both are set', () => {
433+
const block = createParagraphBlock('para-va-5', [
434+
{
435+
text: '1st',
436+
fontFamily: 'Arial',
437+
fontSize: 10.4,
438+
vertAlign: 'superscript' as const,
439+
baselineShift: 4,
440+
pmStart: 0,
441+
pmEnd: 3,
442+
},
443+
]);
444+
445+
const measure = createParagraphMeasure();
446+
const layout = createParagraphLayout('para-va-5');
447+
448+
const painter = createDomPainter({
449+
blocks: [block],
450+
measures: [measure],
451+
});
452+
453+
painter.paint(layout, container);
454+
455+
const span = container.querySelector('span');
456+
expect(span).toBeTruthy();
457+
// baselineShift takes precedence — should be "4pt", not "super"
458+
expect(span?.style.verticalAlign).toBe('4pt');
459+
});
460+
461+
it('should apply negative baselineShift', () => {
462+
const block = createParagraphBlock('para-va-6', [
463+
{
464+
text: 'lowered',
465+
fontFamily: 'Arial',
466+
fontSize: 16,
467+
baselineShift: -2.5,
468+
pmStart: 0,
469+
pmEnd: 7,
470+
},
471+
]);
472+
473+
const measure = createParagraphMeasure();
474+
const layout = createParagraphLayout('para-va-6');
475+
476+
const painter = createDomPainter({
477+
blocks: [block],
478+
measures: [measure],
479+
});
480+
481+
painter.paint(layout, container);
482+
483+
const span = container.querySelector('span');
484+
expect(span).toBeTruthy();
485+
expect(span?.style.verticalAlign).toBe('-2.5pt');
486+
});
487+
325488
it('should handle empty text with textTransform', () => {
326489
const block = createParagraphBlock('para-8', [
327490
{

packages/layout-engine/pm-adapter/src/attributes/paragraph.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,4 +153,37 @@ describe('computeRunAttrs', () => {
153153

154154
expect(result.vanish).toBe(true);
155155
});
156+
157+
it('passes through vertAlign', () => {
158+
const result = computeRunAttrs({ vertAlign: 'superscript', fontSize: 24 } as never);
159+
expect(result.vertAlign).toBe('superscript');
160+
});
161+
162+
it('scales fontSize by 0.65 for superscript', () => {
163+
const base = computeRunAttrs({ fontSize: 24 } as never);
164+
const sup = computeRunAttrs({ fontSize: 24, vertAlign: 'superscript' } as never);
165+
expect(sup.fontSize).toBeCloseTo(base.fontSize * 0.65);
166+
});
167+
168+
it('scales fontSize by 0.65 for subscript', () => {
169+
const base = computeRunAttrs({ fontSize: 24 } as never);
170+
const sub = computeRunAttrs({ fontSize: 24, vertAlign: 'subscript' } as never);
171+
expect(sub.fontSize).toBeCloseTo(base.fontSize * 0.65);
172+
});
173+
174+
it('does not scale fontSize when position is set', () => {
175+
const base = computeRunAttrs({ fontSize: 24 } as never);
176+
const result = computeRunAttrs({ fontSize: 24, vertAlign: 'superscript', position: 6 } as never);
177+
expect(result.fontSize).toBe(base.fontSize);
178+
});
179+
180+
it('converts position from half-points to points as baselineShift', () => {
181+
const result = computeRunAttrs({ position: 6 } as never);
182+
expect(result.baselineShift).toBe(3);
183+
});
184+
185+
it('does not set baselineShift when position is absent', () => {
186+
const result = computeRunAttrs({ fontSize: 24 } as never);
187+
expect(result.baselineShift).toBeUndefined();
188+
});
156189
});

packages/layout-engine/pm-adapter/src/attributes/paragraph.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import type { PMNode } from '../types.js';
1717
import type { ResolvedRunProperties } from '@superdoc/word-layout';
1818
import { computeWordParagraphLayout } from '@superdoc/word-layout';
1919
import { pickNumber, twipsToPx, isFiniteNumber, ptToPx } from '../utilities.js';
20+
import { SUBSCRIPT_SUPERSCRIPT_SCALE } from '../constants.js';
2021
import { normalizeAlignment, normalizeParagraphSpacing } from './spacing-indent.js';
2122
import { normalizeOoxmlTabs } from './tabs.js';
2223
import { normalizeParagraphBorders, normalizeParagraphShading } from './borders.js';
@@ -319,9 +320,18 @@ export const computeRunAttrs = (
319320
fontFamily =
320321
runProps.fontFamily?.ascii || runProps.fontFamily?.hAnsi || runProps.fontFamily?.eastAsia || defaultFontFamily;
321322
}
323+
const vertAlign = runProps.vertAlign as 'superscript' | 'subscript' | 'baseline' | undefined;
324+
const hasPosition = runProps.position != null && Number.isFinite(runProps.position);
325+
let fontSize = runProps.fontSize ? ptToPx(runProps.fontSize / 2)! : defaultFontSizePx;
326+
327+
// Scale font size for superscript/subscript when no custom position override
328+
if (!hasPosition && (vertAlign === 'superscript' || vertAlign === 'subscript')) {
329+
fontSize *= SUBSCRIPT_SUPERSCRIPT_SCALE;
330+
}
331+
322332
return {
323333
fontFamily: toCssFontFamily(fontFamily)!,
324-
fontSize: runProps.fontSize ? ptToPx(runProps.fontSize / 2)! : defaultFontSizePx,
334+
fontSize,
325335
bold: runProps.bold,
326336
italic: runProps.italic,
327337
underline:
@@ -339,5 +349,7 @@ export const computeRunAttrs = (
339349
letterSpacing: runProps.letterSpacing ? twipsToPx(runProps.letterSpacing) : undefined,
340350
lang: runProps.lang?.val || undefined,
341351
vanish: runProps.vanish,
352+
vertAlign,
353+
baselineShift: hasPosition ? runProps.position! / 2 : undefined,
342354
};
343355
};

packages/layout-engine/pm-adapter/src/constants.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@
55
import type { TextRun, TrackedChangeKind } from '@superdoc/contracts';
66
import type { HyperlinkConfig } from './types.js';
77

8+
/**
9+
* Font size scaling factor for subscript and superscript text.
10+
* Matches Microsoft Word's default rendering behavior for w:vertAlign
11+
* when set to 'superscript' or 'subscript'.
12+
*/
13+
export const SUBSCRIPT_SUPERSCRIPT_SCALE = 0.65;
14+
815
/**
916
* Unit conversion constants
1017
*/

packages/layout-engine/pm-adapter/src/marks/application.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -824,6 +824,76 @@ describe('mark application', () => {
824824
expect(run.textTransform).toBe('uppercase');
825825
});
826826
});
827+
828+
describe('vertAlign', () => {
829+
it('sets vertAlign for superscript', () => {
830+
const run: TextRun = { text: '1st', fontFamily: 'Arial', fontSize: 16 };
831+
applyTextStyleMark(run, { vertAlign: 'superscript' });
832+
expect(run.vertAlign).toBe('superscript');
833+
});
834+
835+
it('sets vertAlign for subscript', () => {
836+
const run: TextRun = { text: 'H2O', fontFamily: 'Arial', fontSize: 16 };
837+
applyTextStyleMark(run, { vertAlign: 'subscript' });
838+
expect(run.vertAlign).toBe('subscript');
839+
});
840+
841+
it('sets vertAlign for baseline', () => {
842+
const run: TextRun = { text: 'text', fontFamily: 'Arial', fontSize: 16 };
843+
applyTextStyleMark(run, { vertAlign: 'baseline' });
844+
expect(run.vertAlign).toBe('baseline');
845+
});
846+
847+
it('ignores invalid vertAlign values', () => {
848+
const run: TextRun = { text: 'text', fontFamily: 'Arial', fontSize: 16 };
849+
applyTextStyleMark(run, { vertAlign: 'invalid' });
850+
expect(run.vertAlign).toBeUndefined();
851+
});
852+
853+
it('scales fontSize by 0.65 for superscript', () => {
854+
const run: TextRun = { text: '1st', fontFamily: 'Arial', fontSize: 16 };
855+
applyTextStyleMark(run, { vertAlign: 'superscript' });
856+
expect(run.fontSize).toBeCloseTo(16 * 0.65);
857+
});
858+
859+
it('scales fontSize by 0.65 for subscript', () => {
860+
const run: TextRun = { text: 'H2O', fontFamily: 'Arial', fontSize: 16 };
861+
applyTextStyleMark(run, { vertAlign: 'subscript' });
862+
expect(run.fontSize).toBeCloseTo(16 * 0.65);
863+
});
864+
865+
it('does not scale fontSize for baseline', () => {
866+
const run: TextRun = { text: 'text', fontFamily: 'Arial', fontSize: 16 };
867+
applyTextStyleMark(run, { vertAlign: 'baseline' });
868+
expect(run.fontSize).toBe(16);
869+
});
870+
871+
it('does not scale fontSize when baselineShift is set', () => {
872+
const run: TextRun = { text: '1st', fontFamily: 'Arial', fontSize: 16 };
873+
applyTextStyleMark(run, { vertAlign: 'superscript', position: '3pt' });
874+
expect(run.fontSize).toBe(16);
875+
});
876+
});
877+
878+
describe('position / baselineShift', () => {
879+
it('parses position string to baselineShift number', () => {
880+
const run: TextRun = { text: 'text', fontFamily: 'Arial', fontSize: 16 };
881+
applyTextStyleMark(run, { position: '3pt' });
882+
expect(run.baselineShift).toBe(3);
883+
});
884+
885+
it('handles negative position values', () => {
886+
const run: TextRun = { text: 'text', fontFamily: 'Arial', fontSize: 16 };
887+
applyTextStyleMark(run, { position: '-1.5pt' });
888+
expect(run.baselineShift).toBe(-1.5);
889+
});
890+
891+
it('ignores non-numeric position', () => {
892+
const run: TextRun = { text: 'text', fontFamily: 'Arial', fontSize: 16 };
893+
applyTextStyleMark(run, { position: 'invalid' });
894+
expect(run.baselineShift).toBeUndefined();
895+
});
896+
});
827897
});
828898

829899
describe('applyMarksToRun', () => {

0 commit comments

Comments
 (0)