pa_font 0.2.8 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/USAGE.md CHANGED
@@ -42,15 +42,19 @@ const points = shape.toPoints({ step: 8 });
42
42
  ### 2. 문단을 canvas에 그리고, 필요하면 geometry로 바꾸기
43
43
 
44
44
  ```js
45
- const paragraph = font.paragraph("캔버스에서 자동 줄바꿈되는 문단입니다.", {
46
- x: 40,
47
- y: 80,
48
- size: 32,
49
- lineHeight: 1.4,
50
- align: "left",
51
- wrap: "word",
52
- padding: { x: 24, y: 20 },
53
- });
45
+ const paragraph = font.paragraph(
46
+ "첫 번째 문단입니다. 자동 줄바꿈됩니다.\n\n두 번째 문단입니다.",
47
+ {
48
+ x: 40,
49
+ y: 80,
50
+ size: 32,
51
+ lineHeight: 1.4,
52
+ gap: 0.75,
53
+ align: "left",
54
+ wrap: "word",
55
+ padding: { x: 24, y: 20 },
56
+ }
57
+ );
54
58
 
55
59
  paragraph.drawText(ctx, {
56
60
  fillStyle: "#111",
@@ -107,7 +111,7 @@ console.log(metrics.width, metrics.bbox);
107
111
  - `margin`, `padding`: `24`, `"24 32"`, `[24, 32]`, `{ x: 32, y: 24 }`, `{ top, right, bottom, left }`
108
112
  - `width`: 생략하면 `drawText(ctx)` 시 현재 canvas 폭 기준으로 자동 계산
109
113
  - `height`: `overflow: "hidden"`과 같이 쓰면 clip/clamp
110
- - `x`, `y`, `size`, `lineHeight`, `align`
114
+ - `x`, `y`, `size`, `lineHeight`, `gap`, `align`
111
115
 
112
116
  세부 제어 옵션:
113
117
 
@@ -115,6 +119,7 @@ console.log(metrics.width, metrics.bbox);
115
119
  - `overflowWrap`: `normal | break-word | anywhere`
116
120
  - `wordBreak`: `normal | break-all | keep-all`
117
121
  - `overflow`: `visible | hidden`
122
+ - `overflowY`: `visible | hidden | scroll`
118
123
  - `textOverflow`: `clip | ellipsis`
119
124
  - `fontFamily`, `fontWeight`, `fontStyle`, `font`
120
125
  - `engine`: `pretext | native`
@@ -128,6 +133,13 @@ console.log(metrics.width, metrics.bbox);
128
133
 
129
134
  `wordBreak` / `overflowWrap`를 직접 주면 `wrap`보다 우선합니다.
130
135
 
136
+ 빈 줄 하나 이상은 새 문단으로 처리됩니다.
137
+
138
+ - `gap`: 문단 사이 간격. `lineHeight` 배수이며 기본값은 `0.5`
139
+ - `whiteSpace: "normal"`이면 문단 안 단일 줄바꿈은 공백으로 정리
140
+ - `whiteSpace: "pre-wrap"`이면 문단 안 단일 줄바꿈 유지
141
+ - `overflowY: "scroll"`을 쓰려면 `height`가 필요
142
+
131
143
  ## paParagraph API
132
144
 
133
145
  `font.paragraph()`는 `paParagraph`를 반환합니다.
@@ -172,6 +184,28 @@ paragraph.drawText(ctx, {
172
184
  - `strokeStyle`
173
185
  - `fill`
174
186
  - `stroke`
187
+ - `scrollTop`
188
+
189
+ 스크롤 가능한 viewport를 만들고 싶다면:
190
+
191
+ ```js
192
+ const paragraph = font.paragraph(text, {
193
+ width: 320,
194
+ height: 220,
195
+ overflowY: "scroll",
196
+ lineHeight: 1.5,
197
+ gap: 0.5,
198
+ });
199
+
200
+ paragraph.drawText(ctx, {
201
+ fillStyle: "#111",
202
+ scrollTop: 48,
203
+ });
204
+ ```
205
+
206
+ 전체 콘텐츠 높이는 `paragraph.metrics.height`,
207
+ 현재 viewport 높이는 `paragraph.metrics.viewportHeight`,
208
+ 최대 스크롤 값은 `paragraph.metrics.maxScrollTop`에서 확인할 수 있습니다.
175
209
 
176
210
  ### `paragraph.toShape(options?)`
177
211
 
@@ -281,6 +315,7 @@ const paragraph = font.paragraph(text, {
281
315
  y: 0,
282
316
  size: 20,
283
317
  lineHeight: 1.6,
318
+ gap: 0.5,
284
319
  padding: { x: 32, y: 24 },
285
320
  margin: "20 0 0 20",
286
321
  wrap: "char",
package/dist/paFont.cjs CHANGED
@@ -3299,6 +3299,7 @@ function layoutWithLines(prepared, maxWidth, lineHeight) {
3299
3299
  //#endregion
3300
3300
  //#region src/paFont/paragraphLayout.js
3301
3301
  var DEFAULT_LINE_HEIGHT_RATIO = 1.2;
3302
+ var DEFAULT_PARAGRAPH_GAP = .5;
3302
3303
  var HUGE_LAYOUT_WIDTH = 1e9;
3303
3304
  var JUSTIFY_EPSILON = 1e-6;
3304
3305
  var QUOTE_RE = /"/g;
@@ -3312,13 +3313,14 @@ function layoutParagraph(fontInstance, text, options = {}, state = {}) {
3312
3313
  const textValue = String(text ?? "");
3313
3314
  const layoutBox = resolveLayoutBox(normalized, state);
3314
3315
  const retainedPreparedState = resolveRetainedPreparedState(state, normalized);
3315
- const pretextState = shouldAttemptPretextLayout(normalized) ? layoutWithPretext(textValue, normalized, retainedPreparedState, layoutBox) : null;
3316
- const layoutState = pretextState != null && canUsePretextLayout(pretextState, normalized) ? pretextState : layoutWithNative(fontInstance, textValue, normalized, layoutBox);
3316
+ const paragraphs = splitParagraphText(textValue, normalized.whiteSpace);
3317
+ const pretextState = shouldAttemptPretextLayout(normalized) ? layoutParagraphsWithPretext(paragraphs, normalized, retainedPreparedState, layoutBox) : null;
3318
+ const layoutState = pretextState != null && canUsePretextLayout(pretextState, normalized) ? pretextState : layoutParagraphsWithNative(fontInstance, paragraphs, normalized, layoutBox);
3317
3319
  const measureWidth = createLazyTextMeasurer(fontInstance, normalized);
3318
3320
  const lines = positionLines(fontInstance, applyOverflowClamping(layoutState.lines, normalized, layoutBox, measureWidth), normalized, layoutBox, measureWidth);
3319
3321
  const textBBox = combineRects(lines.map((line) => line.bbox)) ?? emptyRect();
3320
3322
  const textWidth = lines.reduce((max, line) => Math.max(max, line.width), 0);
3321
- const textHeight = lines.length * normalized.lineHeight;
3323
+ const textHeight = resolvePositionedTextHeight(lines, layoutBox.contentY);
3322
3324
  const finalLayoutBox = finalizeLayoutBox(layoutBox, normalized, textHeight);
3323
3325
  const cachedPrepared = pretextState?.prepared ?? retainedPreparedState.prepared ?? null;
3324
3326
  const cachedPreparedWhiteSpace = pretextState?.preparedWhiteSpace ?? retainedPreparedState.preparedWhiteSpace ?? null;
@@ -3330,6 +3332,8 @@ function layoutParagraph(fontInstance, text, options = {}, state = {}) {
3330
3332
  y: finalLayoutBox.contentBox.y,
3331
3333
  width: textWidth,
3332
3334
  height: textHeight,
3335
+ viewportHeight: finalLayoutBox.contentBox.h,
3336
+ maxScrollTop: Math.max(0, textHeight - finalLayoutBox.contentBox.h),
3333
3337
  lineCount: lines.length,
3334
3338
  bbox: textBBox,
3335
3339
  contentBox: { ...finalLayoutBox.contentBox },
@@ -3355,8 +3359,13 @@ function normalizeParagraphOptions(fontInstance, options = {}) {
3355
3359
  ], null));
3356
3360
  const width = normalizeDimension(options.width);
3357
3361
  const height = normalizeDimension(options.height);
3362
+ const gap = normalizeGap(options.gap);
3363
+ const overflow = normalizeEnum(options.overflow, ["visible", "hidden"], "visible");
3364
+ const overflowY = resolveOverflowY(options.overflowY, overflow);
3358
3365
  if (options.width != null && width == null) throw new TypeError("font.paragraph() option \"width\" must be a positive number.");
3359
3366
  if (options.height != null && height == null) throw new TypeError("font.paragraph() option \"height\" must be a positive number.");
3367
+ if (options.gap != null && gap == null) throw new TypeError("font.paragraph() option \"gap\" must be a non-negative number.");
3368
+ if (overflowY === "scroll" && height == null) throw new TypeError("font.paragraph() option \"height\" is required when \"overflowY\" is \"scroll\".");
3360
3369
  const font = resolveCanvasFont(fontInstance, textOptions.size, options);
3361
3370
  return {
3362
3371
  ...textOptions,
@@ -3371,6 +3380,7 @@ function normalizeParagraphOptions(fontInstance, options = {}) {
3371
3380
  ], wrapDefaults.overflowWrap)),
3372
3381
  width,
3373
3382
  height,
3383
+ gap: gap ?? DEFAULT_PARAGRAPH_GAP,
3374
3384
  lineHeight: resolveLineHeight(options.lineHeight, textOptions.size),
3375
3385
  align: normalizeEnum(options.align, [
3376
3386
  "left",
@@ -3393,7 +3403,8 @@ function normalizeParagraphOptions(fontInstance, options = {}) {
3393
3403
  "break-all",
3394
3404
  "keep-all"
3395
3405
  ], wrapDefaults.wordBreak),
3396
- overflow: normalizeEnum(options.overflow, ["visible", "hidden"], "visible"),
3406
+ overflow,
3407
+ overflowY,
3397
3408
  textOverflow: normalizeNullableEnum(options.textOverflow, ["clip", "ellipsis"], null),
3398
3409
  maxLines: normalizeMaxLines(options.maxLines),
3399
3410
  ellipsis: normalizeEllipsis(options.ellipsis),
@@ -3421,6 +3432,44 @@ function resolveCanvasFont(fontInstance, size, options = {}) {
3421
3432
  function canReusePreparedParagraphState(previousState, previousOptions, nextOptions) {
3422
3433
  return previousState?.prepared != null && previousState.preparedWhiteSpace === resolvePretextWhiteSpace(nextOptions.whiteSpace) && previousOptions?.font === nextOptions.font;
3423
3434
  }
3435
+ function layoutParagraphsWithPretext(paragraphs, options, state, layoutBox) {
3436
+ const retainedPrepared = Array.isArray(state.prepared) ? state.prepared : state.prepared != null ? [state.prepared] : [];
3437
+ const paragraphStates = paragraphs.map((paragraph, paragraphIndex) => layoutWithPretext(paragraph, options, {
3438
+ prepared: retainedPrepared[paragraphIndex] ?? null,
3439
+ preparedWhiteSpace: state.preparedWhiteSpace,
3440
+ font: options.font
3441
+ }, layoutBox));
3442
+ return {
3443
+ lines: annotateParagraphLines(paragraphStates),
3444
+ prepared: paragraphStates.map((paragraphState) => paragraphState.prepared ?? null),
3445
+ preparedWhiteSpace: paragraphStates[0]?.preparedWhiteSpace ?? resolvePretextWhiteSpace(options.whiteSpace),
3446
+ layoutEngine: "pretext",
3447
+ usedOverflowWrapFallbackBreaks: paragraphStates.some((paragraphState) => paragraphState.usedOverflowWrapFallbackBreaks)
3448
+ };
3449
+ }
3450
+ function layoutParagraphsWithNative(fontInstance, paragraphs, options, layoutBox) {
3451
+ return {
3452
+ lines: annotateParagraphLines(paragraphs.map((paragraph) => layoutWithNative(fontInstance, paragraph, options, layoutBox))),
3453
+ prepared: null,
3454
+ preparedWhiteSpace: null,
3455
+ layoutEngine: "native"
3456
+ };
3457
+ }
3458
+ function annotateParagraphLines(paragraphStates) {
3459
+ const lines = [];
3460
+ paragraphStates.forEach((paragraphState, paragraphIndex) => {
3461
+ paragraphState.lines.forEach((line, lineIndex) => {
3462
+ lines.push({
3463
+ ...line,
3464
+ start: null,
3465
+ end: null,
3466
+ paragraphIndex,
3467
+ paragraphEnd: lineIndex === paragraphState.lines.length - 1
3468
+ });
3469
+ });
3470
+ });
3471
+ return lines;
3472
+ }
3424
3473
  function layoutWithPretext(text, options, state, layoutBox) {
3425
3474
  const preparedWhiteSpace = resolvePretextWhiteSpace(options.whiteSpace);
3426
3475
  const prepared = state.prepared != null && state.preparedWhiteSpace === preparedWhiteSpace && state.font === options.font ? state.prepared : prepareWithSegments(text, options.font, { whiteSpace: preparedWhiteSpace });
@@ -3544,8 +3593,8 @@ function applyOverflowClamping(lines, options, layoutBox, measureWidth) {
3544
3593
  const contentWidth = layoutBox.contentWidth;
3545
3594
  const result = lines.map((line) => ({ ...line }));
3546
3595
  let lineLimit = options.maxLines;
3547
- if (options.overflow === "hidden" && layoutBox.clipContentHeight != null) {
3548
- const visibleLineCount = Math.max(0, Math.floor((layoutBox.clipContentHeight + JUSTIFY_EPSILON) / options.lineHeight));
3596
+ if (shouldClampLinesToHeight(options) && layoutBox.clipContentHeight != null) {
3597
+ const visibleLineCount = countVisibleLinesForHeight(result, options, layoutBox.clipContentHeight);
3549
3598
  lineLimit = lineLimit == null ? visibleLineCount : Math.min(lineLimit, visibleLineCount);
3550
3599
  }
3551
3600
  let clippedByCount = false;
@@ -3557,14 +3606,30 @@ function applyOverflowClamping(lines, options, layoutBox, measureWidth) {
3557
3606
  }
3558
3607
  }
3559
3608
  if (result.length === 0) return result;
3560
- if (options.overflow === "hidden" && options.whiteSpace === "nowrap" && result[0].width > contentWidth + JUSTIFY_EPSILON) result[0] = truncateLineToWidth(result[0], contentWidth, measureWidth, options.textOverflow === "ellipsis" ? options.ellipsis : false);
3561
- else if (clippedByCount && shouldEllipsizeClampedLines(options)) {
3609
+ if (options.overflow === "hidden" && options.whiteSpace === "nowrap") {
3610
+ for (let index = 0; index < result.length; index += 1) if (result[index].width > contentWidth + JUSTIFY_EPSILON) result[index] = truncateLineToWidth(result[index], contentWidth, measureWidth, options.textOverflow === "ellipsis" ? options.ellipsis : false);
3611
+ }
3612
+ if (clippedByCount && shouldEllipsizeClampedLines(options)) {
3562
3613
  const lastIndex = result.length - 1;
3563
3614
  result[lastIndex] = truncateLineToWidth(result[lastIndex], contentWidth, measureWidth, options.ellipsis);
3564
3615
  result[lastIndex].hardBreak = false;
3616
+ result[lastIndex].paragraphEnd = true;
3565
3617
  }
3566
3618
  return result;
3567
3619
  }
3620
+ function countVisibleLinesForHeight(lines, options, maxHeight) {
3621
+ let visibleLineCount = 0;
3622
+ let consumedHeight = 0;
3623
+ const lineBoxHeight = options.lineHeight;
3624
+ const paragraphGap = lineBoxHeight * options.gap;
3625
+ for (let index = 0; index < lines.length; index += 1) {
3626
+ if (consumedHeight + lineBoxHeight > maxHeight + JUSTIFY_EPSILON) break;
3627
+ consumedHeight += lineBoxHeight;
3628
+ visibleLineCount += 1;
3629
+ if (lines[index].paragraphEnd && index < lines.length - 1) consumedHeight += paragraphGap;
3630
+ }
3631
+ return visibleLineCount;
3632
+ }
3568
3633
  function truncateLineToWidth(line, maxWidth, measureWidth, suffix) {
3569
3634
  const suffixText = suffix === false ? "" : suffix;
3570
3635
  if ((suffixText.length > 0 ? measureWidth(suffixText) : 0) > maxWidth + JUSTIFY_EPSILON) return {
@@ -3589,13 +3654,17 @@ function shouldEllipsizeClampedLines(options) {
3589
3654
  function positionLines(fontInstance, lines, options, layoutBox, measureWidth) {
3590
3655
  const ascent = getFontAscender(fontInstance, options.size);
3591
3656
  const lineBoxHeight = options.lineHeight;
3657
+ const paragraphGap = lineBoxHeight * options.gap;
3592
3658
  let cursor = 0;
3659
+ let cursorY = layoutBox.contentY;
3593
3660
  return lines.map((line, index) => {
3594
3661
  const justified = shouldJustifyLine(line, index, lines.length, options);
3595
3662
  const offsetX = justified ? 0 : resolveAlignOffset(options.align, line.width, layoutBox.contentWidth);
3596
3663
  const x = layoutBox.contentX + offsetX;
3597
- const y = layoutBox.contentY + index * lineBoxHeight;
3664
+ const y = cursorY;
3598
3665
  const baseline = y + ascent;
3666
+ const start = line.start ?? cursor;
3667
+ const end = line.end ?? start + line.text.length;
3599
3668
  const fragments = justified ? buildJustifiedFragments(line, layoutBox.contentX, layoutBox.contentWidth, measureWidth) : [{
3600
3669
  text: line.text,
3601
3670
  x,
@@ -3612,8 +3681,8 @@ function positionLines(fontInstance, lines, options, layoutBox, measureWidth) {
3612
3681
  const positioned = {
3613
3682
  index,
3614
3683
  text: line.text,
3615
- start: line.start ?? cursor,
3616
- end: line.end ?? cursor + line.text.length,
3684
+ start,
3685
+ end,
3617
3686
  x,
3618
3687
  y,
3619
3688
  width,
@@ -3623,10 +3692,21 @@ function positionLines(fontInstance, lines, options, layoutBox, measureWidth) {
3623
3692
  hardBreak: line.hardBreak,
3624
3693
  fragments
3625
3694
  };
3626
- cursor = positioned.end + (line.hardBreak ? 1 : 0);
3695
+ cursor = end;
3696
+ if (line.hardBreak) cursor += 1;
3697
+ if (line.paragraphEnd && index < lines.length - 1) {
3698
+ cursor += 2;
3699
+ cursorY += paragraphGap;
3700
+ }
3701
+ cursorY += lineBoxHeight;
3627
3702
  return positioned;
3628
3703
  });
3629
3704
  }
3705
+ function resolvePositionedTextHeight(lines, contentY) {
3706
+ if (lines.length === 0) return 0;
3707
+ const lastLine = lines[lines.length - 1];
3708
+ return lastLine.y + lastLine.height - contentY;
3709
+ }
3630
3710
  function buildJustifiedFragments(line, contentX, contentWidth, measureWidth) {
3631
3711
  const tokens = splitPreservingWhitespace(line.text);
3632
3712
  const expandable = tokens.reduce((count, token, index) => {
@@ -3657,7 +3737,7 @@ function buildJustifiedFragments(line, contentX, contentWidth, measureWidth) {
3657
3737
  return fragments;
3658
3738
  }
3659
3739
  function shouldJustifyLine(line, index, lineCount, options) {
3660
- return options.align === "justify" && index < lineCount - 1 && !line.hardBreak && /\S\s+\S/u.test(line.text);
3740
+ return options.align === "justify" && index < lineCount - 1 && !line.hardBreak && !line.paragraphEnd && /\S\s+\S/u.test(line.text);
3661
3741
  }
3662
3742
  function resolveAlignOffset(align, lineWidth, maxWidth) {
3663
3743
  if (align === "center") return (maxWidth - lineWidth) * .5;
@@ -3738,7 +3818,7 @@ function finalizeLayoutBox(layoutBox, options, textHeight) {
3738
3818
  x: layoutBox.contentX,
3739
3819
  y: layoutBox.contentY,
3740
3820
  w: layoutBox.contentWidth,
3741
- h: options.overflow === "hidden" ? contentHeight : textHeight
3821
+ h: shouldClipToContentHeight(options) ? contentHeight : textHeight
3742
3822
  }
3743
3823
  };
3744
3824
  }
@@ -3754,6 +3834,19 @@ function normalizeNullableEnum(value, supported, fallback) {
3754
3834
  if (value == null) return fallback;
3755
3835
  return typeof value === "string" && supported.includes(value) ? value : fallback;
3756
3836
  }
3837
+ function resolveOverflowY(value, overflow) {
3838
+ return normalizeEnum(value, [
3839
+ "visible",
3840
+ "hidden",
3841
+ "scroll"
3842
+ ], overflow === "hidden" ? "hidden" : "visible");
3843
+ }
3844
+ function shouldClampLinesToHeight(options) {
3845
+ return options.overflowY === "hidden";
3846
+ }
3847
+ function shouldClipToContentHeight(options) {
3848
+ return options.overflow === "hidden" || options.overflowY === "hidden" || options.overflowY === "scroll";
3849
+ }
3757
3850
  function normalizeEllipsis(value) {
3758
3851
  if (value === false) return false;
3759
3852
  if (typeof value === "string") return value;
@@ -3767,6 +3860,9 @@ function normalizeMaxLines(value) {
3767
3860
  function normalizeDimension(value) {
3768
3861
  return Number.isFinite(value) && value > 0 ? value : null;
3769
3862
  }
3863
+ function normalizeGap(value) {
3864
+ return Number.isFinite(value) && value >= 0 ? Number(value) : null;
3865
+ }
3770
3866
  function normalizeSpacing(value) {
3771
3867
  if (value == null) return zeroSpacing();
3772
3868
  if (Number.isFinite(value)) {
@@ -3881,6 +3977,27 @@ function resolveRetainedPreparedState(state, options) {
3881
3977
  function isHardBreak(prepared, line) {
3882
3978
  return prepared.kinds?.[line.end.segmentIndex] === "hard-break";
3883
3979
  }
3980
+ function splitParagraphText(text, whiteSpace) {
3981
+ const normalized = String(text ?? "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
3982
+ const paragraphs = [];
3983
+ const lines = normalized.split("\n");
3984
+ let currentLines = [];
3985
+ const pushCurrentParagraph = () => {
3986
+ if (currentLines.length === 0) return;
3987
+ const paragraph = whiteSpace === "pre-wrap" ? currentLines.join("\n") : currentLines.join("\n").replace(/\s+/gu, " ").trim();
3988
+ currentLines = [];
3989
+ if (paragraph.length > 0) paragraphs.push(paragraph);
3990
+ };
3991
+ lines.forEach((line) => {
3992
+ if (/^[\t ]*$/u.test(line)) {
3993
+ pushCurrentParagraph();
3994
+ return;
3995
+ }
3996
+ currentLines.push(line);
3997
+ });
3998
+ pushCurrentParagraph();
3999
+ return paragraphs;
4000
+ }
3884
4001
  function normalizeNativeText(text, whiteSpace) {
3885
4002
  if (whiteSpace === "pre-wrap") return String(text ?? "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
3886
4003
  return String(text ?? "").replace(/\s+/gu, " ").trim();
@@ -4166,6 +4283,7 @@ var paParagraph = class paParagraph {
4166
4283
  if (!ctx || typeof ctx.fillText !== "function") throw new TypeError("drawText() expects a CanvasRenderingContext2D.");
4167
4284
  const fill = options.fill !== false;
4168
4285
  const stroke = options.stroke === true;
4286
+ const scrollTop = clampScrollTop(options.scrollTop, this.metrics.maxScrollTop);
4169
4287
  if (!fill && !stroke) return;
4170
4288
  this._syncLayoutWithContext(ctx);
4171
4289
  ctx.save();
@@ -4174,12 +4292,13 @@ var paParagraph = class paParagraph {
4174
4292
  ctx.textBaseline = "alphabetic";
4175
4293
  if (options.fillStyle != null) ctx.fillStyle = options.fillStyle;
4176
4294
  if (options.strokeStyle != null) ctx.strokeStyle = options.strokeStyle;
4177
- if (this.options.overflow === "hidden" && this._state.layoutBox?.clipBox) {
4295
+ if (shouldClipParagraph(this.options) && this._state.layoutBox?.clipBox) {
4178
4296
  const clipBox = this._state.layoutBox.clipBox;
4179
4297
  ctx.beginPath();
4180
4298
  ctx.rect(clipBox.x, clipBox.y, clipBox.w, clipBox.h);
4181
4299
  ctx.clip();
4182
4300
  }
4301
+ if (scrollTop > 0) ctx.translate(0, -scrollTop);
4183
4302
  this._state.lines.forEach((line) => {
4184
4303
  line.fragments.forEach((fragment) => {
4185
4304
  if (fill) ctx.fillText(fragment.text, fragment.x, line.baseline);
@@ -4230,6 +4349,8 @@ var paParagraph = class paParagraph {
4230
4349
  this.metrics = {
4231
4350
  ...state.metrics,
4232
4351
  bbox: { ...state.metrics.bbox },
4352
+ viewportHeight: state.metrics.viewportHeight,
4353
+ maxScrollTop: state.metrics.maxScrollTop,
4233
4354
  contentBox: state.metrics.contentBox ? { ...state.metrics.contentBox } : void 0,
4234
4355
  paddingBox: state.metrics.paddingBox ? { ...state.metrics.paddingBox } : void 0,
4235
4356
  marginBox: state.metrics.marginBox ? { ...state.metrics.marginBox } : void 0,
@@ -4324,6 +4445,13 @@ function normalizeParagraphPointOptions(options = {}) {
4324
4445
  layout: options.layout ?? "current"
4325
4446
  };
4326
4447
  }
4448
+ function shouldClipParagraph(options) {
4449
+ return options.overflow === "hidden" || options.overflowY === "hidden" || options.overflowY === "scroll";
4450
+ }
4451
+ function clampScrollTop(value, maxScrollTop) {
4452
+ if (!Number.isFinite(value) || value <= 0) return 0;
4453
+ return Math.min(Number(value), maxScrollTop ?? 0);
4454
+ }
4327
4455
  //#endregion
4328
4456
  //#region src/paFont/paFont.js
4329
4457
  var browserFontRegistrationId = 0;