Signal drop!
Relay (operand.online) is unreachable.
Usually, a dropped signal means an upgrade is happening. Hold on!
Sorry, no connección.
Hang in there while we get back on track
gram: docs
> ./packages/core/src/layout-painter/renderParagraph/runs.ts
/**
* Per-run rendering: text, tab, image, line-break, field.
*
* `applyRunStyles` is the big block — every run-level OOXML property (font,
* color, decoration, baseline, scale, kerning, emboss/imprint, emphasis,
* tracked-change visuals) is mapped to a CSS recipe here. The individual
* `render*Run` functions wrap a styled span/img/br and apply pm position
* data attrs for selection mapping.
*/
import type {
Run,
TextRun,
TabRun,
ImageRun,
LineBreakRun,
FieldRun,
} from '../../layout-engine/types';
import type { RenderContext } from '../renderPage';
import { isFloatingImageRun } from '../floatingImageFlow';
import { applyImageVisualAttrs, hasImageVisualAttrs } from '../renderImage';
import { resolveFontFamily } from '../../utils/fontResolver';
import {
PARAGRAPH_CLASS_NAMES,
isTextRun,
isTabRun,
isImageRun,
isLineBreakRun,
isFieldRun,
} from './shared';
/**
* Apply text run styles to an element
*/
function applyRunStyles(
element: HTMLElement,
run: TextRun | TabRun,
resolvedCommentIds?: Set<number>
): void {
// Font properties
if (run.fontFamily) {
// Use the font resolver for category-appropriate fallback stacks,
// matching the same stacks used in measureContainer.ts
element.style.fontFamily = resolveFontFamily(run.fontFamily).cssFallback;
}
if (run.fontSize) {
// fontSize is in points - convert to pixels to match Canvas measurement
// (1pt = 96/72 px at standard web DPI)
// Using px ensures consistent rendering with Canvas-based measurements
const fontSizePx = (run.fontSize * 96) / 72;
element.style.fontSize = `${fontSizePx}px`;
}
if (run.bold) {
element.style.fontWeight = 'bold';
}
if (run.italic) {
element.style.fontStyle = 'italic';
}
// Color
if (run.color) {
element.style.color = run.color;
}
// Letter spacing
if (run.letterSpacing) {
element.style.letterSpacing = `${run.letterSpacing}px`;
}
// Caps / small-caps. OOXML w:caps = render glyphs uppercase; w:smallCaps =
// render lowercase glyphs as small uppercase. Map directly onto the
// matching CSS properties — same translation the hidden PM toDOM uses.
if (run.allCaps) {
element.style.textTransform = 'uppercase';
}
if (run.smallCaps) {
element.style.fontVariant = 'small-caps';
}
// Baseline shift (OOXML w:position). Already converted from half-points to
// CSS px on the bridge; positive raises text the same way CSS does.
if (run.positionPx) {
element.style.verticalAlign = `${run.positionPx}px`;
}
// Horizontal scale (OOXML w:w). Stored as a percent (100 = normal). Apply
// via scaleX on an inline-block so the transform actually takes effect.
if (run.horizontalScale && run.horizontalScale !== 100) {
element.style.display = 'inline-block';
element.style.transform = `scaleX(${run.horizontalScale / 100})`;
element.style.transformOrigin = 'left center';
}
// Kerning gate (OOXML w:kern). Enable font-kerning when the run's font
// size is at or above the threshold; otherwise leave it at the browser
// default (`auto`). The painter only knows the resolved fontSize at this
// point — assume the gate is satisfied if a non-zero threshold was set.
if (run.kerningMinPt && run.kerningMinPt > 0) {
const fontSizePt = run.fontSize ?? 11;
if (fontSizePt >= run.kerningMinPt) {
element.style.fontKerning = 'normal';
}
}
// Cosmetic effect marks (§17.3.2.13/.18/.23/.31/.12). The hidden PM
// toDOM uses the same CSS recipes — keep them in sync so the painted
// and editable representations match.
if (run.emboss) {
element.style.textShadow = '1px 1px 1px rgba(255,255,255,0.5), -1px -1px 1px rgba(0,0,0,0.3)';
}
if (run.imprint) {
element.style.textShadow = '-1px -1px 1px rgba(255,255,255,0.5), 1px 1px 1px rgba(0,0,0,0.3)';
}
if (run.textShadow && !run.emboss && !run.imprint) {
// Don't double-apply when emboss/imprint already set text-shadow.
element.style.textShadow = '1px 1px 2px rgba(0,0,0,0.3)';
}
if (run.textOutline) {
element.style.webkitTextStroke = '1px currentColor';
(element.style as CSSStyleDeclaration & { webkitTextFillColor?: string }).webkitTextFillColor =
'transparent';
}
if (run.emphasisMark) {
const variant =
run.emphasisMark === 'comma'
? 'filled sesame'
: run.emphasisMark === 'circle'
? 'filled circle'
: 'filled dot';
const position = run.emphasisMark === 'underDot' ? 'under right' : 'over right';
element.style.textEmphasis = `${variant}`;
element.style.textEmphasisPosition = position;
// Safari prefix.
(element.style as CSSStyleDeclaration & { webkitTextEmphasis?: string }).webkitTextEmphasis =
variant;
(
element.style as CSSStyleDeclaration & { webkitTextEmphasisPosition?: string }
).webkitTextEmphasisPosition = position;
}
// Highlight (background color)
if (run.highlight) {
element.style.backgroundColor = run.highlight;
}
// Text decorations
const decorations: string[] = [];
if (run.underline) {
decorations.push('underline');
if (typeof run.underline === 'object') {
if (run.underline.style) {
element.style.textDecorationStyle = run.underline.style;
}
if (run.underline.color) {
element.style.textDecorationColor = run.underline.color;
}
}
}
if (run.strike) {
decorations.push('line-through');
}
// Comment highlight (skip for resolved comments)
if (run.commentIds && run.commentIds.length > 0) {
const activeCommentId = run.commentIds.find(
(id) => !resolvedCommentIds || !resolvedCommentIds.has(id)
);
if (activeCommentId != null) {
element.style.backgroundColor = 'rgba(255, 212, 0, 0.15)';
element.style.borderBottom = '1px solid rgba(255, 212, 0, 0.4)';
element.style.cursor = 'pointer';
element.style.transition = 'background-color 0.15s ease';
element.dataset.commentId = String(activeCommentId);
}
}
// Tracked insertion styling — light green background with dashed border
if (run.isInsertion) {
element.style.backgroundColor = 'rgba(52, 168, 83, 0.08)';
element.style.borderBottom = '2px dashed #2e7d32';
element.style.paddingBottom = '1px';
element.classList.add('docx-insertion');
if (run.changeAuthor) element.dataset.changeAuthor = run.changeAuthor;
if (run.changeDate) element.dataset.changeDate = run.changeDate;
if (run.changeRevisionId != null) element.dataset.revisionId = String(run.changeRevisionId);
}
// Tracked deletion styling — light red background with strikethrough
if (run.isDeletion) {
element.style.backgroundColor = 'rgba(211, 47, 47, 0.08)';
element.style.color = '#c62828';
if (!decorations.includes('line-through')) decorations.push('line-through');
element.style.textDecorationColor = '#c62828';
element.classList.add('docx-deletion');
if (run.changeAuthor) element.dataset.changeAuthor = run.changeAuthor;
if (run.changeDate) element.dataset.changeDate = run.changeDate;
if (run.changeRevisionId != null) element.dataset.revisionId = String(run.changeRevisionId);
}
if (decorations.length > 0) {
element.style.textDecorationLine = decorations.join(' ');
}
// Superscript/subscript
if (run.superscript) {
element.style.verticalAlign = 'super';
element.style.fontSize = '0.75em';
}
if (run.subscript) {
element.style.verticalAlign = 'sub';
element.style.fontSize = '0.75em';
}
// Hidden run (OOXML w:vanish, §17.3.2.41). In Word's print/normal view
// hidden text is suppressed entirely, but in *editing* view (which we
// always are) Word still draws it dimmed with a dotted underline so the
// author can navigate to and edit it. Mirror that: keep the run in flow
// and selectable — `display: none` would orphan PM positions and break
// cursor movement across hidden ranges. A `docx-hidden` class hook lets
// host CSS swap to print-style suppression when a future view-mode toggle
// ships.
if (run.hidden) {
element.classList.add('docx-hidden');
element.style.opacity = '0.4';
element.style.textDecoration = 'underline dotted';
}
// Per-run RTL (OOXML w:rtl): flip just this run, independent of the
// paragraph's bidi direction. The browser's bidi algorithm picks up `dir`
// automatically from the attribute.
if (run.rtl) {
element.setAttribute('dir', 'rtl');
}
// Legacy w:effect animations: surface as a class hook so the host CSS
// can opt in. We avoid applying actual animations because Word's effects
// are obtrusive and most modern docs treat them as legacy decoration.
if (run.textEffect) {
element.classList.add('docx-text-effect', `docx-text-effect-${run.textEffect}`);
element.dataset.effect = run.textEffect;
}
}
/**
* Apply PM position data attributes
*/
export function applyPmPositions(element: HTMLElement, pmStart?: number, pmEnd?: number): void {
if (pmStart !== undefined) {
element.dataset.pmStart = String(pmStart);
}
if (pmEnd !== undefined) {
element.dataset.pmEnd = String(pmEnd);
}
}
function applyInlineSdtWidgetAttrs(element: HTMLElement, run: TextRun): void {
const widget = run.inlineSdtWidget;
if (!widget) return;
element.classList.add('layout-inline-sdt-widget');
element.dataset.sdtWidget = widget.kind;
element.dataset.sdtGroupId = widget.groupId;
element.dataset.sdtPos = String(widget.pos);
if (widget.tag) element.dataset.sdtTag = widget.tag;
if (widget.alias) element.dataset.sdtAlias = widget.alias;
if (typeof widget.checked === 'boolean') {
element.dataset.sdtChecked = String(widget.checked);
element.setAttribute('aria-checked', String(widget.checked));
}
element.setAttribute('role', 'checkbox');
element.setAttribute('tabindex', '0');
element.setAttribute('aria-label', widget.alias || widget.tag || 'Checkbox content control');
}
/**
* Render a text run
*/
export function renderTextRun(
run: TextRun,
doc: Document,
resolvedCommentIds?: Set<number>
): HTMLElement {
const span = doc.createElement('span');
span.className = `${PARAGRAPH_CLASS_NAMES.run} ${PARAGRAPH_CLASS_NAMES.text}`;
applyRunStyles(span, run, resolvedCommentIds);
applyPmPositions(span, run.pmStart, run.pmEnd);
applyInlineSdtWidgetAttrs(span, run);
// Handle hyperlinks
if (run.hyperlink) {
const anchor = doc.createElement('a');
anchor.href = run.hyperlink.href;
// Internal bookmark links (starting with #) should scroll within the document
// External links should open in a new tab
if (!run.hyperlink.href.startsWith('#')) {
anchor.target = '_blank';
anchor.rel = 'noopener noreferrer';
}
if (run.hyperlink.tooltip) {
anchor.title = run.hyperlink.tooltip;
}
anchor.textContent = run.text;
// Inherit from the wrapping span (already styled by applyRunStyles). Fall
// back to Word-default blue/underline only when nothing was resolved and
// the source didn't opt out via HyperlinkInfo.noDefaultStyle (TOC runs).
anchor.style.color = 'inherit';
anchor.style.textDecoration = 'inherit';
if (!run.hyperlink.noDefaultStyle) {
if (!run.color) {
anchor.style.color = '#0563c1';
span.style.color = '#0563c1';
}
if (!run.underline) {
anchor.style.textDecoration = 'underline';
}
}
span.appendChild(anchor);
} else {
// Set text content
span.textContent = run.text;
}
return span;
}
/**
* Number of leader characters to fill the tab's inner span. The inner span
* uses `overflow: hidden` so excess characters are clipped invisibly; we just
* need enough to span the widest realistic tab stop at the thinnest leader
* (a dot at small font sizes). 1000 covers wide-landscape pages with ~2px dots.
*/
const LEADER_FILL_COUNT = 1000;
/**
* Render a tab run with calculated width
*/
export function renderTabRun(
run: TabRun,
doc: Document,
width: number,
leader?: string
): HTMLElement {
const span = doc.createElement('span');
span.className = `${PARAGRAPH_CLASS_NAMES.run} ${PARAGRAPH_CLASS_NAMES.tab}`;
span.style.display = 'inline-block';
span.style.width = `${width}px`;
applyPmPositions(span, run.pmStart, run.pmEnd);
const leaderChar = leader && leader !== 'none' ? getLeaderChar(leader) : null;
if (leaderChar) {
// Outer span holds a zero-width space so its baseline aligns with the
// surrounding text. Inner absolutely-positioned span carries the dots
// and clips horizontally; keeping `overflow: hidden` off the outer
// avoids the inline-block baseline-at-margin-edge problem.
span.style.position = 'relative';
span.textContent = '';
const inner = doc.createElement('span');
inner.style.position = 'absolute';
inner.style.left = '0';
inner.style.right = '0';
inner.style.top = '0';
inner.style.bottom = '0';
inner.style.overflow = 'hidden';
inner.style.whiteSpace = 'nowrap';
inner.textContent = leaderChar.repeat(LEADER_FILL_COUNT);
span.appendChild(inner);
} else {
// No leader: a single nbsp carries the line-height for layout.
span.textContent = ' ';
}
return span;
}
/**
* Get leader character for tab
*/
function getLeaderChar(leader: string): string | null {
switch (leader) {
case 'dot':
return '.';
case 'hyphen':
return '-';
case 'underscore':
return '_';
case 'middleDot':
return '·';
case 'heavy':
return '_';
default:
return null;
}
}
/**
* Parse the rotation angle (in degrees, normalized to [0, 360)) from a
* `transform` string like `"rotate(90deg) scaleX(-1)"`. Returns 0 when no
* `rotate()` term is present.
*/
function rotationDegrees(transform: string | undefined): number {
if (!transform) return 0;
const m = transform.match(/rotate\(([-\d.]+)deg\)/);
if (!m) return 0;
return ((parseFloat(m[1]) % 360) + 360) % 360;
}
/**
* Axis-aligned bounding box of a rectangle of size `w × h` rotated by
* `deg` degrees. For multiples of 90° the dims swap (or stay) without
* floating-point drift; arbitrary angles use the standard formula.
*/
function rotatedBoundingBox(w: number, h: number, deg: number): { w: number; h: number } {
if (deg === 0 || deg === 180) return { w, h };
if (deg === 90 || deg === 270) return { w: h, h: w };
const rad = (deg * Math.PI) / 180;
const sinA = Math.abs(Math.sin(rad));
const cosA = Math.abs(Math.cos(rad));
return { w: w * cosA + h * sinA, h: w * sinA + h * cosA };
}
/**
* Render an inline image run (flows with text)
*/
/**
* Apply an inline image's `wp:inline` distT/distB wrap distances as top/bottom
* margins. The line measurer folds the same values into the line height, so
* the flowed element's margin box matches the reserved space.
*/
function applyInlineImageDist(el: HTMLElement, run: ImageRun): void {
if (run.distTop) el.style.marginTop = `${run.distTop}px`;
if (run.distBottom) el.style.marginBottom = `${run.distBottom}px`;
}
function renderInlineImageRun(run: ImageRun, doc: Document): HTMLElement {
const img = doc.createElement('img');
img.className = `${PARAGRAPH_CLASS_NAMES.run} ${PARAGRAPH_CLASS_NAMES.image}`;
img.src = run.src;
img.width = run.width;
img.height = run.height;
// Lock dimensions explicitly: when only the width/height attributes are set,
// browsers may compute height from the natural aspect ratio (e.g. wp:extent
// 1771650×278918 EMU rounds to 186×29 px but native 800×126 px gives 29.29 px,
// overflowing the cell by ~0.3 px and clipping the bottom of the logo).
img.style.width = `${run.width}px`;
img.style.height = `${run.height}px`;
if (run.alt) {
img.alt = run.alt;
}
if (run.transform) {
img.style.transform = run.transform;
// Word rotates around the picture's geometric center; the CSS default
// happens to match, but be explicit so future transforms can't drift.
img.style.transformOrigin = 'center center';
}
if (hasImageVisualAttrs(run)) applyImageVisualAttrs(img, run);
const deg = rotationDegrees(run.transform);
if (deg !== 0) {
// Rotated content extends past `run.width × run.height`, so the inline
// line box would otherwise reserve too little space and adjacent text
// would overlap the picture. Wrap the rotated img in a span sized to
// its axis-aligned bounding box and position the img absolutely at the
// wrapper's centre so the rotation pivots correctly. This matches
// Word's behaviour where `wp:extent` reflects the post-rotation bbox
// and the picture content rotates inside it.
const bbox = rotatedBoundingBox(run.width, run.height, deg);
const wrapper = doc.createElement('span');
wrapper.style.display = 'inline-block';
wrapper.style.position = 'relative';
wrapper.style.width = `${bbox.w}px`;
wrapper.style.height = `${bbox.h}px`;
wrapper.style.verticalAlign = 'middle';
img.style.position = 'absolute';
img.style.left = `${(bbox.w - run.width) / 2}px`;
img.style.top = `${(bbox.h - run.height) / 2}px`;
applyInlineImageDist(wrapper, run);
applyPmPositions(wrapper, run.pmStart, run.pmEnd);
wrapper.appendChild(img);
return wrapper;
}
// Tailwind preflight resets `<img>` to `display: block`, which breaks the
// inline run flow: an inline image preceded and followed by text would push
// the trailing text to a new visual row inside the line div, overflowing the
// measured line height into the next paragraph. `inline-block` keeps the
// image inside the line's flow while preserving its explicit width/height.
img.style.display = 'inline-block';
// Middle alignment — when the line's height was sized with extra leading on
// both sides (imageH + 2*descent), middle puts the image roughly at line
// center with visible padding above and below, matching Word's render. (Pure
// baseline/top would land flush with the line edge.)
img.style.verticalAlign = 'middle';
// Fit the image to its container's content width (the text column or table
// cell) while preserving the run's aspect ratio: cap the width at 100% of the
// container and let `aspect-ratio` drive the height. Without this, a wide
// image in a narrow cell squashes (the explicit height stays while the width
// is clamped) or overflows the page entirely. The run's own aspect is used —
// not the image's natural aspect — so a deliberately stretched image keeps
// its shape. Behaves identically in React and Vue (both use this painter).
if (run.width > 0 && run.height > 0) {
img.style.height = 'auto';
img.style.aspectRatio = `${run.width} / ${run.height}`;
img.style.maxWidth = '100%';
}
applyInlineImageDist(img, run);
applyPmPositions(img, run.pmStart, run.pmEnd);
return img;
}
/**
* Render a block image (on its own line, like topAndBottom)
*/
function renderBlockImage(run: ImageRun, doc: Document): HTMLElement {
const container = doc.createElement('div');
container.className = 'layout-block-image';
container.style.display = 'block';
container.style.textAlign = 'center';
container.style.marginTop = `${run.distTop ?? 6}px`;
container.style.marginBottom = `${run.distBottom ?? 6}px`;
const img = doc.createElement('img');
img.src = run.src;
img.width = run.width;
img.height = run.height;
// Global CSS reset (Tailwind preflight) sets img { display: block },
// which makes text-align: center on the container ineffective.
// Use margin: auto on the img itself to center it.
img.style.marginLeft = 'auto';
img.style.marginRight = 'auto';
if (run.alt) {
img.alt = run.alt;
}
if (run.transform) {
img.style.transform = run.transform;
img.style.transformOrigin = 'center center';
}
if (hasImageVisualAttrs(run)) applyImageVisualAttrs(img, run);
// Reserve the rotated bbox height so the rotated image doesn't bleed into
// adjacent paragraphs. The container height matches the bbox; the inner
// img rotates around its own centre, which now lands inside the wrapper.
const deg = rotationDegrees(run.transform);
if (deg !== 0) {
const bbox = rotatedBoundingBox(run.width, run.height, deg);
container.style.height = `${bbox.h}px`;
container.style.position = 'relative';
img.style.position = 'absolute';
img.style.left = '50%';
img.style.top = '50%';
img.style.marginLeft = `${-run.width / 2}px`;
img.style.marginRight = '0';
img.style.marginTop = `${-run.height / 2}px`;
}
applyPmPositions(container, run.pmStart, run.pmEnd);
container.appendChild(img);
return container;
}
/**
* Render an image run based on its display mode
* Note: Floating images (square/tight/through) are handled separately at paragraph level,
* not through this function. If they reach here, render as block.
*/
export function renderImageRun(run: ImageRun, doc: Document): HTMLElement {
// Floating images should be handled at paragraph level, not here
// If they reach here (e.g., inside table cells), render as block
let el: HTMLElement;
if (isFloatingImageRun(run)) {
el = renderBlockImage(run, doc);
} else if (run.displayMode === 'block' || run.wrapType === 'topAndBottom') {
el = renderBlockImage(run, doc);
} else {
// Default: inline
el = renderInlineImageRun(run, doc);
}
applyImageRevisionStyle(el, run);
return el;
}
/**
* A picture that is itself a tracked change gets a colored outline (green for
* an insertion, red + faded for a deletion), mirroring the text-run treatment.
* `outline` is used over `border` so it does not change the image's box size
* and perturb the measured line height.
*/
function applyImageRevisionStyle(el: HTMLElement, run: ImageRun): void {
if (run.isInsertion) {
el.style.outline = '2px solid #2e7d32';
el.style.outlineOffset = '1px';
el.classList.add('docx-insertion');
} else if (run.isDeletion) {
el.style.outline = '2px solid #c62828';
el.style.outlineOffset = '1px';
el.style.opacity = '0.6';
el.classList.add('docx-deletion');
} else {
return;
}
if (run.changeAuthor) el.dataset.changeAuthor = run.changeAuthor;
if (run.changeDate) el.dataset.changeDate = run.changeDate;
if (run.changeRevisionId != null) el.dataset.revisionId = String(run.changeRevisionId);
}
/**
* Render a line break run
*/
export function renderLineBreakRun(run: LineBreakRun, doc: Document): HTMLElement {
const br = doc.createElement('br');
br.className = `${PARAGRAPH_CLASS_NAMES.run} ${PARAGRAPH_CLASS_NAMES.lineBreak}`;
applyPmPositions(br, run.pmStart, run.pmEnd);
return br;
}
/**
* Render a field run (PAGE, NUMPAGES, etc.)
* Substitutes the field with actual values from context.
*/
export function renderFieldRun(run: FieldRun, doc: Document, context: RenderContext): HTMLElement {
let text = run.fallback ?? '';
switch (run.fieldType) {
case 'PAGE':
text = String(context.pageNumber);
break;
case 'NUMPAGES':
text = String(context.totalPages);
break;
case 'DATE':
text = new Date().toLocaleDateString();
break;
case 'TIME':
text = new Date().toLocaleTimeString();
break;
// OTHER fields use fallback
}
// Create a text run with the resolved value. Spread the field run so every
// RunFormatting field (not just font/size/color) carries through — Word
// renders the field result with the result run's full w:rPr. The extra
// `fieldType`/`fallback` keys are inert on a TextRun.
const resolvedRun: TextRun = {
...run,
kind: 'text',
text,
};
return renderTextRun(resolvedRun, doc, context?.resolvedCommentIds);
}
/**
* Render a single run (for non-tab runs)
*/
export function renderRun(run: Run, doc: Document, context?: RenderContext): HTMLElement {
if (isTextRun(run)) {
return renderTextRun(run, doc, context?.resolvedCommentIds);
}
if (isTabRun(run)) {
// Tab runs should be handled by renderLine with proper width calculation
// This is a fallback for cases where tab context isn't available
return renderTabRun(run, doc, 48, undefined); // Default 0.5 inch tab
}
if (isImageRun(run)) {
return renderImageRun(run, doc);
}
if (isLineBreakRun(run)) {
return renderLineBreakRun(run, doc);
}
if (isFieldRun(run) && context) {
return renderFieldRun(run, doc, context);
}
// Fallback for unknown run types
const span = doc.createElement('span');
span.className = PARAGRAPH_CLASS_NAMES.run;
return span;
}