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/dist/paFont.js CHANGED
@@ -3295,6 +3295,7 @@ function layoutWithLines(prepared, maxWidth, lineHeight) {
3295
3295
  //#endregion
3296
3296
  //#region src/paFont/paragraphLayout.js
3297
3297
  var DEFAULT_LINE_HEIGHT_RATIO = 1.2;
3298
+ var DEFAULT_PARAGRAPH_GAP = .5;
3298
3299
  var HUGE_LAYOUT_WIDTH = 1e9;
3299
3300
  var JUSTIFY_EPSILON = 1e-6;
3300
3301
  var QUOTE_RE = /"/g;
@@ -3308,13 +3309,14 @@ function layoutParagraph(fontInstance, text, options = {}, state = {}) {
3308
3309
  const textValue = String(text ?? "");
3309
3310
  const layoutBox = resolveLayoutBox(normalized, state);
3310
3311
  const retainedPreparedState = resolveRetainedPreparedState(state, normalized);
3311
- const pretextState = shouldAttemptPretextLayout(normalized) ? layoutWithPretext(textValue, normalized, retainedPreparedState, layoutBox) : null;
3312
- const layoutState = pretextState != null && canUsePretextLayout(pretextState, normalized) ? pretextState : layoutWithNative(fontInstance, textValue, normalized, layoutBox);
3312
+ const paragraphs = splitParagraphText(textValue, normalized.whiteSpace);
3313
+ const pretextState = shouldAttemptPretextLayout(normalized) ? layoutParagraphsWithPretext(paragraphs, normalized, retainedPreparedState, layoutBox) : null;
3314
+ const layoutState = pretextState != null && canUsePretextLayout(pretextState, normalized) ? pretextState : layoutParagraphsWithNative(fontInstance, paragraphs, normalized, layoutBox);
3313
3315
  const measureWidth = createLazyTextMeasurer(fontInstance, normalized);
3314
3316
  const lines = positionLines(fontInstance, applyOverflowClamping(layoutState.lines, normalized, layoutBox, measureWidth), normalized, layoutBox, measureWidth);
3315
3317
  const textBBox = combineRects(lines.map((line) => line.bbox)) ?? emptyRect();
3316
3318
  const textWidth = lines.reduce((max, line) => Math.max(max, line.width), 0);
3317
- const textHeight = lines.length * normalized.lineHeight;
3319
+ const textHeight = resolvePositionedTextHeight(lines, layoutBox.contentY);
3318
3320
  const finalLayoutBox = finalizeLayoutBox(layoutBox, normalized, textHeight);
3319
3321
  const cachedPrepared = pretextState?.prepared ?? retainedPreparedState.prepared ?? null;
3320
3322
  const cachedPreparedWhiteSpace = pretextState?.preparedWhiteSpace ?? retainedPreparedState.preparedWhiteSpace ?? null;
@@ -3326,6 +3328,8 @@ function layoutParagraph(fontInstance, text, options = {}, state = {}) {
3326
3328
  y: finalLayoutBox.contentBox.y,
3327
3329
  width: textWidth,
3328
3330
  height: textHeight,
3331
+ viewportHeight: finalLayoutBox.contentBox.h,
3332
+ maxScrollTop: Math.max(0, textHeight - finalLayoutBox.contentBox.h),
3329
3333
  lineCount: lines.length,
3330
3334
  bbox: textBBox,
3331
3335
  contentBox: { ...finalLayoutBox.contentBox },
@@ -3351,8 +3355,13 @@ function normalizeParagraphOptions(fontInstance, options = {}) {
3351
3355
  ], null));
3352
3356
  const width = normalizeDimension(options.width);
3353
3357
  const height = normalizeDimension(options.height);
3358
+ const gap = normalizeGap(options.gap);
3359
+ const overflow = normalizeEnum(options.overflow, ["visible", "hidden"], "visible");
3360
+ const overflowY = resolveOverflowY(options.overflowY, overflow);
3354
3361
  if (options.width != null && width == null) throw new TypeError("font.paragraph() option \"width\" must be a positive number.");
3355
3362
  if (options.height != null && height == null) throw new TypeError("font.paragraph() option \"height\" must be a positive number.");
3363
+ if (options.gap != null && gap == null) throw new TypeError("font.paragraph() option \"gap\" must be a non-negative number.");
3364
+ if (overflowY === "scroll" && height == null) throw new TypeError("font.paragraph() option \"height\" is required when \"overflowY\" is \"scroll\".");
3356
3365
  const font = resolveCanvasFont(fontInstance, textOptions.size, options);
3357
3366
  return {
3358
3367
  ...textOptions,
@@ -3367,6 +3376,7 @@ function normalizeParagraphOptions(fontInstance, options = {}) {
3367
3376
  ], wrapDefaults.overflowWrap)),
3368
3377
  width,
3369
3378
  height,
3379
+ gap: gap ?? DEFAULT_PARAGRAPH_GAP,
3370
3380
  lineHeight: resolveLineHeight(options.lineHeight, textOptions.size),
3371
3381
  align: normalizeEnum(options.align, [
3372
3382
  "left",
@@ -3389,7 +3399,8 @@ function normalizeParagraphOptions(fontInstance, options = {}) {
3389
3399
  "break-all",
3390
3400
  "keep-all"
3391
3401
  ], wrapDefaults.wordBreak),
3392
- overflow: normalizeEnum(options.overflow, ["visible", "hidden"], "visible"),
3402
+ overflow,
3403
+ overflowY,
3393
3404
  textOverflow: normalizeNullableEnum(options.textOverflow, ["clip", "ellipsis"], null),
3394
3405
  maxLines: normalizeMaxLines(options.maxLines),
3395
3406
  ellipsis: normalizeEllipsis(options.ellipsis),
@@ -3417,6 +3428,44 @@ function resolveCanvasFont(fontInstance, size, options = {}) {
3417
3428
  function canReusePreparedParagraphState(previousState, previousOptions, nextOptions) {
3418
3429
  return previousState?.prepared != null && previousState.preparedWhiteSpace === resolvePretextWhiteSpace(nextOptions.whiteSpace) && previousOptions?.font === nextOptions.font;
3419
3430
  }
3431
+ function layoutParagraphsWithPretext(paragraphs, options, state, layoutBox) {
3432
+ const retainedPrepared = Array.isArray(state.prepared) ? state.prepared : state.prepared != null ? [state.prepared] : [];
3433
+ const paragraphStates = paragraphs.map((paragraph, paragraphIndex) => layoutWithPretext(paragraph, options, {
3434
+ prepared: retainedPrepared[paragraphIndex] ?? null,
3435
+ preparedWhiteSpace: state.preparedWhiteSpace,
3436
+ font: options.font
3437
+ }, layoutBox));
3438
+ return {
3439
+ lines: annotateParagraphLines(paragraphStates),
3440
+ prepared: paragraphStates.map((paragraphState) => paragraphState.prepared ?? null),
3441
+ preparedWhiteSpace: paragraphStates[0]?.preparedWhiteSpace ?? resolvePretextWhiteSpace(options.whiteSpace),
3442
+ layoutEngine: "pretext",
3443
+ usedOverflowWrapFallbackBreaks: paragraphStates.some((paragraphState) => paragraphState.usedOverflowWrapFallbackBreaks)
3444
+ };
3445
+ }
3446
+ function layoutParagraphsWithNative(fontInstance, paragraphs, options, layoutBox) {
3447
+ return {
3448
+ lines: annotateParagraphLines(paragraphs.map((paragraph) => layoutWithNative(fontInstance, paragraph, options, layoutBox))),
3449
+ prepared: null,
3450
+ preparedWhiteSpace: null,
3451
+ layoutEngine: "native"
3452
+ };
3453
+ }
3454
+ function annotateParagraphLines(paragraphStates) {
3455
+ const lines = [];
3456
+ paragraphStates.forEach((paragraphState, paragraphIndex) => {
3457
+ paragraphState.lines.forEach((line, lineIndex) => {
3458
+ lines.push({
3459
+ ...line,
3460
+ start: null,
3461
+ end: null,
3462
+ paragraphIndex,
3463
+ paragraphEnd: lineIndex === paragraphState.lines.length - 1
3464
+ });
3465
+ });
3466
+ });
3467
+ return lines;
3468
+ }
3420
3469
  function layoutWithPretext(text, options, state, layoutBox) {
3421
3470
  const preparedWhiteSpace = resolvePretextWhiteSpace(options.whiteSpace);
3422
3471
  const prepared = state.prepared != null && state.preparedWhiteSpace === preparedWhiteSpace && state.font === options.font ? state.prepared : prepareWithSegments(text, options.font, { whiteSpace: preparedWhiteSpace });
@@ -3540,8 +3589,8 @@ function applyOverflowClamping(lines, options, layoutBox, measureWidth) {
3540
3589
  const contentWidth = layoutBox.contentWidth;
3541
3590
  const result = lines.map((line) => ({ ...line }));
3542
3591
  let lineLimit = options.maxLines;
3543
- if (options.overflow === "hidden" && layoutBox.clipContentHeight != null) {
3544
- const visibleLineCount = Math.max(0, Math.floor((layoutBox.clipContentHeight + JUSTIFY_EPSILON) / options.lineHeight));
3592
+ if (shouldClampLinesToHeight(options) && layoutBox.clipContentHeight != null) {
3593
+ const visibleLineCount = countVisibleLinesForHeight(result, options, layoutBox.clipContentHeight);
3545
3594
  lineLimit = lineLimit == null ? visibleLineCount : Math.min(lineLimit, visibleLineCount);
3546
3595
  }
3547
3596
  let clippedByCount = false;
@@ -3553,14 +3602,30 @@ function applyOverflowClamping(lines, options, layoutBox, measureWidth) {
3553
3602
  }
3554
3603
  }
3555
3604
  if (result.length === 0) return result;
3556
- 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);
3557
- else if (clippedByCount && shouldEllipsizeClampedLines(options)) {
3605
+ if (options.overflow === "hidden" && options.whiteSpace === "nowrap") {
3606
+ 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);
3607
+ }
3608
+ if (clippedByCount && shouldEllipsizeClampedLines(options)) {
3558
3609
  const lastIndex = result.length - 1;
3559
3610
  result[lastIndex] = truncateLineToWidth(result[lastIndex], contentWidth, measureWidth, options.ellipsis);
3560
3611
  result[lastIndex].hardBreak = false;
3612
+ result[lastIndex].paragraphEnd = true;
3561
3613
  }
3562
3614
  return result;
3563
3615
  }
3616
+ function countVisibleLinesForHeight(lines, options, maxHeight) {
3617
+ let visibleLineCount = 0;
3618
+ let consumedHeight = 0;
3619
+ const lineBoxHeight = options.lineHeight;
3620
+ const paragraphGap = lineBoxHeight * options.gap;
3621
+ for (let index = 0; index < lines.length; index += 1) {
3622
+ if (consumedHeight + lineBoxHeight > maxHeight + JUSTIFY_EPSILON) break;
3623
+ consumedHeight += lineBoxHeight;
3624
+ visibleLineCount += 1;
3625
+ if (lines[index].paragraphEnd && index < lines.length - 1) consumedHeight += paragraphGap;
3626
+ }
3627
+ return visibleLineCount;
3628
+ }
3564
3629
  function truncateLineToWidth(line, maxWidth, measureWidth, suffix) {
3565
3630
  const suffixText = suffix === false ? "" : suffix;
3566
3631
  if ((suffixText.length > 0 ? measureWidth(suffixText) : 0) > maxWidth + JUSTIFY_EPSILON) return {
@@ -3585,13 +3650,17 @@ function shouldEllipsizeClampedLines(options) {
3585
3650
  function positionLines(fontInstance, lines, options, layoutBox, measureWidth) {
3586
3651
  const ascent = getFontAscender(fontInstance, options.size);
3587
3652
  const lineBoxHeight = options.lineHeight;
3653
+ const paragraphGap = lineBoxHeight * options.gap;
3588
3654
  let cursor = 0;
3655
+ let cursorY = layoutBox.contentY;
3589
3656
  return lines.map((line, index) => {
3590
3657
  const justified = shouldJustifyLine(line, index, lines.length, options);
3591
3658
  const offsetX = justified ? 0 : resolveAlignOffset(options.align, line.width, layoutBox.contentWidth);
3592
3659
  const x = layoutBox.contentX + offsetX;
3593
- const y = layoutBox.contentY + index * lineBoxHeight;
3660
+ const y = cursorY;
3594
3661
  const baseline = y + ascent;
3662
+ const start = line.start ?? cursor;
3663
+ const end = line.end ?? start + line.text.length;
3595
3664
  const fragments = justified ? buildJustifiedFragments(line, layoutBox.contentX, layoutBox.contentWidth, measureWidth) : [{
3596
3665
  text: line.text,
3597
3666
  x,
@@ -3608,8 +3677,8 @@ function positionLines(fontInstance, lines, options, layoutBox, measureWidth) {
3608
3677
  const positioned = {
3609
3678
  index,
3610
3679
  text: line.text,
3611
- start: line.start ?? cursor,
3612
- end: line.end ?? cursor + line.text.length,
3680
+ start,
3681
+ end,
3613
3682
  x,
3614
3683
  y,
3615
3684
  width,
@@ -3619,10 +3688,21 @@ function positionLines(fontInstance, lines, options, layoutBox, measureWidth) {
3619
3688
  hardBreak: line.hardBreak,
3620
3689
  fragments
3621
3690
  };
3622
- cursor = positioned.end + (line.hardBreak ? 1 : 0);
3691
+ cursor = end;
3692
+ if (line.hardBreak) cursor += 1;
3693
+ if (line.paragraphEnd && index < lines.length - 1) {
3694
+ cursor += 2;
3695
+ cursorY += paragraphGap;
3696
+ }
3697
+ cursorY += lineBoxHeight;
3623
3698
  return positioned;
3624
3699
  });
3625
3700
  }
3701
+ function resolvePositionedTextHeight(lines, contentY) {
3702
+ if (lines.length === 0) return 0;
3703
+ const lastLine = lines[lines.length - 1];
3704
+ return lastLine.y + lastLine.height - contentY;
3705
+ }
3626
3706
  function buildJustifiedFragments(line, contentX, contentWidth, measureWidth) {
3627
3707
  const tokens = splitPreservingWhitespace(line.text);
3628
3708
  const expandable = tokens.reduce((count, token, index) => {
@@ -3653,7 +3733,7 @@ function buildJustifiedFragments(line, contentX, contentWidth, measureWidth) {
3653
3733
  return fragments;
3654
3734
  }
3655
3735
  function shouldJustifyLine(line, index, lineCount, options) {
3656
- return options.align === "justify" && index < lineCount - 1 && !line.hardBreak && /\S\s+\S/u.test(line.text);
3736
+ return options.align === "justify" && index < lineCount - 1 && !line.hardBreak && !line.paragraphEnd && /\S\s+\S/u.test(line.text);
3657
3737
  }
3658
3738
  function resolveAlignOffset(align, lineWidth, maxWidth) {
3659
3739
  if (align === "center") return (maxWidth - lineWidth) * .5;
@@ -3734,7 +3814,7 @@ function finalizeLayoutBox(layoutBox, options, textHeight) {
3734
3814
  x: layoutBox.contentX,
3735
3815
  y: layoutBox.contentY,
3736
3816
  w: layoutBox.contentWidth,
3737
- h: options.overflow === "hidden" ? contentHeight : textHeight
3817
+ h: shouldClipToContentHeight(options) ? contentHeight : textHeight
3738
3818
  }
3739
3819
  };
3740
3820
  }
@@ -3750,6 +3830,19 @@ function normalizeNullableEnum(value, supported, fallback) {
3750
3830
  if (value == null) return fallback;
3751
3831
  return typeof value === "string" && supported.includes(value) ? value : fallback;
3752
3832
  }
3833
+ function resolveOverflowY(value, overflow) {
3834
+ return normalizeEnum(value, [
3835
+ "visible",
3836
+ "hidden",
3837
+ "scroll"
3838
+ ], overflow === "hidden" ? "hidden" : "visible");
3839
+ }
3840
+ function shouldClampLinesToHeight(options) {
3841
+ return options.overflowY === "hidden";
3842
+ }
3843
+ function shouldClipToContentHeight(options) {
3844
+ return options.overflow === "hidden" || options.overflowY === "hidden" || options.overflowY === "scroll";
3845
+ }
3753
3846
  function normalizeEllipsis(value) {
3754
3847
  if (value === false) return false;
3755
3848
  if (typeof value === "string") return value;
@@ -3763,6 +3856,9 @@ function normalizeMaxLines(value) {
3763
3856
  function normalizeDimension(value) {
3764
3857
  return Number.isFinite(value) && value > 0 ? value : null;
3765
3858
  }
3859
+ function normalizeGap(value) {
3860
+ return Number.isFinite(value) && value >= 0 ? Number(value) : null;
3861
+ }
3766
3862
  function normalizeSpacing(value) {
3767
3863
  if (value == null) return zeroSpacing();
3768
3864
  if (Number.isFinite(value)) {
@@ -3877,6 +3973,27 @@ function resolveRetainedPreparedState(state, options) {
3877
3973
  function isHardBreak(prepared, line) {
3878
3974
  return prepared.kinds?.[line.end.segmentIndex] === "hard-break";
3879
3975
  }
3976
+ function splitParagraphText(text, whiteSpace) {
3977
+ const normalized = String(text ?? "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
3978
+ const paragraphs = [];
3979
+ const lines = normalized.split("\n");
3980
+ let currentLines = [];
3981
+ const pushCurrentParagraph = () => {
3982
+ if (currentLines.length === 0) return;
3983
+ const paragraph = whiteSpace === "pre-wrap" ? currentLines.join("\n") : currentLines.join("\n").replace(/\s+/gu, " ").trim();
3984
+ currentLines = [];
3985
+ if (paragraph.length > 0) paragraphs.push(paragraph);
3986
+ };
3987
+ lines.forEach((line) => {
3988
+ if (/^[\t ]*$/u.test(line)) {
3989
+ pushCurrentParagraph();
3990
+ return;
3991
+ }
3992
+ currentLines.push(line);
3993
+ });
3994
+ pushCurrentParagraph();
3995
+ return paragraphs;
3996
+ }
3880
3997
  function normalizeNativeText(text, whiteSpace) {
3881
3998
  if (whiteSpace === "pre-wrap") return String(text ?? "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
3882
3999
  return String(text ?? "").replace(/\s+/gu, " ").trim();
@@ -4162,6 +4279,7 @@ var paParagraph = class paParagraph {
4162
4279
  if (!ctx || typeof ctx.fillText !== "function") throw new TypeError("drawText() expects a CanvasRenderingContext2D.");
4163
4280
  const fill = options.fill !== false;
4164
4281
  const stroke = options.stroke === true;
4282
+ const scrollTop = clampScrollTop(options.scrollTop, this.metrics.maxScrollTop);
4165
4283
  if (!fill && !stroke) return;
4166
4284
  this._syncLayoutWithContext(ctx);
4167
4285
  ctx.save();
@@ -4170,12 +4288,13 @@ var paParagraph = class paParagraph {
4170
4288
  ctx.textBaseline = "alphabetic";
4171
4289
  if (options.fillStyle != null) ctx.fillStyle = options.fillStyle;
4172
4290
  if (options.strokeStyle != null) ctx.strokeStyle = options.strokeStyle;
4173
- if (this.options.overflow === "hidden" && this._state.layoutBox?.clipBox) {
4291
+ if (shouldClipParagraph(this.options) && this._state.layoutBox?.clipBox) {
4174
4292
  const clipBox = this._state.layoutBox.clipBox;
4175
4293
  ctx.beginPath();
4176
4294
  ctx.rect(clipBox.x, clipBox.y, clipBox.w, clipBox.h);
4177
4295
  ctx.clip();
4178
4296
  }
4297
+ if (scrollTop > 0) ctx.translate(0, -scrollTop);
4179
4298
  this._state.lines.forEach((line) => {
4180
4299
  line.fragments.forEach((fragment) => {
4181
4300
  if (fill) ctx.fillText(fragment.text, fragment.x, line.baseline);
@@ -4226,6 +4345,8 @@ var paParagraph = class paParagraph {
4226
4345
  this.metrics = {
4227
4346
  ...state.metrics,
4228
4347
  bbox: { ...state.metrics.bbox },
4348
+ viewportHeight: state.metrics.viewportHeight,
4349
+ maxScrollTop: state.metrics.maxScrollTop,
4229
4350
  contentBox: state.metrics.contentBox ? { ...state.metrics.contentBox } : void 0,
4230
4351
  paddingBox: state.metrics.paddingBox ? { ...state.metrics.paddingBox } : void 0,
4231
4352
  marginBox: state.metrics.marginBox ? { ...state.metrics.marginBox } : void 0,
@@ -4320,6 +4441,13 @@ function normalizeParagraphPointOptions(options = {}) {
4320
4441
  layout: options.layout ?? "current"
4321
4442
  };
4322
4443
  }
4444
+ function shouldClipParagraph(options) {
4445
+ return options.overflow === "hidden" || options.overflowY === "hidden" || options.overflowY === "scroll";
4446
+ }
4447
+ function clampScrollTop(value, maxScrollTop) {
4448
+ if (!Number.isFinite(value) || value <= 0) return 0;
4449
+ return Math.min(Number(value), maxScrollTop ?? 0);
4450
+ }
4323
4451
  //#endregion
4324
4452
  //#region src/paFont/paFont.js
4325
4453
  var browserFontRegistrationId = 0;