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 +21 -10
- package/dist/paFont.cjs +109 -11
- package/dist/paFont.cjs.map +1 -1
- package/dist/paFont.js +109 -11
- package/dist/paFont.js.map +1 -1
- package/paFont.d.ts +1 -0
- package/package.json +1 -1
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
3316
|
-
const
|
|
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
|
|
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 =
|
|
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"
|
|
3561
|
-
|
|
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 =
|
|
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
|
|
3616
|
-
end
|
|
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 =
|
|
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();
|