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
4 changes: 4 additions & 0 deletions packages/layout-engine/contracts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,10 @@ export type RunMarks = {
highlight?: string;
/** Text transformation (case modification). */
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. */
baselineShift?: number;
};

export type TextRun = RunMarks & {
Expand Down
15 changes: 15 additions & 0 deletions packages/layout-engine/painters/dom/src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6543,6 +6543,8 @@ const deriveBlockVersion = (block: FlowBlock): string => {
textRun.strike ? 1 : 0,
textRun.highlight ?? '',
textRun.letterSpacing != null ? textRun.letterSpacing : '',
textRun.vertAlign ?? '',
textRun.baselineShift != null ? textRun.baselineShift : '',
// Note: pmStart/pmEnd intentionally excluded to prevent O(n) change detection
textRun.token ?? '',
// Tracked changes - force re-render when added or removed tracked change
Expand Down Expand Up @@ -6784,6 +6786,8 @@ const deriveBlockVersion = (block: FlowBlock): string => {
hash = hashString(hash, getRunUnderlineStyle(run));
hash = hashString(hash, getRunUnderlineColor(run));
hash = hashString(hash, getRunBooleanProp(run, 'strike') ? '1' : '');
hash = hashString(hash, getRunStringProp(run, 'vertAlign'));
hash = hashNumber(hash, getRunNumberProp(run, 'baselineShift'));
}
}
}
Expand Down Expand Up @@ -6882,6 +6886,17 @@ const applyRunStyles = (element: HTMLElement, run: Run, _isLink = false): void =
if (decorations.length > 0) {
element.style.textDecorationLine = decorations.join(' ');
}

// Vertical alignment: custom baseline offset takes precedence over vertAlign
if (run.baselineShift != null && Number.isFinite(run.baselineShift)) {
element.style.verticalAlign = `${run.baselineShift}pt`;
} else if (run.vertAlign === 'superscript') {
element.style.verticalAlign = 'super';
} else if (run.vertAlign === 'subscript') {
element.style.verticalAlign = 'sub';
} else if (run.vertAlign === 'baseline') {
element.style.verticalAlign = 'baseline';
}
};

interface CommentHighlightResult {
Expand Down
163 changes: 163 additions & 0 deletions packages/layout-engine/painters/dom/src/text-style-rendering.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,169 @@ describe('DomPainter text style CSS rendering', () => {
expectCssColor(span?.style.color ?? '', '#ff0000');
});

it('should apply vertical-align super for superscript', () => {
const block = createParagraphBlock('para-va-1', [
{
text: '1st',
fontFamily: 'Arial',
fontSize: 10.4,
vertAlign: 'superscript' as const,
pmStart: 0,
pmEnd: 3,
},
]);

const measure = createParagraphMeasure();
const layout = createParagraphLayout('para-va-1');

const painter = createDomPainter({
blocks: [block],
measures: [measure],
});

painter.paint(layout, container);

const span = container.querySelector('span');
expect(span).toBeTruthy();
expect(span?.style.verticalAlign).toBe('super');
});

it('should apply vertical-align sub for subscript', () => {
const block = createParagraphBlock('para-va-2', [
{
text: '2',
fontFamily: 'Arial',
fontSize: 10.4,
vertAlign: 'subscript' as const,
pmStart: 0,
pmEnd: 1,
},
]);

const measure = createParagraphMeasure();
const layout = createParagraphLayout('para-va-2');

const painter = createDomPainter({
blocks: [block],
measures: [measure],
});

painter.paint(layout, container);

const span = container.querySelector('span');
expect(span).toBeTruthy();
expect(span?.style.verticalAlign).toBe('sub');
});

it('should apply vertical-align with pt offset for baselineShift', () => {
const block = createParagraphBlock('para-va-3', [
{
text: 'shifted',
fontFamily: 'Arial',
fontSize: 16,
baselineShift: 3,
pmStart: 0,
pmEnd: 7,
},
]);

const measure = createParagraphMeasure();
const layout = createParagraphLayout('para-va-3');

const painter = createDomPainter({
blocks: [block],
measures: [measure],
});

painter.paint(layout, container);

const span = container.querySelector('span');
expect(span).toBeTruthy();
expect(span?.style.verticalAlign).toBe('3pt');
});

it('should not apply vertical-align when neither vertAlign nor baselineShift is set', () => {
const block = createParagraphBlock('para-va-4', [
{
text: 'normal',
fontFamily: 'Arial',
fontSize: 16,
pmStart: 0,
pmEnd: 6,
},
]);

const measure = createParagraphMeasure();
const layout = createParagraphLayout('para-va-4');

const painter = createDomPainter({
blocks: [block],
measures: [measure],
});

painter.paint(layout, container);

const span = container.querySelector('span');
expect(span).toBeTruthy();
expect(span?.style.verticalAlign).toBe('');
});

it('should use baselineShift over vertAlign when both are set', () => {
const block = createParagraphBlock('para-va-5', [
{
text: '1st',
fontFamily: 'Arial',
fontSize: 10.4,
vertAlign: 'superscript' as const,
baselineShift: 4,
pmStart: 0,
pmEnd: 3,
},
]);

const measure = createParagraphMeasure();
const layout = createParagraphLayout('para-va-5');

const painter = createDomPainter({
blocks: [block],
measures: [measure],
});

painter.paint(layout, container);

const span = container.querySelector('span');
expect(span).toBeTruthy();
// baselineShift takes precedence — should be "4pt", not "super"
expect(span?.style.verticalAlign).toBe('4pt');
});

it('should apply negative baselineShift', () => {
const block = createParagraphBlock('para-va-6', [
{
text: 'lowered',
fontFamily: 'Arial',
fontSize: 16,
baselineShift: -2.5,
pmStart: 0,
pmEnd: 7,
},
]);

const measure = createParagraphMeasure();
const layout = createParagraphLayout('para-va-6');

const painter = createDomPainter({
blocks: [block],
measures: [measure],
});

painter.paint(layout, container);

const span = container.querySelector('span');
expect(span).toBeTruthy();
expect(span?.style.verticalAlign).toBe('-2.5pt');
});

it('should handle empty text with textTransform', () => {
const block = createParagraphBlock('para-8', [
{
Expand Down
33 changes: 33 additions & 0 deletions packages/layout-engine/pm-adapter/src/attributes/paragraph.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,4 +153,37 @@ describe('computeRunAttrs', () => {

expect(result.vanish).toBe(true);
});

it('passes through vertAlign', () => {
const result = computeRunAttrs({ vertAlign: 'superscript', fontSize: 24 } as never);
expect(result.vertAlign).toBe('superscript');
});

it('scales fontSize by 0.65 for superscript', () => {
const base = computeRunAttrs({ fontSize: 24 } as never);
const sup = computeRunAttrs({ fontSize: 24, vertAlign: 'superscript' } as never);
expect(sup.fontSize).toBeCloseTo(base.fontSize * 0.65);
});

it('scales fontSize by 0.65 for subscript', () => {
const base = computeRunAttrs({ fontSize: 24 } as never);
const sub = computeRunAttrs({ fontSize: 24, vertAlign: 'subscript' } as never);
expect(sub.fontSize).toBeCloseTo(base.fontSize * 0.65);
});

it('does not scale fontSize when position is set', () => {
const base = computeRunAttrs({ fontSize: 24 } as never);
const result = computeRunAttrs({ fontSize: 24, vertAlign: 'superscript', position: 6 } as never);
expect(result.fontSize).toBe(base.fontSize);
});

it('converts position from half-points to points as baselineShift', () => {
const result = computeRunAttrs({ position: 6 } as never);
expect(result.baselineShift).toBe(3);
});

it('does not set baselineShift when position is absent', () => {
const result = computeRunAttrs({ fontSize: 24 } as never);
expect(result.baselineShift).toBeUndefined();
});
});
14 changes: 13 additions & 1 deletion packages/layout-engine/pm-adapter/src/attributes/paragraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type { PMNode } from '../types.js';
import type { ResolvedRunProperties } from '@superdoc/word-layout';
import { computeWordParagraphLayout } from '@superdoc/word-layout';
import { pickNumber, twipsToPx, isFiniteNumber, ptToPx } from '../utilities.js';
import { SUBSCRIPT_SUPERSCRIPT_SCALE } from '../constants.js';
import { normalizeAlignment, normalizeParagraphSpacing } from './spacing-indent.js';
import { normalizeOoxmlTabs } from './tabs.js';
import { normalizeParagraphBorders, normalizeParagraphShading } from './borders.js';
Expand Down Expand Up @@ -319,9 +320,18 @@ export const computeRunAttrs = (
fontFamily =
runProps.fontFamily?.ascii || runProps.fontFamily?.hAnsi || runProps.fontFamily?.eastAsia || defaultFontFamily;
}
const vertAlign = runProps.vertAlign as 'superscript' | 'subscript' | 'baseline' | undefined;
const hasPosition = runProps.position != null && Number.isFinite(runProps.position);
let fontSize = runProps.fontSize ? ptToPx(runProps.fontSize / 2)! : defaultFontSizePx;

// Scale font size for superscript/subscript when no custom position override
if (!hasPosition && (vertAlign === 'superscript' || vertAlign === 'subscript')) {
fontSize *= SUBSCRIPT_SUPERSCRIPT_SCALE;
}

return {
fontFamily: toCssFontFamily(fontFamily)!,
fontSize: runProps.fontSize ? ptToPx(runProps.fontSize / 2)! : defaultFontSizePx,
fontSize,
bold: runProps.bold,
italic: runProps.italic,
underline:
Expand All @@ -339,5 +349,7 @@ export const computeRunAttrs = (
letterSpacing: runProps.letterSpacing ? twipsToPx(runProps.letterSpacing) : undefined,
lang: runProps.lang?.val || undefined,
vanish: runProps.vanish,
vertAlign,
baselineShift: hasPosition ? runProps.position! / 2 : undefined,
};
};
7 changes: 7 additions & 0 deletions packages/layout-engine/pm-adapter/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@
import type { TextRun, TrackedChangeKind } from '@superdoc/contracts';
import type { HyperlinkConfig } from './types.js';

/**
* Font size scaling factor for subscript and superscript text.
* Matches Microsoft Word's default rendering behavior for w:vertAlign
* when set to 'superscript' or 'subscript'.
*/
export const SUBSCRIPT_SUPERSCRIPT_SCALE = 0.65;

/**
* Unit conversion constants
*/
Expand Down
70 changes: 70 additions & 0 deletions packages/layout-engine/pm-adapter/src/marks/application.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -810,6 +810,76 @@ describe('mark application', () => {
expect(run.textTransform).toBe('uppercase');
});
});

describe('vertAlign', () => {
it('sets vertAlign for superscript', () => {
const run: TextRun = { text: '1st', fontFamily: 'Arial', fontSize: 16 };
applyTextStyleMark(run, { vertAlign: 'superscript' });
expect(run.vertAlign).toBe('superscript');
});

it('sets vertAlign for subscript', () => {
const run: TextRun = { text: 'H2O', fontFamily: 'Arial', fontSize: 16 };
applyTextStyleMark(run, { vertAlign: 'subscript' });
expect(run.vertAlign).toBe('subscript');
});

it('sets vertAlign for baseline', () => {
const run: TextRun = { text: 'text', fontFamily: 'Arial', fontSize: 16 };
applyTextStyleMark(run, { vertAlign: 'baseline' });
expect(run.vertAlign).toBe('baseline');
});

it('ignores invalid vertAlign values', () => {
const run: TextRun = { text: 'text', fontFamily: 'Arial', fontSize: 16 };
applyTextStyleMark(run, { vertAlign: 'invalid' });
expect(run.vertAlign).toBeUndefined();
});

it('scales fontSize by 0.65 for superscript', () => {
const run: TextRun = { text: '1st', fontFamily: 'Arial', fontSize: 16 };
applyTextStyleMark(run, { vertAlign: 'superscript' });
expect(run.fontSize).toBeCloseTo(16 * 0.65);
});

it('scales fontSize by 0.65 for subscript', () => {
const run: TextRun = { text: 'H2O', fontFamily: 'Arial', fontSize: 16 };
applyTextStyleMark(run, { vertAlign: 'subscript' });
expect(run.fontSize).toBeCloseTo(16 * 0.65);
});

it('does not scale fontSize for baseline', () => {
const run: TextRun = { text: 'text', fontFamily: 'Arial', fontSize: 16 };
applyTextStyleMark(run, { vertAlign: 'baseline' });
expect(run.fontSize).toBe(16);
});

it('does not scale fontSize when baselineShift is set', () => {
const run: TextRun = { text: '1st', fontFamily: 'Arial', fontSize: 16 };
applyTextStyleMark(run, { vertAlign: 'superscript', position: '3pt' });
expect(run.fontSize).toBe(16);
});
});

describe('position / baselineShift', () => {
it('parses position string to baselineShift number', () => {
const run: TextRun = { text: 'text', fontFamily: 'Arial', fontSize: 16 };
applyTextStyleMark(run, { position: '3pt' });
expect(run.baselineShift).toBe(3);
});

it('handles negative position values', () => {
const run: TextRun = { text: 'text', fontFamily: 'Arial', fontSize: 16 };
applyTextStyleMark(run, { position: '-1.5pt' });
expect(run.baselineShift).toBe(-1.5);
});

it('ignores non-numeric position', () => {
const run: TextRun = { text: 'text', fontFamily: 'Arial', fontSize: 16 };
applyTextStyleMark(run, { position: 'invalid' });
expect(run.baselineShift).toBeUndefined();
});
});
});

describe('applyMarksToRun', () => {
Expand Down
Loading
Loading