-
Notifications
You must be signed in to change notification settings - Fork 211
Expand file tree
/
Copy pathrender-line.ts
More file actions
1341 lines (1234 loc) · 53.5 KB
/
Copy pathrender-line.ts
File metadata and controls
1341 lines (1234 loc) · 53.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import type { ImageRun, Line, LineSegment, ParagraphAttrs, ParagraphBlock, Run, TextRun } from '@superdoc/contracts';
import {
calculateJustifySpacing,
calculateInterCharacterJustifySpacing,
computeLinePmRange,
expandRunsForInlineNewlines,
isEmptyInlineSdtPlaceholderRun,
isEmptySdtPlaceholderRun,
normalizeBaselineShift,
shouldApplyJustify,
sliceRunsForLine,
SPACE_CHARS,
usesPositionedTextGeometry,
} from '@superdoc/contracts';
import {
isMinimalWordLayout as isMinimalWordLayoutShared,
type MinimalWordLayout,
} from '@superdoc/common/list-marker-utils';
import { CLASS_NAMES, lineStyles } from '../styles.js';
import { applyRtlStyles } from '../features/inline-direction/index.js';
import { applyTooltipAccessibility } from './links.js';
import { appendFormattingParagraphMark } from './formatting-marks.js';
import { textRunMergeSignature } from './hash.js';
import { inlineBoxAdvanceBeforeOffset, markInlineBoxRun, paintInlineBoxes, splitInlineBoxRuns } from './inline-box.js';
import { isBreakRun, isFieldAnnotationRun, isImageRun, isLineBreakRun, isMathRun, renderRun } from './render-run.js';
import { applyRunTypographyStyles, runStrikeDecoration } from './text-run.js';
import {
canPaintUnderlineOverlay,
renderInlineTabRun,
renderPositionedTabRun,
underlineBorderForRun,
underlineOffsetFromLineTop,
} from './tab-run.js';
import type { RenderLineParams } from './types.js';
/**
* Type guard narrowing to the shared word layout contract type.
* Delegates structural validation to the shared isMinimalWordLayout guard.
*/
function isMinimalWordLayout(value: unknown): value is MinimalWordLayout {
return isMinimalWordLayoutShared(value);
}
const applyStyles = (el: HTMLElement, styles: Partial<CSSStyleDeclaration>): void => {
Object.entries(styles).forEach(([key, value]) => {
if (value != null && value !== '' && key in el.style) {
(el.style as unknown as Record<string, string>)[key] = String(value);
}
});
};
const countSpaces = (text: string): number => {
let count = 0;
for (let i = 0; i < text.length; i += 1) {
if (SPACE_CHARS.has(text[i])) count += 1;
}
return count;
};
const isWhitespaceOnly = (text: string): boolean => {
if (text.length === 0) return false;
for (let i = 0; i < text.length; i += 1) {
if (!SPACE_CHARS.has(text[i])) return false;
}
return true;
};
const hasTabBoundaryGeometry = (segment: LineSegment): boolean =>
segment.x !== undefined || segment.precedingTabEndX !== undefined;
const isVanishedTextRun = (run: Run | undefined): run is TextRun =>
Boolean(run && (run.kind === 'text' || run.kind === undefined) && (run as TextRun).vanish === true);
const isParagraphMarkDeletionAnchorRun = (run: Run | undefined): run is TextRun =>
isVanishedTextRun(run) && (run as TextRun).dataAttrs?.['data-paragraph-mark-deletion-anchor'] === 'true';
const isTransparentVanishedSegment = (run: Run | undefined, segment: LineSegment): boolean =>
isVanishedTextRun(run) && segment.width === 0 && !hasTabBoundaryGeometry(segment);
/**
* Word's workaround for a top-aligned, line-expanding inline image: normal text
* beside it is dropped to the bottom of the (expanded) line box so the text
* baseline sits below the tall image instead of being centered against it.
*
* Scoped to lines that actually contain a line-expanding (top-aligned) image.
* Glyph-like baseline images do NOT trigger this — their line height matches the
* text, so the surrounding text must keep its natural baseline alignment.
*/
const alignNormalTextBesideLineExpandingImage = (
element: HTMLElement,
run: Run,
lineContainsLineExpandingImage: boolean,
): void => {
if (!lineContainsLineExpandingImage) return;
if ((run.kind !== 'text' && run.kind !== undefined) || !('text' in run)) return;
const textRun = run as TextRun;
if (normalizeBaselineShift(textRun.baselineShift) != null || textRun.vertAlign != null) return;
element.style.lineHeight = 'normal';
element.style.verticalAlign = 'bottom';
};
const LINE_EXPANDING_IMAGE_TOLERANCE_PX = 0.5;
/**
* Copy the measured per-line inline-image vertical alignment onto the image runs
* of a paragraph block, returning a shallow-cloned block when anything changed.
*
* Effective alignment order (highest first): an authored `run.verticalAlign`,
* then the measured `line.inlineImageAlignments` entry, then the painter's
* legacy `'top'` default. Resolving here (once per line, before the branch
* split) keeps the inline-flow and segment-positioned branches consistent and
* is pure data shaping — no DOM measurement.
*/
const resolveLineInlineImageRuns = (block: ParagraphBlock, line: Line): ParagraphBlock => {
const alignments = line.inlineImageAlignments;
if (!alignments || alignments.length === 0) return block;
let runs: Run[] | null = null;
for (const { runIndex, verticalAlign } of alignments) {
const run = block.runs[runIndex];
if (!run || !isImageRun(run)) continue;
// An authored alignment is the source of truth and always wins.
if ((run as ImageRun).verticalAlign != null) continue;
if (!runs) runs = [...block.runs];
runs[runIndex] = { ...(run as ImageRun), verticalAlign };
}
return runs ? { ...block, runs } : block;
};
/** True when a resolved image run is top/default aligned and expands the line box. */
const isLineExpandingImageRun = (run: Run, line: Line): boolean => {
if (!isImageRun(run)) return false;
const va = (run as ImageRun).verticalAlign;
if (va != null && va !== 'top') return false;
const imageOuterHeight =
(run as ImageRun).height + ((run as ImageRun).distTop ?? 0) + ((run as ImageRun).distBottom ?? 0);
if (imageOuterHeight <= 0) return false;
return imageOuterHeight >= line.lineHeight - LINE_EXPANDING_IMAGE_TOLERANCE_PX;
};
const baselineImageTopFromLine = (line: Line, imageHeight: number): number => {
const halfLeading = Math.max(0, (line.lineHeight - line.ascent - line.descent) / 2);
return Math.max(0, halfLeading + line.ascent - imageHeight);
};
const cloneTextRun = (run: TextRun): TextRun => ({
...(run as TextRun),
comments: run.comments ? [...run.comments] : undefined,
dataAttrs: run.dataAttrs ? { ...run.dataAttrs } : undefined,
underline: run.underline ? { ...run.underline } : undefined,
pageRefMetadata: run.pageRefMetadata ? { ...run.pageRefMetadata } : undefined,
});
const applyInterCharacterSpacingToRuns = (runs: Run[], spacing: number, boundaries: readonly number[]): Run[] => {
if (spacing === 0 || boundaries.length === 0) return runs;
let lineOffset = 0;
return runs.flatMap((run) => {
if (!isTextRun(run) || run.text.length === 0) return [run];
const runStart = lineOffset;
const runEnd = runStart + run.text.length;
lineOffset = runEnd;
let lastBoundary: number | undefined;
for (let index = boundaries.length - 1; index >= 0; index -= 1) {
const boundary = boundaries[index]!;
if (boundary > runStart && boundary <= runEnd) {
lastBoundary = boundary;
break;
}
}
if (lastBoundary == null) return [run];
const splitAt = lastBoundary - runStart;
if (splitAt === run.text.length) return [{ ...run, letterSpacing: spacing }];
const splitPm =
run.pmStart != null
? run.pmStart + splitAt
: run.pmEnd != null
? run.pmEnd - (run.text.length - splitAt)
: undefined;
return [
{ ...run, text: run.text.slice(0, splitAt), pmEnd: splitPm, letterSpacing: spacing },
{ ...run, text: run.text.slice(splitAt), pmStart: splitPm, letterSpacing: run.letterSpacing },
];
});
};
const normalizeJustifiedRuns = (runsForLine: Run[]): Run[] => {
const normalized: Run[] = runsForLine.map((run) => {
if ((run.kind !== 'text' && run.kind !== undefined) || !('text' in run)) return run;
return cloneTextRun(run as TextRun);
});
const merged: Run[] = [];
for (let i = 0; i < normalized.length; i += 1) {
const run = normalized[i]!;
if ((run.kind !== 'text' && run.kind !== undefined) || !('text' in run)) {
merged.push(run);
continue;
}
const textRun = run as TextRun;
if (!isWhitespaceOnly(textRun.text ?? '')) {
merged.push(textRun);
continue;
}
const prev = merged[merged.length - 1];
if (prev && (prev.kind === 'text' || prev.kind === undefined) && 'text' in prev) {
const prevTextRun = prev as TextRun;
if (textRunMergeSignature(prevTextRun) === textRunMergeSignature(textRun)) {
const extra = textRun.text ?? '';
prevTextRun.text = (prevTextRun.text ?? '') + extra;
if (prevTextRun.pmStart != null) {
prevTextRun.pmEnd = prevTextRun.pmStart + prevTextRun.text.length;
} else if (prevTextRun.pmEnd != null) {
prevTextRun.pmEnd = prevTextRun.pmEnd + extra.length;
}
continue;
}
}
const next = normalized[i + 1];
if (next && (next.kind === 'text' || next.kind === undefined) && 'text' in next) {
const nextTextRun = next as TextRun;
if (textRunMergeSignature(nextTextRun) === textRunMergeSignature(textRun)) {
const extra = textRun.text ?? '';
nextTextRun.text = extra + (nextTextRun.text ?? '');
if (textRun.pmStart != null) {
nextTextRun.pmStart = textRun.pmStart;
} else if (nextTextRun.pmStart != null) {
nextTextRun.pmStart = nextTextRun.pmStart - extra.length;
}
if (nextTextRun.pmStart != null && nextTextRun.pmEnd == null) {
nextTextRun.pmEnd = nextTextRun.pmStart + nextTextRun.text.length;
}
continue;
}
}
merged.push(textRun);
}
// Suppress trailing wrap-point spaces on justified lines. With `white-space: pre`, they would
// otherwise consume width and be stretched by word-spacing, producing a ragged visible edge.
// Preserve intentionally space-only lines (rare but supported).
const hasNonSpaceText = merged.some(
(run) => (run.kind === 'text' || run.kind === undefined) && 'text' in run && (run.text ?? '').trim().length > 0,
);
if (hasNonSpaceText) {
for (let i = merged.length - 1; i >= 0; i -= 1) {
const run = merged[i];
if ((run.kind !== 'text' && run.kind !== undefined) || !('text' in run)) continue;
const text = run.text ?? '';
let trimCount = 0;
for (let j = text.length - 1; j >= 0 && text[j] === ' '; j -= 1) {
trimCount += 1;
}
if (trimCount === 0) break;
const nextText = text.slice(0, Math.max(0, text.length - trimCount));
if (nextText.length === 0) {
merged.splice(i, 1);
continue;
}
(run as TextRun).text = nextText;
if ((run as TextRun).pmEnd != null) {
(run as TextRun).pmEnd = (run as TextRun).pmEnd! - trimCount;
}
break;
}
}
return merged;
};
type UnderlineOverlaySpan = {
from: number;
to: number;
border: string;
};
const isTextRun = (run: Run): run is TextRun => (run.kind === 'text' || run.kind === undefined) && 'text' in run;
// The overlay can only measure and cover text and tab runs - their widths come from line segments
// or run.width. Atomic runs (field annotations, inline images, math) carry their width elsewhere
// (run.size), so a line containing one would mis-advance the overlay cursor and could suppress an
// atomic run's native underline without painting a replacement (SD-3330 review). Restrict the
// overlay to lines built only from text / tab / line-break runs, with an overlay-eligible tab.
const isOverlaySafeRunKind = (run: Run): boolean => {
const kind = run.kind ?? 'text';
return kind === 'text' || kind === 'tab' || kind === 'lineBreak' || kind === 'break';
};
const shouldUseLineUnderlineOverlay = (runsForLine: Run[]): boolean =>
runsForLine.every(isOverlaySafeRunKind) &&
runsForLine.some((run) => run.kind === 'tab' && canPaintUnderlineOverlay(run));
const cloneRunWithoutUnderline = <T extends Run>(run: T): T => ({ ...run, underline: undefined }) as T;
const appendUnderlineOverlaySpan = (
spans: UnderlineOverlaySpan[],
from: number,
to: number,
border: string | undefined,
): void => {
if (!border || to <= from) return;
const last = spans[spans.length - 1];
if (last && last.border === border && Math.abs(last.to - from) < 0.5) {
last.to = to;
return;
}
spans.push({ from, to, border });
};
const runInlinePaintWidth = (
run: Run,
runIndex: number,
segmentsByRun: Map<number, LineSegment[]>,
spacingPerSpace: number,
tabWidths?: Record<number, number>,
): number => {
if (run.kind === 'tab') {
return tabWidths?.[runIndex] ?? run.width ?? 48;
}
const segments = segmentsByRun.get(runIndex);
if (segments?.length) {
return segments.reduce((sum, segment) => {
const text = isTextRun(run) ? (run.text ?? '').slice(segment.fromChar, segment.toChar) : '';
return sum + segment.width + spacingPerSpace * countSpaces(text);
}, 0);
}
if ('width' in run && typeof run.width === 'number') {
return run.width;
}
return 0;
};
/**
* Resolve the measured advance for one sliced inline text run without reading
* DOM geometry. Production runs carry PM ranges, which let us intersect the
* rendered slice with the line's measured segments. The text/signature fallback
* keeps synthetic tests and legacy producers deterministic when PM ranges are
* absent.
*/
const measuredInlineTextAdvance = (
run: TextRun,
block: ParagraphBlock,
line: Line,
segmentsByRun: Map<number, LineSegment[]>,
spacingPerSpace: number,
): number | null => {
const runStart = run.pmStart;
const runEnd = run.pmEnd;
if (runStart != null && runEnd != null) {
let width = 0;
let matched = false;
for (const [runIndex, segments] of segmentsByRun) {
const sourceRun = block.runs[runIndex];
if (!sourceRun || !isTextRun(sourceRun) || sourceRun.pmStart == null) continue;
for (const segment of segments) {
const segmentStart = sourceRun.pmStart + segment.fromChar;
const segmentEnd = sourceRun.pmStart + segment.toChar;
const overlapStart = Math.max(runStart, segmentStart);
const overlapEnd = Math.min(runEnd, segmentEnd);
if (overlapEnd <= overlapStart) continue;
matched = true;
const segmentLength = Math.max(1, segmentEnd - segmentStart);
const overlapLength = overlapEnd - overlapStart;
const sourceFrom = segment.fromChar + (overlapStart - segmentStart);
const sourceTo = sourceFrom + overlapLength;
const overlapText = sourceRun.text.slice(sourceFrom, sourceTo);
width += (segment.width * overlapLength) / segmentLength + spacingPerSpace * countSpaces(overlapText);
}
}
if (matched) return width;
}
for (let runIndex = line.fromRun; runIndex <= line.toRun; runIndex += 1) {
const sourceRun = block.runs[runIndex];
if (!sourceRun || !isTextRun(sourceRun)) continue;
const fromChar = runIndex === line.fromRun ? line.fromChar : 0;
const toChar = runIndex === line.toRun ? line.toChar : sourceRun.text.length;
if (sourceRun.text.slice(fromChar, toChar) !== run.text) continue;
if (textRunMergeSignature(sourceRun) !== textRunMergeSignature(run)) continue;
return runInlinePaintWidth(sourceRun, runIndex, segmentsByRun, spacingPerSpace, line.tabWidths);
}
return null;
};
/**
* CSS transforms change glyph geometry but not inline advance. RTL lines must
* remain in browser bidi flow, so a measured-width outer box owns the advance
* while the existing run element paints the transformed glyphs inside it.
*/
const wrapScaledRtlRunWithMeasuredAdvance = (
element: HTMLElement,
measuredAdvance: number,
doc: Document,
): HTMLElement => {
const wrapper = doc.createElement('span');
wrapper.classList.add('superdoc-scaled-inline-advance');
wrapper.style.display = 'inline-block';
wrapper.style.width = `${Math.max(0, measuredAdvance)}px`;
wrapper.style.textAlign = 'right';
if (element.style.verticalAlign) {
wrapper.style.verticalAlign = element.style.verticalAlign;
element.style.verticalAlign = 'baseline';
}
element.style.transformOrigin = 'right center';
wrapper.appendChild(element);
return wrapper;
};
// Builds underline spans for the normal inline-flow branch. Spans are in line-relative px
// (the paragraph indent is folded into `from`/`to`) so a single coordinate space is shared
// with the segment-positioned branch and the draw step below.
const buildInlineUnderlineSpans = (
block: ParagraphBlock,
line: import('@superdoc/contracts').Line,
spacingPerSpace: number,
lineTextStartOffsetPx: number,
): UnderlineOverlaySpan[] => {
const segmentsByRun = new Map<number, LineSegment[]>();
line.segments?.forEach((segment) => {
const segments = segmentsByRun.get(segment.runIndex);
if (segments) {
segments.push(segment);
} else {
segmentsByRun.set(segment.runIndex, [segment]);
}
});
const spans: UnderlineOverlaySpan[] = [];
let currentX = lineTextStartOffsetPx;
for (let runIndex = line.fromRun; runIndex <= line.toRun; runIndex += 1) {
const run = block.runs[runIndex];
if (!run) continue;
const width = runInlinePaintWidth(run, runIndex, segmentsByRun, spacingPerSpace, line.tabWidths);
if (canPaintUnderlineOverlay(run)) {
appendUnderlineOverlaySpan(spans, currentX, currentX + width, underlineBorderForRun(run));
}
currentX += width;
}
return spans;
};
// Draws one absolutely-positioned underline element per span. Because the overlay owns the
// underline for both text and tabs in the covered range, text, preserved spaces, and tabs
// share one y, thickness, style and color - removing the text-decoration vs tab-border seam
// that two separate painters produced (SD-3330). `span.from`/`span.to` are line-relative px.
const renderUnderlineSpans = (spans: UnderlineOverlaySpan[], top: number, el: HTMLElement, doc: Document): void => {
spans.forEach((span) => {
const overlay = doc.createElement('div');
overlay.classList.add('superdoc-underline-overlay');
overlay.setAttribute('aria-hidden', 'true');
overlay.style.position = 'absolute';
overlay.style.left = `${span.from}px`;
overlay.style.top = `${top}px`;
overlay.style.width = `${Math.max(0, span.to - span.from)}px`;
overlay.style.height = '0px';
overlay.style.borderTop = span.border;
overlay.style.pointerEvents = 'none';
overlay.style.zIndex = '2';
el.appendChild(overlay);
});
};
export const renderLine = ({
block,
line,
context,
availableWidthOverride,
lineIndex,
skipJustify,
preExpandedRuns,
resolvedListTextStartPx,
indentOffsetOverride,
paragraphMarkLeftOffsetOverride,
runContext,
}: RenderLineParams): HTMLElement => {
// Apply the measured per-line inline-image alignment to the image runs before
// anything reads them, so both render branches and the expanded-run path see
// the same effective verticalAlign. When the line carries no image alignment,
// this is the original block (and the preExpandedRuns fast path is preserved).
const resolvedBlock = resolveLineInlineImageRuns(block as ParagraphBlock, line);
const hasResolvedImageAlignments = resolvedBlock !== block;
const expandedBlock = {
...resolvedBlock,
runs: hasResolvedImageAlignments
? expandRunsForInlineNewlines(resolvedBlock.runs)
: (preExpandedRuns ?? expandRunsForInlineNewlines(block.runs)),
};
const lineRange = computeLinePmRange(expandedBlock, line);
let runsForLine = sliceRunsForLine(expandedBlock, line);
runsForLine = splitInlineBoxRuns(expandedBlock as ParagraphBlock, line) ?? runsForLine;
const el = runContext.doc.createElement('div');
el.classList.add(CLASS_NAMES.line);
applyStyles(el, lineStyles(line.lineHeight));
el.dataset.layoutEpoch = String(runContext.layoutEpoch);
const paragraphAttrs = (block.attrs as ParagraphAttrs | undefined) ?? {};
const styleId = paragraphAttrs.styleId;
if (styleId) {
el.setAttribute('styleid', styleId);
}
const pAttrs = block.attrs as ParagraphAttrs | undefined;
const isRtl = applyRtlStyles(el, pAttrs);
if (lineRange.pmStart != null) {
el.dataset.pmStart = String(lineRange.pmStart);
}
if (lineRange.pmEnd != null) {
el.dataset.pmEnd = String(lineRange.pmEnd);
}
const trackedConfig = runContext.resolveTrackedChangesConfig(block);
// Preserve PM positions for DOM caret mapping on empty lines.
if (runsForLine.length === 0) {
const span = runContext.doc.createElement('span');
span.classList.add('superdoc-empty-run');
if (lineRange.pmStart != null) {
span.dataset.pmStart = String(lineRange.pmStart);
}
if (lineRange.pmEnd != null) {
span.dataset.pmEnd = String(lineRange.pmEnd);
}
const insertionRun = expandedBlock.runs[line.fromRun];
if (insertionRun && isTextRun(insertionRun) && insertionRun.text.length === 0) {
applyRunTypographyStyles(span, insertionRun, runContext.resolvePhysical);
} else {
span.style.fontSize = `${line.lineHeight}px`;
}
span.innerHTML = ' ';
el.appendChild(span);
}
// Render tab leaders (absolute positioned overlays)
if (line.leaders && line.leaders.length > 0) {
line.leaders.forEach((ld) => {
const leaderEl = runContext.doc.createElement('div');
leaderEl.classList.add('superdoc-leader');
leaderEl.setAttribute('data-style', ld.style);
leaderEl.style.position = 'absolute';
leaderEl.style.left = `${ld.from}px`;
leaderEl.style.width = `${Math.max(0, ld.to - ld.from)}px`;
// Align leaders closer to the text baseline using measured descent
const baselineOffset = Math.max(1, Math.round(Math.max(1, line.descent * 0.5)));
leaderEl.style.bottom = `${baselineOffset}px`;
leaderEl.style.height = ld.style === 'heavy' ? '2px' : '1px';
leaderEl.style.pointerEvents = 'none';
leaderEl.style.zIndex = '0'; // Same layer as line, text will be z-index: 1
// Map leader styles to CSS
if (ld.style === 'dot' || ld.style === 'middleDot') {
leaderEl.style.borderBottom = '1px dotted currentColor';
} else if (ld.style === 'hyphen') {
leaderEl.style.borderBottom = '1px dashed currentColor';
} else if (ld.style === 'underscore') {
leaderEl.style.borderBottom = '1px solid currentColor';
} else if (ld.style === 'heavy') {
leaderEl.style.borderBottom = '2px solid currentColor';
}
el.appendChild(leaderEl);
});
}
// Render bar tabs (vertical hairlines)
if (line.bars && line.bars.length > 0) {
line.bars.forEach((bar) => {
const barEl = runContext.doc.createElement('div');
barEl.classList.add('superdoc-tab-bar');
barEl.style.position = 'absolute';
barEl.style.left = `${bar.x}px`;
barEl.style.top = '0px';
barEl.style.bottom = '0px';
barEl.style.width = '1px';
barEl.style.background = 'currentColor';
barEl.style.opacity = '0.6';
barEl.style.pointerEvents = 'none';
el.appendChild(barEl);
});
}
// Check if any segments have explicit X positioning (from tab stops)
const hasExplicitPositioning = line.segments?.some((seg) => seg.x !== undefined);
const explicitPositionedSegmentCount = line.segments?.filter((seg) => seg.x !== undefined).length ?? 0;
const hasMultipleExplicitPositionedSegments = explicitPositionedSegmentCount > 1;
const availableWidth = availableWidthOverride ?? line.maxWidth ?? line.width;
const justifyShouldApply = shouldApplyJustify({
alignment: (block as ParagraphBlock).attrs?.alignment,
hasExplicitPositioning: hasExplicitPositioning ?? false,
hasExplicitTabStops: line.hasExplicitTabStops === true,
// Caller already folds last-line + trailing lineBreak behavior into skipJustify.
isLastLineOfParagraph: false,
paragraphEndsWithLineBreak: false,
skipJustifyOverride: skipJustify || hasMultipleExplicitPositionedSegments,
});
if (justifyShouldApply) {
// The measurer trims wrap-point trailing spaces from line ranges, but slicing can still
// produce whitespace-only runs at style boundaries. These runs are especially problematic
// for justify because `word-spacing` behavior is inconsistent on pure-whitespace spans.
//
// Normalize by merging whitespace-only slices into adjacent runs with identical styling.
runsForLine = normalizeJustifiedRuns(runsForLine);
}
const spaceCount =
line.spaceCount ??
runsForLine.reduce((sum, run) => {
if ((run.kind !== 'text' && run.kind !== undefined) || !('text' in run) || run.text == null) return sum;
return sum + countSpaces(run.text);
}, 0);
const lineWidth = line.naturalWidth ?? line.width;
const spacingPerSpace = calculateJustifySpacing({
lineWidth,
availableWidth,
spaceCount,
shouldJustify: justifyShouldApply,
});
const interCharacterSpacing = calculateInterCharacterJustifySpacing({
lineWidth,
availableWidth,
boundaryCount: spaceCount === 0 ? (line.justificationPlan?.boundaries.length ?? 0) : 0,
shouldJustify: justifyShouldApply,
});
runsForLine = applyInterCharacterSpacingToRuns(
runsForLine,
interCharacterSpacing,
line.justificationPlan?.boundaries ?? [],
);
// Only a top-aligned, line-expanding image triggers the Word text-bottom
// workaround. Glyph-like baseline images leave the surrounding text alone.
const lineContainsLineExpandingImage = runsForLine.some((run) => isLineExpandingImageRun(run, line));
// CSS inline baseline alignment has no font strut when a line contains only
// an image (the line container intentionally uses font-size: 0). Route that
// structural case through the deterministic measured-baseline painter used
// by explicitly positioned segments. Mixed text/image lines keep native
// inline composition.
const isBaselineImageOnlyLine =
runsForLine.length > 0 &&
runsForLine.every((run) => isImageRun(run) && (run as ImageRun).verticalAlign === 'baseline');
const useSegmentPositioning =
usesPositionedTextGeometry(line, runsForLine, isRtl) || (!isRtl && isBaselineImageOnlyLine);
// Enabled for both inline-flow and segment-positioned lines: a single measured underline
// overlay owns the mark across text + preserved spaces + tabs, so the two never disagree
// on the underline's y (SD-3330). The segment-positioned branch captures span geometry as
// it renders; the inline branch builds it from segment/tab widths.
// The inline-flow overlay builds left-origin offsets that only line up with the content when the
// content actually starts at the left. Several layouts shift it the overlay can't see:
// - RTL: positioned text geometry is disabled, so RTL falls to inline flow where the browser
// bidi-places the tabs - the LTR overlay would land on the wrong side.
// - center / right alignment: the browser shifts the in-flow content; the overlay does not.
// - hanging or negative indent: renderParagraphContent's CSS clamps negative indent and treats
// hanging continuation lines differently than the overlay's resolveLineIndentOffset, so the two
// origins diverge.
// In all of these, keep native underlines (don't suppress) rather than paint a misplaced overlay.
// The segment-positioned branch is exempt: it captures spans at the same absolute x it positions
// runs at, so it stays correct under any alignment/indent.
const overlayAlignment = (block.attrs as ParagraphAttrs | undefined)?.alignment;
const overlayIndent = (block.attrs as ParagraphAttrs | undefined)?.indent;
const inlineOverlayOriginMatchesContent =
overlayAlignment !== 'center' &&
overlayAlignment !== 'right' &&
(overlayIndent?.hanging ?? 0) === 0 &&
(overlayIndent?.left ?? 0) >= 0;
const useLineUnderlineOverlay =
Boolean(line.segments) &&
!isRtl &&
shouldUseLineUnderlineOverlay(runsForLine) &&
(useSegmentPositioning || inlineOverlayOriginMatchesContent);
const resolveLineIndentOffset = (): number => {
if (indentOffsetOverride != null) {
return indentOffsetOverride;
}
const paraIndent = (block.attrs as ParagraphAttrs | undefined)?.indent;
const indentLeft = paraIndent?.left ?? 0;
const firstLine = paraIndent?.firstLine ?? 0;
const hanging = paraIndent?.hanging ?? 0;
const isFirstLineOfPara = lineIndex === 0 || lineIndex === undefined;
const firstLineOffsetForCumX = isFirstLineOfPara ? firstLine - hanging : 0;
const wordLayoutValue = (block.attrs as ParagraphAttrs | undefined)?.wordLayout;
const wordLayout = isMinimalWordLayout(wordLayoutValue) ? wordLayoutValue : undefined;
const isListParagraph = Boolean(wordLayout?.marker);
const fallbackListTextStartPx =
typeof wordLayout?.marker?.textStartX === 'number' && Number.isFinite(wordLayout.marker.textStartX)
? wordLayout.marker.textStartX
: typeof wordLayout?.textStartPx === 'number' && Number.isFinite(wordLayout.textStartPx)
? wordLayout.textStartPx
: undefined;
const listIndentOffset = isFirstLineOfPara
? (resolvedListTextStartPx ?? fallbackListTextStartPx ?? indentLeft)
: indentLeft;
return isListParagraph ? listIndentOffset : indentLeft + firstLineOffsetForCumX;
};
const lineTextStartOffsetPx =
paragraphMarkLeftOffsetOverride != null ? paragraphMarkLeftOffsetOverride : resolveLineIndentOffset();
const paragraphMarkLeftOffsetPx = lineTextStartOffsetPx;
if (spacingPerSpace !== 0) {
// Each rendered line is its own block; relying on text-align-last is brittle, so we use word-spacing.
el.style.wordSpacing = `${spacingPerSpace}px`;
}
// Collects measured underline spans (line-relative px) from whichever branch renders, so a
// single draw step paints them. The segment-positioned branch fills it during rendering
// (using the same coordinates it positions runs at); the inline branch builds it afterwards.
const underlineSpans: UnderlineOverlaySpan[] = [];
if (useSegmentPositioning) {
renderExplicitlyPositionedRuns({
block: resolvedBlock,
line,
context,
el,
lineTextStartOffsetPx,
spacingPerSpace,
styleId,
runContext,
trackedConfig,
lineContainsLineExpandingImage,
useLineUnderlineOverlay,
underlineSpanCollector: useLineUnderlineOverlay ? underlineSpans : undefined,
});
paintInlineBoxes(line.inlineBoxes, el, isRtl);
} else {
renderInlineRuns({
block: expandedBlock as ParagraphBlock,
runsForLine,
line,
tabWidthByRun: buildTabWidthByRun(expandedBlock as ParagraphBlock, line),
context,
el,
styleId,
runContext,
trackedConfig,
lineContainsLineExpandingImage,
useLineUnderlineOverlay,
isRtl,
spacingPerSpace,
});
paintInlineBoxes(line.inlineBoxes, el, isRtl);
if (useLineUnderlineOverlay) {
underlineSpans.push(
...buildInlineUnderlineSpans(expandedBlock as ParagraphBlock, line, spacingPerSpace, lineTextStartOffsetPx),
);
}
}
if (useLineUnderlineOverlay && underlineSpans.length > 0) {
renderUnderlineSpans(underlineSpans, underlineOffsetFromLineTop(line), el, runContext.doc);
}
appendFormattingParagraphMark(
el,
line,
expandedBlock.runs,
paragraphMarkLeftOffsetPx,
availableWidth,
hasExplicitPositioning ?? false,
runContext.doc,
runContext.showFormattingMarks,
);
// Post-process: Apply tooltip accessibility for any links with pending tooltips
// This must happen after elements are in the DOM so aria-describedby can reference siblings
const anchors = el.querySelectorAll('a[href]');
anchors.forEach((anchor) => {
const pendingTooltip = runContext.pendingTooltips.get(anchor as HTMLElement);
if (pendingTooltip) {
applyTooltipAccessibility(anchor as HTMLAnchorElement, pendingTooltip, runContext);
runContext.pendingTooltips.delete(anchor as HTMLElement); // Clean up memory
}
});
return el;
};
type RunRenderBranchParams = {
line: import('@superdoc/contracts').Line;
context: import('../renderer.js').FragmentRenderContext;
el: HTMLElement;
styleId?: string;
runContext: RenderLineParams['runContext'];
trackedConfig: ReturnType<RenderLineParams['runContext']['resolveTrackedChangesConfig']>;
lineContainsLineExpandingImage: boolean;
};
const renderExplicitlyPositionedRuns = ({
block,
line,
context,
el,
lineTextStartOffsetPx,
spacingPerSpace,
styleId,
runContext,
trackedConfig,
lineContainsLineExpandingImage,
useLineUnderlineOverlay,
underlineSpanCollector,
}: RunRenderBranchParams & {
block: ParagraphBlock;
lineTextStartOffsetPx: number;
spacingPerSpace: number;
useLineUnderlineOverlay: boolean;
underlineSpanCollector?: UnderlineOverlaySpan[];
}): void => {
// Use segment-based rendering with absolute positioning for tab-aligned text.
// Positioned geometry is disabled for RTL because the layout engine computes
// tab positions in LTR order; RTL lines fall through to inline-flow rendering
// where dir="rtl" lets the browser handle tab positioning.
//
// The segment x positions from layout are relative to the content area (left margin = 0).
// We need to add the paragraph indent to ALL positions (both explicit and calculated).
// Segment x positions and paragraph marks both need the visual text start,
// including list marker/suffix space when the resolved layout provides it.
const indentOffset = lineTextStartOffsetPx;
let cumulativeX = 0; // Start at 0, we'll add indentOffset when positioning
const visibleRunStarts: number[] = [];
let visibleOffset = 0;
for (const run of block.runs) {
visibleRunStarts.push(visibleOffset);
visibleOffset += isTextRun(run) ? run.text.length : 1;
}
const lineVisibleStart = (visibleRunStarts[line.fromRun] ?? 0) + line.fromChar;
const segments = line.segments!;
const segmentsByRun = new Map<number, LineSegment[]>();
segments.forEach((segment) => {
const list = segmentsByRun.get(segment.runIndex);
if (list) {
list.push(segment);
} else {
segmentsByRun.set(segment.runIndex, [segment]);
}
});
/**
* Finds the next visible adjacent segment carrying tab geometry after a given run index.
* This handles tab-aligned text and compensated tab paint geometry.
*
* WHY ONLY VISIBLE ADJACENCY:
* When rendering a tab, we need to know where the content IMMEDIATELY after this tab begins
* to correctly size the tab element. We only skip vanished zero-width text segments because
* they are addressable source ranges but not visible tab-adjacent content. We don't look
* beyond other runs because:
* 1. Each tab is independent and should only consider its directly adjacent content
* 2. Looking further ahead would incorrectly span multiple tabs or unrelated runs
* 3. If there's another tab between this tab and some content, that intermediate tab is
* responsible for its own layout - we shouldn't reach across it
*
* For example, given: "Text[TAB1]Content[TAB2]MoreContent"
* - When sizing TAB1, we only check "Content" (immediate next run)
* - We don't check "MoreContent" because TAB2 is in between
* - TAB2 will independently check "MoreContent" when it's rendered
*
* @param fromRunIndex - The run index to search after
* @returns The immediate next tab-positioned segment, or undefined if not found or not immediate
*/
const findImmediateNextSegment = (fromRunIndex: number): LineSegment | undefined => {
for (let nextRunIdx = fromRunIndex + 1; nextRunIdx <= line.toRun; nextRunIdx += 1) {
const nextRun = block.runs[nextRunIdx];
if (!nextRun) return undefined;
const nextSegments = segmentsByRun.get(nextRunIdx);
if (nextSegments && nextSegments.length > 0) {
const firstSegment = nextSegments[0];
// Return only the first segment; later segments in the same run are
// not immediately adjacent to this tab.
if (hasTabBoundaryGeometry(firstSegment)) return firstSegment;
if (nextSegments.every((segment) => isTransparentVanishedSegment(nextRun, segment))) continue;
return undefined;
}
if (isVanishedTextRun(nextRun)) continue;
return undefined;
}
return undefined;
};
// Inline SDT wrapping for geometry path (absolute-positioned elements).
// Same concept as the run-based path's SDT wrapper, but here elements use
// position:absolute so the wrapper itself must be absolutely positioned to
// span from the leftmost to rightmost child element.
let geoSdtWrapper: HTMLElement | null = null;
let geoSdtId: string | null = null;
let geoSdtWrapperLeft = 0;
let geoSdtMaxRight = 0;
const closeGeoSdtWrapper = () => {
if (geoSdtWrapper) {
geoSdtWrapper.style.width = `${geoSdtMaxRight - geoSdtWrapperLeft}px`;
el.appendChild(geoSdtWrapper);
geoSdtWrapper = null;
geoSdtId = null;
}
};
/**
* Append an element to the line, routing through an inline SDT wrapper
* when the run has inline structuredContent metadata.
*/
const appendToLineGeo = (elem: HTMLElement, runForSdt: Run, elemLeftPx: number, elemWidthPx: number) => {
const resolved = runContext.resolveRunSdtId(runForSdt);
const thisRunSdtId = resolved?.sdtId ?? null;
if (thisRunSdtId !== geoSdtId) {
closeGeoSdtWrapper();
}
if (resolved) {
if (!geoSdtWrapper) {
geoSdtWrapper = runContext.createInlineSdtWrapper(resolved.sdt);
if (isEmptyInlineSdtPlaceholderRun(runForSdt)) {
geoSdtWrapper.dataset.empty = 'true';
}
geoSdtId = thisRunSdtId;
geoSdtWrapperLeft = elemLeftPx;
geoSdtMaxRight = elemLeftPx;
geoSdtWrapper.style.position = 'absolute';
geoSdtWrapper.style.left = `${elemLeftPx}px`;
geoSdtWrapper.style.top = '0px';
geoSdtWrapper.style.height = `${line.lineHeight}px`;
geoSdtWrapper.style.padding = '0px';
geoSdtWrapper.style.borderWidth = '0px';
geoSdtWrapper.style.lineHeight = `${line.lineHeight}px`;
}
if (isImageRun(runForSdt)) {
geoSdtWrapper.dataset.containsInlineImage = 'true';
}
runContext.syncInlineSdtWrapperTypography(geoSdtWrapper, runForSdt);
geoSdtWrapper.style.lineHeight = `${line.lineHeight}px`;
elem.style.left = `${elemLeftPx - geoSdtWrapperLeft}px`;
elem.style.top = '0px';
geoSdtMaxRight = Math.max(geoSdtMaxRight, elemLeftPx + elemWidthPx);
runContext.expandSdtWrapperPmRange(geoSdtWrapper, (runForSdt as TextRun).pmStart, (runForSdt as TextRun).pmEnd);
geoSdtWrapper.appendChild(elem);
} else {
el.appendChild(elem);
}
};
for (let runIndex = line.fromRun; runIndex <= line.toRun; runIndex += 1) {
const baseRun = block.runs[runIndex];
if (!baseRun) continue;
if (baseRun.kind === 'tab') {
// Find where the immediate next content begins (if it's right after this tab)
const immediateNextSegment = findImmediateNextSegment(runIndex);
const tabStartX = cumulativeX;
// When the line-level underline overlay owns this tab's underline, render the tab box
// without its own border and let the overlay draw the mark; capture the tab's measured
// span so the overlay covers exactly the geometry the tab occupies.
const coveredByOverlay = useLineUnderlineOverlay && canPaintUnderlineOverlay(baseRun);
const {
element: tabEl,
tabEndX,
actualTabWidth,
} = renderPositionedTabRun(
baseRun,
line,
runContext.doc,
runContext.layoutEpoch,
tabStartX,
indentOffset,
immediateNextSegment,
styleId,
!coveredByOverlay,
);
appendToLineGeo(tabEl, baseRun, tabStartX + indentOffset, actualTabWidth);
if (coveredByOverlay && underlineSpanCollector) {
appendUnderlineOverlaySpan(
underlineSpanCollector,
tabStartX + indentOffset,
tabStartX + indentOffset + actualTabWidth,
underlineBorderForRun(baseRun),
);
}
// Update cumulativeX to where the next content begins
// This ensures proper positioning for subsequent elements
cumulativeX = tabEndX;
continue;
}
// Handle ImageRun - render as-is (no slicing needed, atomic unit)