Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion devtools/visual-testing/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 14 additions & 1 deletion packages/layout-engine/contracts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,16 @@ export {
formatInsetClipPathTransform,
type InsetClipPathScale,
} from './clip-path-inset.js';
export {
SUBSCRIPT_SUPERSCRIPT_SCALE,
normalizeBaselineShift,
hasExplicitBaselineShift,
isSuperscriptOrSubscript,
usesDefaultScriptLayout,
scaleFontSizeForVerticalText,
resolveBaseFontSizeForVerticalText,
type VerticalTextAlign,
} from './vertical-text.js';

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

Expand Down
179 changes: 179 additions & 0 deletions packages/layout-engine/contracts/src/vertical-text.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { describe, expect, it } from 'vitest';
import {
SUBSCRIPT_SUPERSCRIPT_SCALE,
normalizeBaselineShift,
hasExplicitBaselineShift,
isSuperscriptOrSubscript,
usesDefaultScriptLayout,
scaleFontSizeForVerticalText,
resolveBaseFontSizeForVerticalText,
} from './vertical-text.js';

describe('normalizeBaselineShift', () => {
it('returns undefined for null', () => {
expect(normalizeBaselineShift(null)).toBeUndefined();
});

it('returns undefined for undefined', () => {
expect(normalizeBaselineShift(undefined)).toBeUndefined();
});

it('returns undefined for NaN', () => {
expect(normalizeBaselineShift(NaN)).toBeUndefined();
});

it('returns undefined for Infinity', () => {
expect(normalizeBaselineShift(Infinity)).toBeUndefined();
});

it('returns undefined for zero (identity value)', () => {
expect(normalizeBaselineShift(0)).toBeUndefined();
});

it('returns undefined for near-zero values within epsilon', () => {
expect(normalizeBaselineShift(1e-7)).toBeUndefined();
expect(normalizeBaselineShift(-1e-7)).toBeUndefined();
});

it('returns the value for positive shifts', () => {
expect(normalizeBaselineShift(3)).toBe(3);
});

it('returns the value for negative shifts', () => {
expect(normalizeBaselineShift(-1.5)).toBe(-1.5);
});

it('returns the value for small but non-zero shifts', () => {
expect(normalizeBaselineShift(0.01)).toBe(0.01);
});
});

describe('hasExplicitBaselineShift', () => {
it('returns false for null/undefined/zero', () => {
expect(hasExplicitBaselineShift(null)).toBe(false);
expect(hasExplicitBaselineShift(undefined)).toBe(false);
expect(hasExplicitBaselineShift(0)).toBe(false);
});

it('returns true for non-zero finite values', () => {
expect(hasExplicitBaselineShift(3)).toBe(true);
expect(hasExplicitBaselineShift(-1.5)).toBe(true);
});
});

describe('isSuperscriptOrSubscript', () => {
it('returns true for superscript', () => {
expect(isSuperscriptOrSubscript('superscript')).toBe(true);
});

it('returns true for subscript', () => {
expect(isSuperscriptOrSubscript('subscript')).toBe(true);
});

it('returns false for baseline', () => {
expect(isSuperscriptOrSubscript('baseline')).toBe(false);
});

it('returns false for null/undefined', () => {
expect(isSuperscriptOrSubscript(null)).toBe(false);
expect(isSuperscriptOrSubscript(undefined)).toBe(false);
});
});

describe('usesDefaultScriptLayout', () => {
it('returns true for superscript without explicit shift', () => {
expect(usesDefaultScriptLayout({ vertAlign: 'superscript' })).toBe(true);
});

it('returns true for subscript without explicit shift', () => {
expect(usesDefaultScriptLayout({ vertAlign: 'subscript' })).toBe(true);
});

it('returns false for superscript with explicit shift', () => {
expect(usesDefaultScriptLayout({ vertAlign: 'superscript', baselineShift: 3 })).toBe(false);
});

it('returns true for superscript with zero shift (identity)', () => {
expect(usesDefaultScriptLayout({ vertAlign: 'superscript', baselineShift: 0 })).toBe(true);
});

it('returns false for baseline', () => {
expect(usesDefaultScriptLayout({ vertAlign: 'baseline' })).toBe(false);
});

it('returns false when no vertAlign', () => {
expect(usesDefaultScriptLayout({})).toBe(false);
expect(usesDefaultScriptLayout({ baselineShift: 3 })).toBe(false);
});
});

describe('scaleFontSizeForVerticalText', () => {
it('scales font size for default superscript', () => {
expect(scaleFontSizeForVerticalText(16, { vertAlign: 'superscript' })).toBeCloseTo(
16 * SUBSCRIPT_SUPERSCRIPT_SCALE,
);
});

it('scales font size for default subscript', () => {
expect(scaleFontSizeForVerticalText(16, { vertAlign: 'subscript' })).toBeCloseTo(16 * SUBSCRIPT_SUPERSCRIPT_SCALE);
});

it('does not scale when explicit shift is present', () => {
expect(scaleFontSizeForVerticalText(16, { vertAlign: 'superscript', baselineShift: 3 })).toBe(16);
});

it('scales when shift is zero (identity)', () => {
expect(scaleFontSizeForVerticalText(16, { vertAlign: 'superscript', baselineShift: 0 })).toBeCloseTo(
16 * SUBSCRIPT_SUPERSCRIPT_SCALE,
);
});

it('does not scale for baseline', () => {
expect(scaleFontSizeForVerticalText(16, { vertAlign: 'baseline' })).toBe(16);
});

it('does not scale when no vertAlign', () => {
expect(scaleFontSizeForVerticalText(16, {})).toBe(16);
});

it('passes through non-finite values unchanged', () => {
expect(scaleFontSizeForVerticalText(NaN, { vertAlign: 'superscript' })).toBeNaN();
expect(scaleFontSizeForVerticalText(Infinity, { vertAlign: 'superscript' })).toBe(Infinity);
});
});

describe('resolveBaseFontSizeForVerticalText', () => {
it('un-scales default superscript font size', () => {
const scaled = 16 * SUBSCRIPT_SUPERSCRIPT_SCALE;
expect(resolveBaseFontSizeForVerticalText(scaled, { vertAlign: 'superscript' })).toBeCloseTo(16);
});

it('un-scales default subscript font size', () => {
const scaled = 16 * SUBSCRIPT_SUPERSCRIPT_SCALE;
expect(resolveBaseFontSizeForVerticalText(scaled, { vertAlign: 'subscript' })).toBeCloseTo(16);
});

it('returns font size unchanged when explicit shift is present', () => {
expect(resolveBaseFontSizeForVerticalText(16, { vertAlign: 'superscript', baselineShift: 3 })).toBe(16);
});

it('un-scales when shift is zero (identity)', () => {
const scaled = 16 * SUBSCRIPT_SUPERSCRIPT_SCALE;
expect(resolveBaseFontSizeForVerticalText(scaled, { vertAlign: 'superscript', baselineShift: 0 })).toBeCloseTo(16);
});

it('returns font size unchanged for baseline', () => {
expect(resolveBaseFontSizeForVerticalText(16, { vertAlign: 'baseline' })).toBe(16);
});

it('passes through non-finite values unchanged', () => {
expect(resolveBaseFontSizeForVerticalText(NaN, { vertAlign: 'superscript' })).toBeNaN();
});

it('roundtrips with scaleFontSizeForVerticalText', () => {
const formatting = { vertAlign: 'superscript' as const };
const original = 24;
const scaled = scaleFontSizeForVerticalText(original, formatting);
expect(resolveBaseFontSizeForVerticalText(scaled, formatting)).toBeCloseTo(original);
});
});
78 changes: 78 additions & 0 deletions packages/layout-engine/contracts/src/vertical-text.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/**
* Shared vertical-text helpers for superscript, subscript, and explicit baseline shifts.
*
* OOXML allows both semantic vertical alignment (`vertAlign`) and an explicit
* position offset (`position`). During rendering, a zero offset is an identity
* value and should behave the same as an absent offset.
*/

export type VerticalTextAlign = 'superscript' | 'subscript' | 'baseline';

type VerticalTextFormatting = {
vertAlign?: VerticalTextAlign | null;
baselineShift?: number | null;
};

/**
* Font size scaling factor for default superscript/subscript rendering.
* Matches Microsoft Word's default visual behavior closely enough for layout.
*/
export const SUBSCRIPT_SUPERSCRIPT_SCALE = 0.65;

const BASELINE_SHIFT_EPSILON = 1e-6;

/**
* Normalizes explicit baseline shifts for rendering.
*
* A numeric shift of zero is a no-op and should not override semantic
* superscript/subscript styling. This preserves the raw OOXML value for
* round-tripping while giving the renderer a clean intent model.
*/
export function normalizeBaselineShift(baselineShift: number | null | undefined): number | undefined {
if (!Number.isFinite(baselineShift)) {
return undefined;
}

const normalizedShift = baselineShift as number;
return Math.abs(normalizedShift) <= BASELINE_SHIFT_EPSILON ? undefined : normalizedShift;
}

export function hasExplicitBaselineShift(baselineShift: number | null | undefined): boolean {
return normalizeBaselineShift(baselineShift) != null;
}

export function isSuperscriptOrSubscript(vertAlign: VerticalTextAlign | null | undefined): boolean {
return vertAlign === 'superscript' || vertAlign === 'subscript';
}

/**
* Returns true when the run should use the default superscript/subscript
* presentation path: scaled font size plus the renderer's default raise/lower.
*/
export function usesDefaultScriptLayout(formatting: VerticalTextFormatting): boolean {
return isSuperscriptOrSubscript(formatting.vertAlign) && !hasExplicitBaselineShift(formatting.baselineShift);
}

/**
* Applies default superscript/subscript font scaling when the run uses the
* default semantic layout path.
*/
export function scaleFontSizeForVerticalText(fontSize: number, formatting: VerticalTextFormatting): number {
if (!Number.isFinite(fontSize)) {
return fontSize;
}

return usesDefaultScriptLayout(formatting) ? fontSize * SUBSCRIPT_SUPERSCRIPT_SCALE : fontSize;
}

/**
* Returns the original base font size for runs that already carry scaled
* superscript/subscript text metrics.
*/
export function resolveBaseFontSizeForVerticalText(fontSize: number, formatting: VerticalTextFormatting): number {
if (!Number.isFinite(fontSize)) {
return fontSize;
}

return usesDefaultScriptLayout(formatting) ? fontSize / SUBSCRIPT_SUPERSCRIPT_SCALE : fontSize;
}
Loading
Loading