pa_font 0.3.4 → 0.3.7

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
@@ -39,6 +39,26 @@ const regions = shape.toRegions();
39
39
  const points = shape.toPoints({ step: 8 });
40
40
  ```
41
41
 
42
+ 문자열 길이에 따라 비슷한 최종 폭으로 맞추고 싶다면 `size`에 객체를 넣을 수 있습니다.
43
+
44
+ ```js
45
+ const short = font.text("펑", {
46
+ x: canvasWidth * 0.5,
47
+ y: canvasHeight * 0.5,
48
+ anchor: 0.5,
49
+ size: { width: canvasWidth * 0.7, basis: "bbox" },
50
+ });
51
+
52
+ const long = font.text("왕밤빵", {
53
+ x: canvasWidth * 0.5,
54
+ y: canvasHeight * 0.5,
55
+ anchor: 0.5,
56
+ size: { width: canvasWidth * 0.7, basis: "bbox", min: 48, max: 320 },
57
+ });
58
+
59
+ console.log(short.metrics.size, long.metrics.size);
60
+ ```
61
+
42
62
  ### 2. 문단을 canvas에 그리고, 필요하면 geometry로 바꾸기
43
63
 
44
64
  ```js
@@ -82,12 +102,20 @@ const regions = paragraph.toRegions({ step: 6, openWidth: 1 });
82
102
 
83
103
  주요 옵션:
84
104
 
85
- - `x`, `y`: baseline 시작 위치
86
- - `size`: 글자 크기
105
+ - `x`, `y`: 기본값은 baseline 시작 위치
106
+ - `anchor`: 주면 `x`, `y`를 실제 글자 bbox 기준점으로 사용
107
+ - `size`: 글자 크기. `number` 또는 `{ width, basis?, min?, max? }`
87
108
  - `flatten`: 곡선 평탄화 허용 오차
88
109
  - `kerning`, `letterSpacing`, `tracking`
89
110
  - `script`, `language`, `features`
90
111
 
112
+ `size` 객체를 주면 내부에서 최종 숫자 size를 계산한 뒤 기존 텍스트 생성 로직으로 이어집니다.
113
+
114
+ - `width`: 목표 폭
115
+ - `basis`: `"bbox"` 또는 `"advance"`, 기본값은 `"bbox"`
116
+ - `min`: 최소 size, 기본값은 `0`
117
+ - `max`: 최대 size, 기본값은 `Infinity`
118
+
91
119
  ### `font.glyph(value, options?)`
92
120
 
93
121
  한 글자만 `PAShape`로 만듭니다.
@@ -95,10 +123,19 @@ const regions = paragraph.toRegions({ step: 6, openWidth: 1 });
95
123
  ### `font.metrics(value, options?)`
96
124
 
97
125
  텍스트 폭과 bounding box만 빠르게 계산합니다.
126
+ `anchor`를 주면 반환되는 `bbox`도 같은 기준점으로 이동된 값입니다.
98
127
 
99
128
  ```js
100
129
  const metrics = font.metrics("안녕", { size: 120 });
101
- console.log(metrics.width, metrics.bbox);
130
+ console.log(metrics.size, metrics.width, metrics.bbox);
131
+ ```
132
+
133
+ ```js
134
+ const metrics = font.metrics("왕밤빵", {
135
+ size: { width: 600, basis: "advance", min: 80, max: 240 },
136
+ });
137
+
138
+ console.log(metrics.size, metrics.width, metrics.bbox);
102
139
  ```
103
140
 
104
141
  ### `font.paragraph(value, options?)`
@@ -113,6 +150,8 @@ console.log(metrics.width, metrics.bbox);
113
150
  - `height`: `overflow: "hidden"`과 같이 쓰면 clip/clamp
114
151
  - `x`, `y`, `size`, `lineHeight`, `gap`, `anchor`, `align`
115
152
 
153
+ `font.paragraph()`의 `size`는 현재도 숫자만 받습니다.
154
+
116
155
  세부 제어 옵션:
117
156
 
118
157
  - `whiteSpace`: `normal | pre-wrap | nowrap`
@@ -135,7 +174,7 @@ console.log(metrics.width, metrics.bbox);
135
174
  빈 줄 하나 이상은 새 문단으로 처리됩니다.
136
175
 
137
176
  - `gap`: 문단 사이 간격. `lineHeight` 배수이며 기본값은 `0.5`
138
- - `anchor`: 기준점을 옮깁니다. `anchor: 0.5`면 CSS의 `translate(-50%, -50%)`처럼 동작
177
+ - `anchor`: 실제 렌더된 글자 bbox 기준으로 기준점을 옮깁니다. `anchor: 0.5`면 CSS의 `translate(-50%, -50%)`처럼 동작
139
178
  - `whiteSpace: "normal"`이면 문단 안 단일 줄바꿈은 공백으로 정리
140
179
  - `whiteSpace: "pre-wrap"`이면 문단 안 단일 줄바꿈 유지
141
180
 
package/dist/paFont.cjs CHANGED
@@ -276,10 +276,12 @@ function mod(value, divisor) {
276
276
  }
277
277
  //#endregion
278
278
  //#region src/paFont/core.js
279
+ var DEFAULT_TEXT_SIZE = 72;
280
+ var DEFAULT_TEXT_SIZE_BASIS = "bbox";
279
281
  function layoutGlyphs(font, value, opts) {
280
282
  const glyphs = [];
281
283
  const renderOptions = toRenderOptions(opts);
282
- return {
284
+ return applyAnchorToGlyphLayout(font, value, {
283
285
  text: value,
284
286
  glyphs,
285
287
  metrics: {
@@ -296,19 +298,45 @@ function layoutGlyphs(font, value, opts) {
296
298
  y: opts.y,
297
299
  size: opts.size
298
300
  }
301
+ }, opts);
302
+ }
303
+ function applyAnchorToGlyphLayout(font, value, layout, opts) {
304
+ if (opts.anchor == null || layout.glyphs.length === 0) return layout;
305
+ const { bbox } = measureText(font, value, {
306
+ ...opts,
307
+ anchor: null
308
+ });
309
+ const tx = opts.x - (bbox.x + bbox.w * opts.anchor.x);
310
+ const ty = opts.y - (bbox.y + bbox.h * opts.anchor.y);
311
+ if (tx === 0 && ty === 0) return layout;
312
+ return {
313
+ text: value,
314
+ glyphs: layout.glyphs.map((glyph) => ({
315
+ ...glyph,
316
+ x: glyph.x + tx,
317
+ y: glyph.y + ty
318
+ })),
319
+ metrics: {
320
+ ...layout.metrics,
321
+ x: layout.metrics.x + tx,
322
+ y: layout.metrics.y + ty
323
+ }
299
324
  };
300
325
  }
301
326
  function measureText(font, value, opts) {
302
327
  const renderOptions = toRenderOptions(opts);
303
- const box = font.getPath(value, opts.x, opts.y, opts.size, renderOptions).getBoundingBox();
328
+ const bbox = resolveMeasuredBBox(font.getPath(value, opts.x, opts.y, opts.size, renderOptions).getBoundingBox(), opts.x, opts.y);
329
+ if (opts.anchor == null) return {
330
+ size: opts.size,
331
+ width: measureAdvanceWidth(font, value, opts),
332
+ bbox
333
+ };
334
+ const tx = opts.x - (bbox.x + bbox.w * opts.anchor.x);
335
+ const ty = opts.y - (bbox.y + bbox.h * opts.anchor.y);
304
336
  return {
337
+ size: opts.size,
305
338
  width: measureAdvanceWidth(font, value, opts),
306
- bbox: {
307
- x: box.x1,
308
- y: box.y1,
309
- w: box.x2 - box.x1,
310
- h: box.y2 - box.y1
311
- }
339
+ bbox: translateRect(bbox, tx, ty)
312
340
  };
313
341
  }
314
342
  function measureAdvanceWidth(font, value, opts) {
@@ -524,8 +552,31 @@ function translateGlyphGeometry(geometry, tx, ty, glyphPosition) {
524
552
  bbox: translateRect(geometry.bbox, tx, ty)
525
553
  };
526
554
  }
555
+ function resolveTextOptions(font, value, options = {}, config = {}) {
556
+ const sourceOptions = options ?? {};
557
+ return normalizeTextOptions({
558
+ ...sourceOptions,
559
+ size: resolveTextSize(font, value, sourceOptions, config)
560
+ });
561
+ }
562
+ function resolveTextSize(font, value, options = {}, config = {}) {
563
+ const sourceOptions = options ?? {};
564
+ const sizeOption = sourceOptions.size;
565
+ if (!isTextSizeConstraint(sizeOption)) return normalizePositive(sizeOption, DEFAULT_TEXT_SIZE);
566
+ const fit = normalizeTextSizeConstraint(sizeOption, config.methodName);
567
+ const referenceSize = DEFAULT_TEXT_SIZE;
568
+ const measurement = measureText(font, String(value ?? ""), normalizeTextOptions({
569
+ ...sourceOptions,
570
+ anchor: null,
571
+ size: referenceSize
572
+ }));
573
+ const measuredWidth = fit.basis === "advance" ? measurement.width : measurement.bbox.w;
574
+ if (!Number.isFinite(measuredWidth) || measuredWidth <= 0) return clampTextSize(referenceSize, fit.min, fit.max);
575
+ return clampTextSize(referenceSize * fit.width / measuredWidth, fit.min, fit.max);
576
+ }
527
577
  function normalizeTextOptions(options = {}) {
528
- const size = normalizePositive(options.size, 72);
578
+ const size = normalizePositive(options.size, DEFAULT_TEXT_SIZE);
579
+ const anchor = normalizeAnchor(options.anchor);
529
580
  return {
530
581
  x: normalizeNumber(options.x, 0),
531
582
  y: normalizeNumber(options.y, 0),
@@ -535,14 +586,85 @@ function normalizeTextOptions(options = {}) {
535
586
  kerning: options.kerning !== false,
536
587
  letterSpacing: options.letterSpacing == null ? void 0 : normalizeNumber(options.letterSpacing, 0),
537
588
  tracking: options.tracking == null ? void 0 : normalizeNumber(options.tracking, 0),
589
+ anchor,
538
590
  script: options.script,
539
591
  language: options.language,
540
592
  features: options.features
541
593
  };
542
594
  }
595
+ function normalizeAnchor(value) {
596
+ if (value == null) return null;
597
+ if (Number.isFinite(value)) {
598
+ const next = Number(value);
599
+ return {
600
+ x: next,
601
+ y: next
602
+ };
603
+ }
604
+ if (Array.isArray(value)) {
605
+ if (value.length === 1 && Number.isFinite(value[0])) {
606
+ const next = Number(value[0]);
607
+ return {
608
+ x: next,
609
+ y: next
610
+ };
611
+ }
612
+ if (value.length >= 2 && Number.isFinite(value[0]) && Number.isFinite(value[1])) return {
613
+ x: Number(value[0]),
614
+ y: Number(value[1])
615
+ };
616
+ return null;
617
+ }
618
+ if (typeof value === "object") {
619
+ const hasX = Number.isFinite(value.x);
620
+ const hasY = Number.isFinite(value.y);
621
+ if (!hasX && !hasY) return null;
622
+ return {
623
+ x: hasX ? Number(value.x) : 0,
624
+ y: hasY ? Number(value.y) : 0
625
+ };
626
+ }
627
+ return null;
628
+ }
629
+ function resolveMeasuredBBox(box, fallbackX, fallbackY) {
630
+ if (Number.isFinite(box?.x1) && Number.isFinite(box?.y1) && Number.isFinite(box?.x2) && Number.isFinite(box?.y2)) return {
631
+ x: box.x1,
632
+ y: box.y1,
633
+ w: box.x2 - box.x1,
634
+ h: box.y2 - box.y1
635
+ };
636
+ return {
637
+ x: normalizeNumber(fallbackX, 0),
638
+ y: normalizeNumber(fallbackY, 0),
639
+ w: 0,
640
+ h: 0
641
+ };
642
+ }
543
643
  function defaultEdgeEpsilon(size) {
544
644
  return Math.min(1, Math.max(.01, size * .0025));
545
645
  }
646
+ function isTextSizeConstraint(value) {
647
+ return value != null && typeof value === "object" && !Array.isArray(value);
648
+ }
649
+ function normalizeTextSizeConstraint(value, methodName) {
650
+ const width = normalizeNonNegativeNumber(value.width, NaN);
651
+ if (!Number.isFinite(width)) throw new TypeError(`${methodName ?? "Text"} option "size.width" must be a non-negative number.`);
652
+ const min = normalizeNonNegativeNumber(value.min, 0);
653
+ const maxValue = value.max === Infinity ? Infinity : normalizeNonNegativeNumber(value.max, Number.POSITIVE_INFINITY);
654
+ const max = Number.isFinite(maxValue) ? Math.max(min, maxValue) : Infinity;
655
+ return {
656
+ width,
657
+ basis: value.basis === "advance" ? "advance" : DEFAULT_TEXT_SIZE_BASIS,
658
+ min,
659
+ max
660
+ };
661
+ }
662
+ function normalizeNonNegativeNumber(value, fallback) {
663
+ return Number.isFinite(value) && value >= 0 ? Number(value) : fallback;
664
+ }
665
+ function clampTextSize(value, min, max) {
666
+ return Math.min(max, Math.max(min, normalizeNonNegativeNumber(value, min)));
667
+ }
546
668
  function toRenderOptions(opts) {
547
669
  const renderOptions = { kerning: opts.kerning };
548
670
  if (opts.letterSpacing != null) renderOptions.letterSpacing = opts.letterSpacing;
@@ -3318,7 +3440,7 @@ function layoutParagraph(fontInstance, text, options = {}, state = {}) {
3318
3440
  const layoutState = pretextState != null && canUsePretextLayout(pretextState, normalized) ? pretextState : layoutParagraphsWithNative(fontInstance, paragraphs, normalized, layoutBox);
3319
3441
  const measureWidth = createLazyTextMeasurer(fontInstance, normalized);
3320
3442
  const lines = positionLines(fontInstance, applyOverflowClamping(layoutState.lines, normalized, layoutBox, measureWidth), normalized, layoutBox, measureWidth);
3321
- const textBBox = combineRects(lines.map((line) => line.bbox)) ?? emptyRect();
3443
+ const textBBox = normalized.anchor == null ? combineRects(lines.map((line) => line.bbox)) ?? emptyRect() : measurePositionedTextBBox(fontInstance, lines, normalized);
3322
3444
  const textWidth = lines.reduce((max, line) => Math.max(max, line.width), 0);
3323
3445
  const textHeight = resolvePositionedTextHeight(lines, layoutBox.contentY);
3324
3446
  const anchoredLayout = applyParagraphAnchor(lines, textBBox, finalizeLayoutBox(layoutBox, normalized, textHeight), normalized.anchor);
@@ -3857,6 +3979,35 @@ function translateLayoutBox(layoutBox, tx, ty) {
3857
3979
  clipBox: translateRect(layoutBox.clipBox, tx, ty)
3858
3980
  };
3859
3981
  }
3982
+ function measurePositionedTextBBox(fontInstance, lines, options) {
3983
+ const visibleBoxes = [];
3984
+ const measureOptions = {
3985
+ x: 0,
3986
+ y: 0,
3987
+ size: options.size,
3988
+ flatten: options.flatten,
3989
+ edgeEpsilon: options.edgeEpsilon,
3990
+ kerning: options.kerning,
3991
+ letterSpacing: options.letterSpacing,
3992
+ tracking: options.tracking,
3993
+ anchor: null,
3994
+ script: options.script,
3995
+ language: options.language,
3996
+ features: options.features
3997
+ };
3998
+ lines.forEach((line) => {
3999
+ line.fragments.forEach((fragment) => {
4000
+ if (fragment.text.length === 0) return;
4001
+ const { bbox } = measureText(fontInstance.font, fragment.text, {
4002
+ ...measureOptions,
4003
+ x: fragment.x,
4004
+ y: line.baseline
4005
+ });
4006
+ if (bbox.w > 0 || bbox.h > 0) visibleBoxes.push(bbox);
4007
+ });
4008
+ });
4009
+ return combineRects(visibleBoxes) ?? combineRects(lines.map((line) => line.bbox)) ?? emptyRect();
4010
+ }
3860
4011
  function resolveContainerDimension(explicit, fallback) {
3861
4012
  if (Number.isFinite(explicit) && explicit > 0) return explicit;
3862
4013
  if (Number.isFinite(fallback) && fallback > 0) return fallback;
@@ -3885,40 +4036,6 @@ function normalizeDimension(value) {
3885
4036
  function normalizeGap(value) {
3886
4037
  return Number.isFinite(value) && value >= 0 ? Number(value) : null;
3887
4038
  }
3888
- function normalizeAnchor(value) {
3889
- if (value == null) return null;
3890
- if (Number.isFinite(value)) {
3891
- const next = Number(value);
3892
- return {
3893
- x: next,
3894
- y: next
3895
- };
3896
- }
3897
- if (Array.isArray(value)) {
3898
- if (value.length === 1 && Number.isFinite(value[0])) {
3899
- const next = Number(value[0]);
3900
- return {
3901
- x: next,
3902
- y: next
3903
- };
3904
- }
3905
- if (value.length >= 2 && Number.isFinite(value[0]) && Number.isFinite(value[1])) return {
3906
- x: Number(value[0]),
3907
- y: Number(value[1])
3908
- };
3909
- return null;
3910
- }
3911
- if (typeof value === "object") {
3912
- const hasX = Number.isFinite(value.x);
3913
- const hasY = Number.isFinite(value.y);
3914
- if (!hasX && !hasY) return null;
3915
- return {
3916
- x: hasX ? Number(value.x) : 0,
3917
- y: hasY ? Number(value.y) : 0
3918
- };
3919
- }
3920
- return null;
3921
- }
3922
4039
  function normalizeSpacing(value) {
3923
4040
  if (value == null) return zeroSpacing();
3924
4041
  if (Number.isFinite(value)) {
@@ -4528,14 +4645,20 @@ var paFont = class paFont {
4528
4645
  return new paFont(await (0, opentype_js.load)(target, void 0, loadOptions));
4529
4646
  }
4530
4647
  text(value, options = {}) {
4531
- const opts = normalizeTextOptions(options);
4532
- return createTextShape(this._layoutText(String(value ?? ""), opts), opts, this);
4648
+ const text = String(value ?? "");
4649
+ const sourceOptions = options ?? {};
4650
+ const opts = resolveTextOptions(this.font, text, sourceOptions, { methodName: "font.text()" });
4651
+ assertValidAnchorOption("font.text()", sourceOptions, opts.anchor);
4652
+ return createTextShape(this._layoutText(text, opts), opts, this);
4533
4653
  }
4534
4654
  glyph(value, options = {}) {
4535
- const opts = normalizeTextOptions(options);
4536
- const glyph = this.font.charToGlyph(String(value ?? ""));
4537
- return createTextShape({
4538
- text: String(value ?? ""),
4655
+ const text = Array.from(String(value ?? ""))[0] ?? "";
4656
+ const sourceOptions = options ?? {};
4657
+ const opts = resolveTextOptions(this.font, text, sourceOptions, { methodName: "font.glyph()" });
4658
+ assertValidAnchorOption("font.glyph()", sourceOptions, opts.anchor);
4659
+ const glyph = this.font.charToGlyph(text);
4660
+ const layout = {
4661
+ text,
4539
4662
  glyphs: [{
4540
4663
  glyph,
4541
4664
  x: opts.x,
@@ -4549,11 +4672,15 @@ var paFont = class paFont {
4549
4672
  y: opts.y,
4550
4673
  size: opts.size
4551
4674
  }
4552
- }, opts, this);
4675
+ };
4676
+ return createTextShape(anchorSingleGlyphLayout(this.font, text, layout, opts), opts, this);
4553
4677
  }
4554
4678
  metrics(value, options = {}) {
4555
- const opts = normalizeTextOptions(options);
4556
- return measureText(this.font, String(value ?? ""), opts);
4679
+ const text = String(value ?? "");
4680
+ const sourceOptions = options ?? {};
4681
+ const opts = resolveTextOptions(this.font, text, sourceOptions, { methodName: "font.metrics()" });
4682
+ assertValidAnchorOption("font.metrics()", sourceOptions, opts.anchor);
4683
+ return measureText(this.font, text, opts);
4557
4684
  }
4558
4685
  paragraph(value, options = {}) {
4559
4686
  return createParagraph(this, String(value ?? ""), options);
@@ -4607,6 +4734,32 @@ function normalizeShapeVariantValue(value) {
4607
4734
  function toShapeVariantKey(value) {
4608
4735
  return normalizeShapeVariantValue(value).toFixed(6);
4609
4736
  }
4737
+ function assertValidAnchorOption(methodName, sourceOptions, anchor) {
4738
+ if (sourceOptions?.anchor != null && anchor == null) throw new TypeError(`${methodName} option "anchor" must be a number, [x, y], or { x, y }.`);
4739
+ }
4740
+ function anchorSingleGlyphLayout(font, text, layout, opts) {
4741
+ if (opts.anchor == null || text.length === 0) return layout;
4742
+ const { bbox } = measureText(font, text, {
4743
+ ...opts,
4744
+ anchor: null
4745
+ });
4746
+ const tx = opts.x - (bbox.x + bbox.w * opts.anchor.x);
4747
+ const ty = opts.y - (bbox.y + bbox.h * opts.anchor.y);
4748
+ if (tx === 0 && ty === 0) return layout;
4749
+ return {
4750
+ ...layout,
4751
+ glyphs: layout.glyphs.map((glyph) => ({
4752
+ ...glyph,
4753
+ x: glyph.x + tx,
4754
+ y: glyph.y + ty
4755
+ })),
4756
+ metrics: {
4757
+ ...layout.metrics,
4758
+ x: layout.metrics.x + tx,
4759
+ y: layout.metrics.y + ty
4760
+ }
4761
+ };
4762
+ }
4610
4763
  async function fetchFontBytes(source) {
4611
4764
  const response = await fetch(source);
4612
4765
  if (!response.ok) throw new Error(`Failed to load font from ${source}: ${response.status} ${response.statusText}`);