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/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
 
@@ -128,6 +132,12 @@ console.log(metrics.width, metrics.bbox);
128
132
 
129
133
  `wordBreak` / `overflowWrap`를 직접 주면 `wrap`보다 우선합니다.
130
134
 
135
+ 빈 줄 하나 이상은 새 문단으로 처리됩니다.
136
+
137
+ - `gap`: 문단 사이 간격. `lineHeight` 배수이며 기본값은 `0.5`
138
+ - `whiteSpace: "normal"`이면 문단 안 단일 줄바꿈은 공백으로 정리
139
+ - `whiteSpace: "pre-wrap"`이면 문단 안 단일 줄바꿈 유지
140
+
131
141
  ## paParagraph API
132
142
 
133
143
  `font.paragraph()`는 `paParagraph`를 반환합니다.
@@ -281,6 +291,7 @@ const paragraph = font.paragraph(text, {
281
291
  y: 0,
282
292
  size: 20,
283
293
  lineHeight: 1.6,
294
+ gap: 0.5,
284
295
  padding: { x: 32, y: 24 },
285
296
  margin: "20 0 0 20",
286
297
  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;
@@ -3355,8 +3357,10 @@ function normalizeParagraphOptions(fontInstance, options = {}) {
3355
3357
  ], null));
3356
3358
  const width = normalizeDimension(options.width);
3357
3359
  const height = normalizeDimension(options.height);
3360
+ const gap = normalizeGap(options.gap);
3358
3361
  if (options.width != null && width == null) throw new TypeError("font.paragraph() option \"width\" must be a positive number.");
3359
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.");
3360
3364
  const font = resolveCanvasFont(fontInstance, textOptions.size, options);
3361
3365
  return {
3362
3366
  ...textOptions,
@@ -3371,6 +3375,7 @@ function normalizeParagraphOptions(fontInstance, options = {}) {
3371
3375
  ], wrapDefaults.overflowWrap)),
3372
3376
  width,
3373
3377
  height,
3378
+ gap: gap ?? DEFAULT_PARAGRAPH_GAP,
3374
3379
  lineHeight: resolveLineHeight(options.lineHeight, textOptions.size),
3375
3380
  align: normalizeEnum(options.align, [
3376
3381
  "left",
@@ -3421,6 +3426,44 @@ function resolveCanvasFont(fontInstance, size, options = {}) {
3421
3426
  function canReusePreparedParagraphState(previousState, previousOptions, nextOptions) {
3422
3427
  return previousState?.prepared != null && previousState.preparedWhiteSpace === resolvePretextWhiteSpace(nextOptions.whiteSpace) && previousOptions?.font === nextOptions.font;
3423
3428
  }
3429
+ function layoutParagraphsWithPretext(paragraphs, options, state, layoutBox) {
3430
+ const retainedPrepared = Array.isArray(state.prepared) ? state.prepared : state.prepared != null ? [state.prepared] : [];
3431
+ const paragraphStates = paragraphs.map((paragraph, paragraphIndex) => layoutWithPretext(paragraph, options, {
3432
+ prepared: retainedPrepared[paragraphIndex] ?? null,
3433
+ preparedWhiteSpace: state.preparedWhiteSpace,
3434
+ font: options.font
3435
+ }, layoutBox));
3436
+ return {
3437
+ lines: annotateParagraphLines(paragraphStates),
3438
+ prepared: paragraphStates.map((paragraphState) => paragraphState.prepared ?? null),
3439
+ preparedWhiteSpace: paragraphStates[0]?.preparedWhiteSpace ?? resolvePretextWhiteSpace(options.whiteSpace),
3440
+ layoutEngine: "pretext",
3441
+ usedOverflowWrapFallbackBreaks: paragraphStates.some((paragraphState) => paragraphState.usedOverflowWrapFallbackBreaks)
3442
+ };
3443
+ }
3444
+ function layoutParagraphsWithNative(fontInstance, paragraphs, options, layoutBox) {
3445
+ return {
3446
+ lines: annotateParagraphLines(paragraphs.map((paragraph) => layoutWithNative(fontInstance, paragraph, options, layoutBox))),
3447
+ prepared: null,
3448
+ preparedWhiteSpace: null,
3449
+ layoutEngine: "native"
3450
+ };
3451
+ }
3452
+ function annotateParagraphLines(paragraphStates) {
3453
+ const lines = [];
3454
+ paragraphStates.forEach((paragraphState, paragraphIndex) => {
3455
+ paragraphState.lines.forEach((line, lineIndex) => {
3456
+ lines.push({
3457
+ ...line,
3458
+ start: null,
3459
+ end: null,
3460
+ paragraphIndex,
3461
+ paragraphEnd: lineIndex === paragraphState.lines.length - 1
3462
+ });
3463
+ });
3464
+ });
3465
+ return lines;
3466
+ }
3424
3467
  function layoutWithPretext(text, options, state, layoutBox) {
3425
3468
  const preparedWhiteSpace = resolvePretextWhiteSpace(options.whiteSpace);
3426
3469
  const prepared = state.prepared != null && state.preparedWhiteSpace === preparedWhiteSpace && state.font === options.font ? state.prepared : prepareWithSegments(text, options.font, { whiteSpace: preparedWhiteSpace });
@@ -3545,7 +3588,7 @@ function applyOverflowClamping(lines, options, layoutBox, measureWidth) {
3545
3588
  const result = lines.map((line) => ({ ...line }));
3546
3589
  let lineLimit = options.maxLines;
3547
3590
  if (options.overflow === "hidden" && layoutBox.clipContentHeight != null) {
3548
- const visibleLineCount = Math.max(0, Math.floor((layoutBox.clipContentHeight + JUSTIFY_EPSILON) / options.lineHeight));
3591
+ const visibleLineCount = countVisibleLinesForHeight(result, options, layoutBox.clipContentHeight);
3549
3592
  lineLimit = lineLimit == null ? visibleLineCount : Math.min(lineLimit, visibleLineCount);
3550
3593
  }
3551
3594
  let clippedByCount = false;
@@ -3557,14 +3600,30 @@ function applyOverflowClamping(lines, options, layoutBox, measureWidth) {
3557
3600
  }
3558
3601
  }
3559
3602
  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)) {
3603
+ if (options.overflow === "hidden" && options.whiteSpace === "nowrap") {
3604
+ 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);
3605
+ }
3606
+ if (clippedByCount && shouldEllipsizeClampedLines(options)) {
3562
3607
  const lastIndex = result.length - 1;
3563
3608
  result[lastIndex] = truncateLineToWidth(result[lastIndex], contentWidth, measureWidth, options.ellipsis);
3564
3609
  result[lastIndex].hardBreak = false;
3610
+ result[lastIndex].paragraphEnd = true;
3565
3611
  }
3566
3612
  return result;
3567
3613
  }
3614
+ function countVisibleLinesForHeight(lines, options, maxHeight) {
3615
+ let visibleLineCount = 0;
3616
+ let consumedHeight = 0;
3617
+ const lineBoxHeight = options.lineHeight;
3618
+ const paragraphGap = lineBoxHeight * options.gap;
3619
+ for (let index = 0; index < lines.length; index += 1) {
3620
+ if (consumedHeight + lineBoxHeight > maxHeight + JUSTIFY_EPSILON) break;
3621
+ consumedHeight += lineBoxHeight;
3622
+ visibleLineCount += 1;
3623
+ if (lines[index].paragraphEnd && index < lines.length - 1) consumedHeight += paragraphGap;
3624
+ }
3625
+ return visibleLineCount;
3626
+ }
3568
3627
  function truncateLineToWidth(line, maxWidth, measureWidth, suffix) {
3569
3628
  const suffixText = suffix === false ? "" : suffix;
3570
3629
  if ((suffixText.length > 0 ? measureWidth(suffixText) : 0) > maxWidth + JUSTIFY_EPSILON) return {
@@ -3589,13 +3648,17 @@ function shouldEllipsizeClampedLines(options) {
3589
3648
  function positionLines(fontInstance, lines, options, layoutBox, measureWidth) {
3590
3649
  const ascent = getFontAscender(fontInstance, options.size);
3591
3650
  const lineBoxHeight = options.lineHeight;
3651
+ const paragraphGap = lineBoxHeight * options.gap;
3592
3652
  let cursor = 0;
3653
+ let cursorY = layoutBox.contentY;
3593
3654
  return lines.map((line, index) => {
3594
3655
  const justified = shouldJustifyLine(line, index, lines.length, options);
3595
3656
  const offsetX = justified ? 0 : resolveAlignOffset(options.align, line.width, layoutBox.contentWidth);
3596
3657
  const x = layoutBox.contentX + offsetX;
3597
- const y = layoutBox.contentY + index * lineBoxHeight;
3658
+ const y = cursorY;
3598
3659
  const baseline = y + ascent;
3660
+ const start = line.start ?? cursor;
3661
+ const end = line.end ?? start + line.text.length;
3599
3662
  const fragments = justified ? buildJustifiedFragments(line, layoutBox.contentX, layoutBox.contentWidth, measureWidth) : [{
3600
3663
  text: line.text,
3601
3664
  x,
@@ -3612,8 +3675,8 @@ function positionLines(fontInstance, lines, options, layoutBox, measureWidth) {
3612
3675
  const positioned = {
3613
3676
  index,
3614
3677
  text: line.text,
3615
- start: line.start ?? cursor,
3616
- end: line.end ?? cursor + line.text.length,
3678
+ start,
3679
+ end,
3617
3680
  x,
3618
3681
  y,
3619
3682
  width,
@@ -3623,10 +3686,21 @@ function positionLines(fontInstance, lines, options, layoutBox, measureWidth) {
3623
3686
  hardBreak: line.hardBreak,
3624
3687
  fragments
3625
3688
  };
3626
- cursor = positioned.end + (line.hardBreak ? 1 : 0);
3689
+ cursor = end;
3690
+ if (line.hardBreak) cursor += 1;
3691
+ if (line.paragraphEnd && index < lines.length - 1) {
3692
+ cursor += 2;
3693
+ cursorY += paragraphGap;
3694
+ }
3695
+ cursorY += lineBoxHeight;
3627
3696
  return positioned;
3628
3697
  });
3629
3698
  }
3699
+ function resolvePositionedTextHeight(lines, contentY) {
3700
+ if (lines.length === 0) return 0;
3701
+ const lastLine = lines[lines.length - 1];
3702
+ return lastLine.y + lastLine.height - contentY;
3703
+ }
3630
3704
  function buildJustifiedFragments(line, contentX, contentWidth, measureWidth) {
3631
3705
  const tokens = splitPreservingWhitespace(line.text);
3632
3706
  const expandable = tokens.reduce((count, token, index) => {
@@ -3657,7 +3731,7 @@ function buildJustifiedFragments(line, contentX, contentWidth, measureWidth) {
3657
3731
  return fragments;
3658
3732
  }
3659
3733
  function shouldJustifyLine(line, index, lineCount, options) {
3660
- return options.align === "justify" && index < lineCount - 1 && !line.hardBreak && /\S\s+\S/u.test(line.text);
3734
+ return options.align === "justify" && index < lineCount - 1 && !line.hardBreak && !line.paragraphEnd && /\S\s+\S/u.test(line.text);
3661
3735
  }
3662
3736
  function resolveAlignOffset(align, lineWidth, maxWidth) {
3663
3737
  if (align === "center") return (maxWidth - lineWidth) * .5;
@@ -3767,6 +3841,9 @@ function normalizeMaxLines(value) {
3767
3841
  function normalizeDimension(value) {
3768
3842
  return Number.isFinite(value) && value > 0 ? value : null;
3769
3843
  }
3844
+ function normalizeGap(value) {
3845
+ return Number.isFinite(value) && value >= 0 ? Number(value) : null;
3846
+ }
3770
3847
  function normalizeSpacing(value) {
3771
3848
  if (value == null) return zeroSpacing();
3772
3849
  if (Number.isFinite(value)) {
@@ -3881,6 +3958,27 @@ function resolveRetainedPreparedState(state, options) {
3881
3958
  function isHardBreak(prepared, line) {
3882
3959
  return prepared.kinds?.[line.end.segmentIndex] === "hard-break";
3883
3960
  }
3961
+ function splitParagraphText(text, whiteSpace) {
3962
+ const normalized = String(text ?? "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
3963
+ const paragraphs = [];
3964
+ const lines = normalized.split("\n");
3965
+ let currentLines = [];
3966
+ const pushCurrentParagraph = () => {
3967
+ if (currentLines.length === 0) return;
3968
+ const paragraph = whiteSpace === "pre-wrap" ? currentLines.join("\n") : currentLines.join("\n").replace(/\s+/gu, " ").trim();
3969
+ currentLines = [];
3970
+ if (paragraph.length > 0) paragraphs.push(paragraph);
3971
+ };
3972
+ lines.forEach((line) => {
3973
+ if (/^[\t ]*$/u.test(line)) {
3974
+ pushCurrentParagraph();
3975
+ return;
3976
+ }
3977
+ currentLines.push(line);
3978
+ });
3979
+ pushCurrentParagraph();
3980
+ return paragraphs;
3981
+ }
3884
3982
  function normalizeNativeText(text, whiteSpace) {
3885
3983
  if (whiteSpace === "pre-wrap") return String(text ?? "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
3886
3984
  return String(text ?? "").replace(/\s+/gu, " ").trim();