pa_font 0.2.8 → 0.3.0

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;
@@ -3351,8 +3353,10 @@ function normalizeParagraphOptions(fontInstance, options = {}) {
3351
3353
  ], null));
3352
3354
  const width = normalizeDimension(options.width);
3353
3355
  const height = normalizeDimension(options.height);
3356
+ const gap = normalizeGap(options.gap);
3354
3357
  if (options.width != null && width == null) throw new TypeError("font.paragraph() option \"width\" must be a positive number.");
3355
3358
  if (options.height != null && height == null) throw new TypeError("font.paragraph() option \"height\" must be a positive number.");
3359
+ if (options.gap != null && gap == null) throw new TypeError("font.paragraph() option \"gap\" must be a non-negative number.");
3356
3360
  const font = resolveCanvasFont(fontInstance, textOptions.size, options);
3357
3361
  return {
3358
3362
  ...textOptions,
@@ -3367,6 +3371,7 @@ function normalizeParagraphOptions(fontInstance, options = {}) {
3367
3371
  ], wrapDefaults.overflowWrap)),
3368
3372
  width,
3369
3373
  height,
3374
+ gap: gap ?? DEFAULT_PARAGRAPH_GAP,
3370
3375
  lineHeight: resolveLineHeight(options.lineHeight, textOptions.size),
3371
3376
  align: normalizeEnum(options.align, [
3372
3377
  "left",
@@ -3417,6 +3422,44 @@ function resolveCanvasFont(fontInstance, size, options = {}) {
3417
3422
  function canReusePreparedParagraphState(previousState, previousOptions, nextOptions) {
3418
3423
  return previousState?.prepared != null && previousState.preparedWhiteSpace === resolvePretextWhiteSpace(nextOptions.whiteSpace) && previousOptions?.font === nextOptions.font;
3419
3424
  }
3425
+ function layoutParagraphsWithPretext(paragraphs, options, state, layoutBox) {
3426
+ const retainedPrepared = Array.isArray(state.prepared) ? state.prepared : state.prepared != null ? [state.prepared] : [];
3427
+ const paragraphStates = paragraphs.map((paragraph, paragraphIndex) => layoutWithPretext(paragraph, options, {
3428
+ prepared: retainedPrepared[paragraphIndex] ?? null,
3429
+ preparedWhiteSpace: state.preparedWhiteSpace,
3430
+ font: options.font
3431
+ }, layoutBox));
3432
+ return {
3433
+ lines: annotateParagraphLines(paragraphStates),
3434
+ prepared: paragraphStates.map((paragraphState) => paragraphState.prepared ?? null),
3435
+ preparedWhiteSpace: paragraphStates[0]?.preparedWhiteSpace ?? resolvePretextWhiteSpace(options.whiteSpace),
3436
+ layoutEngine: "pretext",
3437
+ usedOverflowWrapFallbackBreaks: paragraphStates.some((paragraphState) => paragraphState.usedOverflowWrapFallbackBreaks)
3438
+ };
3439
+ }
3440
+ function layoutParagraphsWithNative(fontInstance, paragraphs, options, layoutBox) {
3441
+ return {
3442
+ lines: annotateParagraphLines(paragraphs.map((paragraph) => layoutWithNative(fontInstance, paragraph, options, layoutBox))),
3443
+ prepared: null,
3444
+ preparedWhiteSpace: null,
3445
+ layoutEngine: "native"
3446
+ };
3447
+ }
3448
+ function annotateParagraphLines(paragraphStates) {
3449
+ const lines = [];
3450
+ paragraphStates.forEach((paragraphState, paragraphIndex) => {
3451
+ paragraphState.lines.forEach((line, lineIndex) => {
3452
+ lines.push({
3453
+ ...line,
3454
+ start: null,
3455
+ end: null,
3456
+ paragraphIndex,
3457
+ paragraphEnd: lineIndex === paragraphState.lines.length - 1
3458
+ });
3459
+ });
3460
+ });
3461
+ return lines;
3462
+ }
3420
3463
  function layoutWithPretext(text, options, state, layoutBox) {
3421
3464
  const preparedWhiteSpace = resolvePretextWhiteSpace(options.whiteSpace);
3422
3465
  const prepared = state.prepared != null && state.preparedWhiteSpace === preparedWhiteSpace && state.font === options.font ? state.prepared : prepareWithSegments(text, options.font, { whiteSpace: preparedWhiteSpace });
@@ -3541,7 +3584,7 @@ function applyOverflowClamping(lines, options, layoutBox, measureWidth) {
3541
3584
  const result = lines.map((line) => ({ ...line }));
3542
3585
  let lineLimit = options.maxLines;
3543
3586
  if (options.overflow === "hidden" && layoutBox.clipContentHeight != null) {
3544
- const visibleLineCount = Math.max(0, Math.floor((layoutBox.clipContentHeight + JUSTIFY_EPSILON) / options.lineHeight));
3587
+ const visibleLineCount = countVisibleLinesForHeight(result, options, layoutBox.clipContentHeight);
3545
3588
  lineLimit = lineLimit == null ? visibleLineCount : Math.min(lineLimit, visibleLineCount);
3546
3589
  }
3547
3590
  let clippedByCount = false;
@@ -3553,14 +3596,30 @@ function applyOverflowClamping(lines, options, layoutBox, measureWidth) {
3553
3596
  }
3554
3597
  }
3555
3598
  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)) {
3599
+ if (options.overflow === "hidden" && options.whiteSpace === "nowrap") {
3600
+ 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);
3601
+ }
3602
+ if (clippedByCount && shouldEllipsizeClampedLines(options)) {
3558
3603
  const lastIndex = result.length - 1;
3559
3604
  result[lastIndex] = truncateLineToWidth(result[lastIndex], contentWidth, measureWidth, options.ellipsis);
3560
3605
  result[lastIndex].hardBreak = false;
3606
+ result[lastIndex].paragraphEnd = true;
3561
3607
  }
3562
3608
  return result;
3563
3609
  }
3610
+ function countVisibleLinesForHeight(lines, options, maxHeight) {
3611
+ let visibleLineCount = 0;
3612
+ let consumedHeight = 0;
3613
+ const lineBoxHeight = options.lineHeight;
3614
+ const paragraphGap = lineBoxHeight * options.gap;
3615
+ for (let index = 0; index < lines.length; index += 1) {
3616
+ if (consumedHeight + lineBoxHeight > maxHeight + JUSTIFY_EPSILON) break;
3617
+ consumedHeight += lineBoxHeight;
3618
+ visibleLineCount += 1;
3619
+ if (lines[index].paragraphEnd && index < lines.length - 1) consumedHeight += paragraphGap;
3620
+ }
3621
+ return visibleLineCount;
3622
+ }
3564
3623
  function truncateLineToWidth(line, maxWidth, measureWidth, suffix) {
3565
3624
  const suffixText = suffix === false ? "" : suffix;
3566
3625
  if ((suffixText.length > 0 ? measureWidth(suffixText) : 0) > maxWidth + JUSTIFY_EPSILON) return {
@@ -3585,13 +3644,17 @@ function shouldEllipsizeClampedLines(options) {
3585
3644
  function positionLines(fontInstance, lines, options, layoutBox, measureWidth) {
3586
3645
  const ascent = getFontAscender(fontInstance, options.size);
3587
3646
  const lineBoxHeight = options.lineHeight;
3647
+ const paragraphGap = lineBoxHeight * options.gap;
3588
3648
  let cursor = 0;
3649
+ let cursorY = layoutBox.contentY;
3589
3650
  return lines.map((line, index) => {
3590
3651
  const justified = shouldJustifyLine(line, index, lines.length, options);
3591
3652
  const offsetX = justified ? 0 : resolveAlignOffset(options.align, line.width, layoutBox.contentWidth);
3592
3653
  const x = layoutBox.contentX + offsetX;
3593
- const y = layoutBox.contentY + index * lineBoxHeight;
3654
+ const y = cursorY;
3594
3655
  const baseline = y + ascent;
3656
+ const start = line.start ?? cursor;
3657
+ const end = line.end ?? start + line.text.length;
3595
3658
  const fragments = justified ? buildJustifiedFragments(line, layoutBox.contentX, layoutBox.contentWidth, measureWidth) : [{
3596
3659
  text: line.text,
3597
3660
  x,
@@ -3608,8 +3671,8 @@ function positionLines(fontInstance, lines, options, layoutBox, measureWidth) {
3608
3671
  const positioned = {
3609
3672
  index,
3610
3673
  text: line.text,
3611
- start: line.start ?? cursor,
3612
- end: line.end ?? cursor + line.text.length,
3674
+ start,
3675
+ end,
3613
3676
  x,
3614
3677
  y,
3615
3678
  width,
@@ -3619,10 +3682,21 @@ function positionLines(fontInstance, lines, options, layoutBox, measureWidth) {
3619
3682
  hardBreak: line.hardBreak,
3620
3683
  fragments
3621
3684
  };
3622
- cursor = positioned.end + (line.hardBreak ? 1 : 0);
3685
+ cursor = end;
3686
+ if (line.hardBreak) cursor += 1;
3687
+ if (line.paragraphEnd && index < lines.length - 1) {
3688
+ cursor += 2;
3689
+ cursorY += paragraphGap;
3690
+ }
3691
+ cursorY += lineBoxHeight;
3623
3692
  return positioned;
3624
3693
  });
3625
3694
  }
3695
+ function resolvePositionedTextHeight(lines, contentY) {
3696
+ if (lines.length === 0) return 0;
3697
+ const lastLine = lines[lines.length - 1];
3698
+ return lastLine.y + lastLine.height - contentY;
3699
+ }
3626
3700
  function buildJustifiedFragments(line, contentX, contentWidth, measureWidth) {
3627
3701
  const tokens = splitPreservingWhitespace(line.text);
3628
3702
  const expandable = tokens.reduce((count, token, index) => {
@@ -3653,7 +3727,7 @@ function buildJustifiedFragments(line, contentX, contentWidth, measureWidth) {
3653
3727
  return fragments;
3654
3728
  }
3655
3729
  function shouldJustifyLine(line, index, lineCount, options) {
3656
- return options.align === "justify" && index < lineCount - 1 && !line.hardBreak && /\S\s+\S/u.test(line.text);
3730
+ return options.align === "justify" && index < lineCount - 1 && !line.hardBreak && !line.paragraphEnd && /\S\s+\S/u.test(line.text);
3657
3731
  }
3658
3732
  function resolveAlignOffset(align, lineWidth, maxWidth) {
3659
3733
  if (align === "center") return (maxWidth - lineWidth) * .5;
@@ -3763,6 +3837,9 @@ function normalizeMaxLines(value) {
3763
3837
  function normalizeDimension(value) {
3764
3838
  return Number.isFinite(value) && value > 0 ? value : null;
3765
3839
  }
3840
+ function normalizeGap(value) {
3841
+ return Number.isFinite(value) && value >= 0 ? Number(value) : null;
3842
+ }
3766
3843
  function normalizeSpacing(value) {
3767
3844
  if (value == null) return zeroSpacing();
3768
3845
  if (Number.isFinite(value)) {
@@ -3877,6 +3954,27 @@ function resolveRetainedPreparedState(state, options) {
3877
3954
  function isHardBreak(prepared, line) {
3878
3955
  return prepared.kinds?.[line.end.segmentIndex] === "hard-break";
3879
3956
  }
3957
+ function splitParagraphText(text, whiteSpace) {
3958
+ const normalized = String(text ?? "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
3959
+ const paragraphs = [];
3960
+ const lines = normalized.split("\n");
3961
+ let currentLines = [];
3962
+ const pushCurrentParagraph = () => {
3963
+ if (currentLines.length === 0) return;
3964
+ const paragraph = whiteSpace === "pre-wrap" ? currentLines.join("\n") : currentLines.join("\n").replace(/\s+/gu, " ").trim();
3965
+ currentLines = [];
3966
+ if (paragraph.length > 0) paragraphs.push(paragraph);
3967
+ };
3968
+ lines.forEach((line) => {
3969
+ if (/^[\t ]*$/u.test(line)) {
3970
+ pushCurrentParagraph();
3971
+ return;
3972
+ }
3973
+ currentLines.push(line);
3974
+ });
3975
+ pushCurrentParagraph();
3976
+ return paragraphs;
3977
+ }
3880
3978
  function normalizeNativeText(text, whiteSpace) {
3881
3979
  if (whiteSpace === "pre-wrap") return String(text ?? "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
3882
3980
  return String(text ?? "").replace(/\s+/gu, " ").trim();