pa_font 0.3.4 → 0.3.5

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
@@ -82,7 +82,8 @@ const regions = paragraph.toRegions({ step: 6, openWidth: 1 });
82
82
 
83
83
  주요 옵션:
84
84
 
85
- - `x`, `y`: baseline 시작 위치
85
+ - `x`, `y`: 기본값은 baseline 시작 위치
86
+ - `anchor`: 주면 `x`, `y`를 실제 글자 bbox 기준점으로 사용
86
87
  - `size`: 글자 크기
87
88
  - `flatten`: 곡선 평탄화 허용 오차
88
89
  - `kerning`, `letterSpacing`, `tracking`
@@ -95,6 +96,7 @@ const regions = paragraph.toRegions({ step: 6, openWidth: 1 });
95
96
  ### `font.metrics(value, options?)`
96
97
 
97
98
  텍스트 폭과 bounding box만 빠르게 계산합니다.
99
+ `anchor`를 주면 반환되는 `bbox`도 같은 기준점으로 이동된 값입니다.
98
100
 
99
101
  ```js
100
102
  const metrics = font.metrics("안녕", { size: 120 });
@@ -135,7 +137,7 @@ console.log(metrics.width, metrics.bbox);
135
137
  빈 줄 하나 이상은 새 문단으로 처리됩니다.
136
138
 
137
139
  - `gap`: 문단 사이 간격. `lineHeight` 배수이며 기본값은 `0.5`
138
- - `anchor`: 기준점을 옮깁니다. `anchor: 0.5`면 CSS의 `translate(-50%, -50%)`처럼 동작
140
+ - `anchor`: 실제 렌더된 글자 bbox 기준으로 기준점을 옮깁니다. `anchor: 0.5`면 CSS의 `translate(-50%, -50%)`처럼 동작
139
141
  - `whiteSpace: "normal"`이면 문단 안 단일 줄바꿈은 공백으로 정리
140
142
  - `whiteSpace: "pre-wrap"`이면 문단 안 단일 줄바꿈 유지
141
143
 
package/dist/paFont.cjs CHANGED
@@ -279,7 +279,7 @@ function mod(value, divisor) {
279
279
  function layoutGlyphs(font, value, opts) {
280
280
  const glyphs = [];
281
281
  const renderOptions = toRenderOptions(opts);
282
- return {
282
+ return applyAnchorToGlyphLayout(font, value, {
283
283
  text: value,
284
284
  glyphs,
285
285
  metrics: {
@@ -296,19 +296,43 @@ function layoutGlyphs(font, value, opts) {
296
296
  y: opts.y,
297
297
  size: opts.size
298
298
  }
299
+ }, opts);
300
+ }
301
+ function applyAnchorToGlyphLayout(font, value, layout, opts) {
302
+ if (opts.anchor == null || layout.glyphs.length === 0) return layout;
303
+ const { bbox } = measureText(font, value, {
304
+ ...opts,
305
+ anchor: null
306
+ });
307
+ const tx = opts.x - (bbox.x + bbox.w * opts.anchor.x);
308
+ const ty = opts.y - (bbox.y + bbox.h * opts.anchor.y);
309
+ if (tx === 0 && ty === 0) return layout;
310
+ return {
311
+ text: value,
312
+ glyphs: layout.glyphs.map((glyph) => ({
313
+ ...glyph,
314
+ x: glyph.x + tx,
315
+ y: glyph.y + ty
316
+ })),
317
+ metrics: {
318
+ ...layout.metrics,
319
+ x: layout.metrics.x + tx,
320
+ y: layout.metrics.y + ty
321
+ }
299
322
  };
300
323
  }
301
324
  function measureText(font, value, opts) {
302
325
  const renderOptions = toRenderOptions(opts);
303
- const box = font.getPath(value, opts.x, opts.y, opts.size, renderOptions).getBoundingBox();
326
+ const bbox = resolveMeasuredBBox(font.getPath(value, opts.x, opts.y, opts.size, renderOptions).getBoundingBox(), opts.x, opts.y);
327
+ if (opts.anchor == null) return {
328
+ width: measureAdvanceWidth(font, value, opts),
329
+ bbox
330
+ };
331
+ const tx = opts.x - (bbox.x + bbox.w * opts.anchor.x);
332
+ const ty = opts.y - (bbox.y + bbox.h * opts.anchor.y);
304
333
  return {
305
334
  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
- }
335
+ bbox: translateRect(bbox, tx, ty)
312
336
  };
313
337
  }
314
338
  function measureAdvanceWidth(font, value, opts) {
@@ -526,6 +550,7 @@ function translateGlyphGeometry(geometry, tx, ty, glyphPosition) {
526
550
  }
527
551
  function normalizeTextOptions(options = {}) {
528
552
  const size = normalizePositive(options.size, 72);
553
+ const anchor = normalizeAnchor(options.anchor);
529
554
  return {
530
555
  x: normalizeNumber(options.x, 0),
531
556
  y: normalizeNumber(options.y, 0),
@@ -535,11 +560,60 @@ function normalizeTextOptions(options = {}) {
535
560
  kerning: options.kerning !== false,
536
561
  letterSpacing: options.letterSpacing == null ? void 0 : normalizeNumber(options.letterSpacing, 0),
537
562
  tracking: options.tracking == null ? void 0 : normalizeNumber(options.tracking, 0),
563
+ anchor,
538
564
  script: options.script,
539
565
  language: options.language,
540
566
  features: options.features
541
567
  };
542
568
  }
569
+ function normalizeAnchor(value) {
570
+ if (value == null) return null;
571
+ if (Number.isFinite(value)) {
572
+ const next = Number(value);
573
+ return {
574
+ x: next,
575
+ y: next
576
+ };
577
+ }
578
+ if (Array.isArray(value)) {
579
+ if (value.length === 1 && Number.isFinite(value[0])) {
580
+ const next = Number(value[0]);
581
+ return {
582
+ x: next,
583
+ y: next
584
+ };
585
+ }
586
+ if (value.length >= 2 && Number.isFinite(value[0]) && Number.isFinite(value[1])) return {
587
+ x: Number(value[0]),
588
+ y: Number(value[1])
589
+ };
590
+ return null;
591
+ }
592
+ if (typeof value === "object") {
593
+ const hasX = Number.isFinite(value.x);
594
+ const hasY = Number.isFinite(value.y);
595
+ if (!hasX && !hasY) return null;
596
+ return {
597
+ x: hasX ? Number(value.x) : 0,
598
+ y: hasY ? Number(value.y) : 0
599
+ };
600
+ }
601
+ return null;
602
+ }
603
+ function resolveMeasuredBBox(box, fallbackX, fallbackY) {
604
+ if (Number.isFinite(box?.x1) && Number.isFinite(box?.y1) && Number.isFinite(box?.x2) && Number.isFinite(box?.y2)) return {
605
+ x: box.x1,
606
+ y: box.y1,
607
+ w: box.x2 - box.x1,
608
+ h: box.y2 - box.y1
609
+ };
610
+ return {
611
+ x: normalizeNumber(fallbackX, 0),
612
+ y: normalizeNumber(fallbackY, 0),
613
+ w: 0,
614
+ h: 0
615
+ };
616
+ }
543
617
  function defaultEdgeEpsilon(size) {
544
618
  return Math.min(1, Math.max(.01, size * .0025));
545
619
  }
@@ -3318,7 +3392,7 @@ function layoutParagraph(fontInstance, text, options = {}, state = {}) {
3318
3392
  const layoutState = pretextState != null && canUsePretextLayout(pretextState, normalized) ? pretextState : layoutParagraphsWithNative(fontInstance, paragraphs, normalized, layoutBox);
3319
3393
  const measureWidth = createLazyTextMeasurer(fontInstance, normalized);
3320
3394
  const lines = positionLines(fontInstance, applyOverflowClamping(layoutState.lines, normalized, layoutBox, measureWidth), normalized, layoutBox, measureWidth);
3321
- const textBBox = combineRects(lines.map((line) => line.bbox)) ?? emptyRect();
3395
+ const textBBox = normalized.anchor == null ? combineRects(lines.map((line) => line.bbox)) ?? emptyRect() : measurePositionedTextBBox(fontInstance, lines, normalized);
3322
3396
  const textWidth = lines.reduce((max, line) => Math.max(max, line.width), 0);
3323
3397
  const textHeight = resolvePositionedTextHeight(lines, layoutBox.contentY);
3324
3398
  const anchoredLayout = applyParagraphAnchor(lines, textBBox, finalizeLayoutBox(layoutBox, normalized, textHeight), normalized.anchor);
@@ -3857,6 +3931,35 @@ function translateLayoutBox(layoutBox, tx, ty) {
3857
3931
  clipBox: translateRect(layoutBox.clipBox, tx, ty)
3858
3932
  };
3859
3933
  }
3934
+ function measurePositionedTextBBox(fontInstance, lines, options) {
3935
+ const visibleBoxes = [];
3936
+ const measureOptions = {
3937
+ x: 0,
3938
+ y: 0,
3939
+ size: options.size,
3940
+ flatten: options.flatten,
3941
+ edgeEpsilon: options.edgeEpsilon,
3942
+ kerning: options.kerning,
3943
+ letterSpacing: options.letterSpacing,
3944
+ tracking: options.tracking,
3945
+ anchor: null,
3946
+ script: options.script,
3947
+ language: options.language,
3948
+ features: options.features
3949
+ };
3950
+ lines.forEach((line) => {
3951
+ line.fragments.forEach((fragment) => {
3952
+ if (fragment.text.length === 0) return;
3953
+ const { bbox } = measureText(fontInstance.font, fragment.text, {
3954
+ ...measureOptions,
3955
+ x: fragment.x,
3956
+ y: line.baseline
3957
+ });
3958
+ if (bbox.w > 0 || bbox.h > 0) visibleBoxes.push(bbox);
3959
+ });
3960
+ });
3961
+ return combineRects(visibleBoxes) ?? combineRects(lines.map((line) => line.bbox)) ?? emptyRect();
3962
+ }
3860
3963
  function resolveContainerDimension(explicit, fallback) {
3861
3964
  if (Number.isFinite(explicit) && explicit > 0) return explicit;
3862
3965
  if (Number.isFinite(fallback) && fallback > 0) return fallback;
@@ -3885,40 +3988,6 @@ function normalizeDimension(value) {
3885
3988
  function normalizeGap(value) {
3886
3989
  return Number.isFinite(value) && value >= 0 ? Number(value) : null;
3887
3990
  }
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
3991
  function normalizeSpacing(value) {
3923
3992
  if (value == null) return zeroSpacing();
3924
3993
  if (Number.isFinite(value)) {
@@ -4529,13 +4598,16 @@ var paFont = class paFont {
4529
4598
  }
4530
4599
  text(value, options = {}) {
4531
4600
  const opts = normalizeTextOptions(options);
4601
+ assertValidAnchorOption("font.text()", options, opts.anchor);
4532
4602
  return createTextShape(this._layoutText(String(value ?? ""), opts), opts, this);
4533
4603
  }
4534
4604
  glyph(value, options = {}) {
4535
4605
  const opts = normalizeTextOptions(options);
4536
- const glyph = this.font.charToGlyph(String(value ?? ""));
4537
- return createTextShape({
4538
- text: String(value ?? ""),
4606
+ assertValidAnchorOption("font.glyph()", options, opts.anchor);
4607
+ const text = Array.from(String(value ?? ""))[0] ?? "";
4608
+ const glyph = this.font.charToGlyph(text);
4609
+ const layout = {
4610
+ text,
4539
4611
  glyphs: [{
4540
4612
  glyph,
4541
4613
  x: opts.x,
@@ -4549,10 +4621,12 @@ var paFont = class paFont {
4549
4621
  y: opts.y,
4550
4622
  size: opts.size
4551
4623
  }
4552
- }, opts, this);
4624
+ };
4625
+ return createTextShape(anchorSingleGlyphLayout(this.font, text, layout, opts), opts, this);
4553
4626
  }
4554
4627
  metrics(value, options = {}) {
4555
4628
  const opts = normalizeTextOptions(options);
4629
+ assertValidAnchorOption("font.metrics()", options, opts.anchor);
4556
4630
  return measureText(this.font, String(value ?? ""), opts);
4557
4631
  }
4558
4632
  paragraph(value, options = {}) {
@@ -4607,6 +4681,32 @@ function normalizeShapeVariantValue(value) {
4607
4681
  function toShapeVariantKey(value) {
4608
4682
  return normalizeShapeVariantValue(value).toFixed(6);
4609
4683
  }
4684
+ function assertValidAnchorOption(methodName, sourceOptions, anchor) {
4685
+ if (sourceOptions?.anchor != null && anchor == null) throw new TypeError(`${methodName} option "anchor" must be a number, [x, y], or { x, y }.`);
4686
+ }
4687
+ function anchorSingleGlyphLayout(font, text, layout, opts) {
4688
+ if (opts.anchor == null || text.length === 0) return layout;
4689
+ const { bbox } = measureText(font, text, {
4690
+ ...opts,
4691
+ anchor: null
4692
+ });
4693
+ const tx = opts.x - (bbox.x + bbox.w * opts.anchor.x);
4694
+ const ty = opts.y - (bbox.y + bbox.h * opts.anchor.y);
4695
+ if (tx === 0 && ty === 0) return layout;
4696
+ return {
4697
+ ...layout,
4698
+ glyphs: layout.glyphs.map((glyph) => ({
4699
+ ...glyph,
4700
+ x: glyph.x + tx,
4701
+ y: glyph.y + ty
4702
+ })),
4703
+ metrics: {
4704
+ ...layout.metrics,
4705
+ x: layout.metrics.x + tx,
4706
+ y: layout.metrics.y + ty
4707
+ }
4708
+ };
4709
+ }
4610
4710
  async function fetchFontBytes(source) {
4611
4711
  const response = await fetch(source);
4612
4712
  if (!response.ok) throw new Error(`Failed to load font from ${source}: ${response.status} ${response.statusText}`);