@weasel-js/text 1.7.0 → 1.7.2

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/index.js CHANGED
@@ -1,6 +1,7 @@
1
- import { resolveTextStyle, resolveAlign, cachedLayoutRuns, DECORATION_KINDS, decorationRule } from './chunk-SXEHVOGE.js';
2
- export { DEFAULT_TEXT_STYLE, LAYOUT_CACHE_STRUCTURAL_LIMIT, LAYOUT_CACHE_VARIANT_LIMIT, cachedLayoutRuns, fontString, layoutRuns, resolveAlign, resolveTextStyle } from './chunk-SXEHVOGE.js';
1
+ import { resolveTextStyle, resolveAlign, cachedLayoutRuns, graphemeCount, wrapLines, DECORATION_KINDS, decorationRule } from './chunk-JQPOFIGT.js';
2
+ export { BREAK_ALLOWED, BREAK_MANDATORY, DEFAULT_DECORATION_METRICS, DEFAULT_TEXT_STYLE, LAYOUT_CACHE_STRUCTURAL_LIMIT, LAYOUT_CACHE_VARIANT_LIMIT, NO_BREAK, cachedLayoutRuns, decorationMetrics, fontString, isHardLineBreak, layoutRuns, lineBreakOpportunities, resolveAlign, resolveTextStyle } from './chunk-JQPOFIGT.js';
3
3
  import { resolveScreenLength } from '@weasel-js/paint';
4
+ import { faceMetricsOf, resolveFontVariant, faceMetricsFor } from '@weasel-js/font';
4
5
 
5
6
  // src/runs.ts
6
7
  function toRuns(input) {
@@ -232,43 +233,119 @@ function transformRunTexts(texts, transforms) {
232
233
  }
233
234
  return out;
234
235
  }
236
+ var SMALL_CAPS_SCALE = 0.7;
237
+ function smallCapsScale(face) {
238
+ const x = face?.xHeight;
239
+ const cap = face?.capHeight;
240
+ if (x === void 0 || cap === void 0 || x >= cap) return SMALL_CAPS_SCALE;
241
+ return x / cap;
242
+ }
243
+ function smallCapsScaleFor(family, weight, style) {
244
+ return smallCapsScale(faceMetricsOf(resolveFontVariant(family, weight, style)));
245
+ }
246
+ function isSmallCapsLetter(ch) {
247
+ return ch.toUpperCase() !== ch;
248
+ }
249
+ function smallCapsText(text, srcMap) {
250
+ let out = "";
251
+ const small = [];
252
+ const starts = [];
253
+ const ends = [];
254
+ let resized = false;
255
+ let anySmall = false;
256
+ let at = 0;
257
+ for (const ch of text) {
258
+ const shrink = isSmallCapsLetter(ch);
259
+ const piece = shrink ? ch.toUpperCase() : ch;
260
+ if (piece.length !== ch.length) resized = true;
261
+ anySmall ||= shrink;
262
+ const start = srcMap ? srcMap.starts[at] : at;
263
+ const end = srcMap ? srcMap.ends[at] : at + ch.length;
264
+ for (let k = 0; k < piece.length; k++) {
265
+ small.push(shrink);
266
+ starts.push(start);
267
+ ends.push(end);
268
+ }
269
+ out += piece;
270
+ at += ch.length;
271
+ }
272
+ if (!anySmall) return srcMap ? { text, srcMap } : { text };
273
+ const map = resized ? { length: srcMap?.length ?? text.length, starts, ends } : srcMap;
274
+ return { text: out, ...map ? { srcMap: map } : {}, small };
275
+ }
235
276
 
236
277
  // src/runs/resolveRuns.ts
237
278
  var SCRIPT_METRICS = Object.freeze({
238
279
  super: Object.freeze({ size: 0.583, shift: 0.333 }),
239
280
  sub: Object.freeze({ size: 0.583, shift: -0.333 })
240
281
  });
282
+ var resolvedScripts = /* @__PURE__ */ new WeakMap();
283
+ function scriptMetrics(face) {
284
+ if (!face || !face.superscript && !face.subscript) return SCRIPT_METRICS;
285
+ let out = resolvedScripts.get(face);
286
+ if (!out) {
287
+ out = Object.freeze({
288
+ super: face.superscript ?? SCRIPT_METRICS.super,
289
+ sub: face.subscript ?? SCRIPT_METRICS.sub
290
+ });
291
+ resolvedScripts.set(face, out);
292
+ }
293
+ return out;
294
+ }
295
+ function scriptMetricsFor(family, weight, style) {
296
+ return scriptMetrics(faceMetricsOf(resolveFontVariant(family, weight, style)));
297
+ }
241
298
  function numericWeight(w) {
242
299
  if (typeof w === "number") return w;
243
- if (w === "bold") return 700;
300
+ if (w === "bold" || w === "bolder") return 700;
244
301
  if (w === "normal") return 400;
245
302
  const parsed = Number(w);
246
303
  return Number.isFinite(parsed) ? parsed : 400;
247
304
  }
305
+ function isBoldWeight(w) {
306
+ return numericWeight(w) >= 600;
307
+ }
308
+ function resolveRunFace(run, style) {
309
+ return {
310
+ fontFamily: run.fontFamily ?? style.fontFamily,
311
+ fontWeight: run.fontWeight ?? (run.bold ? 700 : numericWeight(style.fontWeight)),
312
+ fontStyle: run.italic ? "italic" : style.fontStyle
313
+ };
314
+ }
248
315
  function resolveRuns(runs, style, viewScale = 1) {
249
316
  const out = [];
250
- const baseWeight = numericWeight(style.fontWeight);
251
317
  const shown = transformRunTexts(
252
318
  runs.map((r) => r.text),
253
319
  runs.map((r) => r.textTransform ?? style.textTransform)
254
320
  );
255
321
  for (let i = 0; i < runs.length; i++) {
256
322
  const run = runs[i];
257
- const { text, srcMap } = shown[i];
323
+ let { text, srcMap } = shown[i];
258
324
  const scriptKey = run.script ?? style.script;
259
- const script = scriptKey ? SCRIPT_METRICS[scriptKey] : void 0;
325
+ const { fontFamily, fontWeight, fontStyle } = resolveRunFace(run, style);
326
+ const script = scriptKey ? scriptMetricsFor(fontFamily, fontWeight, fontStyle)[scriptKey] : void 0;
260
327
  const shiftEm = run.baselineShift ?? script?.shift ?? 0;
261
328
  const scale = run.fontScale ?? script?.size ?? 1;
262
329
  const relative = run.fontSize === void 0 && scale !== 1;
330
+ const fontSize = run.fontSize !== void 0 ? resolveScreenLength(run.fontSize, viewScale) : style.fontSize * scale;
331
+ let sizeMap;
332
+ if ((run.fontVariantCaps ?? style.fontVariantCaps) === "small-caps") {
333
+ const caps = smallCapsText(text, srcMap);
334
+ text = caps.text;
335
+ srcMap = caps.srcMap;
336
+ if (caps.small) {
337
+ const small = fontSize * smallCapsScaleFor(fontFamily, fontWeight, fontStyle);
338
+ sizeMap = caps.small.map((s) => s ? small : fontSize);
339
+ }
340
+ }
263
341
  out.push({
264
342
  text,
265
343
  ...srcMap ? { srcMap } : {},
266
- fontFamily: run.fontFamily ?? style.fontFamily,
267
- // An absolute size wins over a relative one; naming both is a consumer
268
- // saying "this size exactly", which a multiplier cannot improve on.
269
- fontSize: run.fontSize !== void 0 ? resolveScreenLength(run.fontSize, viewScale) : style.fontSize * scale,
270
- fontWeight: run.bold ? 700 : baseWeight,
271
- fontStyle: run.italic ? "italic" : style.fontStyle,
344
+ fontFamily,
345
+ fontSize,
346
+ ...sizeMap ? { sizeMap } : {},
347
+ fontWeight,
348
+ fontStyle,
272
349
  fill: run.fill ?? style.fill,
273
350
  // Unlike the decorations below, a run's stroke *replaces* the node's
274
351
  // rather than adding to it — there is only one outline to paint.
@@ -303,7 +380,8 @@ function textPoseLayoutInput(pose, scale = 1) {
303
380
  maxWidth: style.wrap ? pose.width : Infinity,
304
381
  alignWidth: pose.width,
305
382
  lineHeight: style.lineHeight,
306
- align: resolveAlign(style.align, style.direction)
383
+ align: resolveAlign(style.align, style.direction),
384
+ justify: style.align === "justify"
307
385
  }
308
386
  };
309
387
  }
@@ -319,46 +397,21 @@ function layoutTextPose(pose, scale = 1) {
319
397
 
320
398
  // src/measure/measureText.ts
321
399
  function measuredWidth(ctx, text, style) {
322
- return ctx.measureText(text).width + text.length * style.letterSpacing;
400
+ return ctx.measureText(text).width + (style.letterSpacing === 0 ? 0 : graphemeCount(text) * style.letterSpacing);
323
401
  }
324
402
  function measureText(ctx, text, maxWidth, style) {
325
- const lines = [];
326
- const lineStarts = [];
327
- const paragraphs = text.split("\n");
328
- let pos = 0;
329
- for (let pi = 0; pi < paragraphs.length; pi++) {
330
- if (pi > 0) pos += 1;
331
- const paragraph = paragraphs[pi];
332
- if (paragraph === "") {
333
- lines.push("");
334
- lineStarts.push(pos);
335
- continue;
336
- }
337
- const words = paragraph.split(/(\s+)/).filter((w) => w.length > 0);
338
- let current = "";
339
- let currentStart = pos;
340
- let consumed = 0;
341
- for (const word of words) {
342
- if (current === "") currentStart = pos + consumed;
343
- const candidate = current + word;
344
- if (current === "" || measuredWidth(ctx, candidate, style) <= maxWidth) {
345
- current = candidate;
346
- } else {
347
- lines.push(current.trimEnd());
348
- lineStarts.push(currentStart);
349
- if (/^\s+$/.test(word)) {
350
- current = "";
351
- } else {
352
- current = word;
353
- currentStart = pos + consumed;
354
- }
355
- }
356
- consumed += word.length;
357
- }
358
- lines.push(current.trimEnd());
359
- lineStarts.push(currentStart);
360
- pos += paragraph.length;
403
+ const cps = [];
404
+ const at = [];
405
+ let u = 0;
406
+ for (const ch of text) {
407
+ cps.push(ch.codePointAt(0));
408
+ at.push(u);
409
+ u += ch.length;
361
410
  }
411
+ at.push(u);
412
+ const wrapped = wrapLines(cps, maxWidth, (start, end) => measuredWidth(ctx, text.slice(at[start], at[end]), style));
413
+ const lines = wrapped.map((l) => text.slice(at[l.start], at[l.end]).replace(/ +$/, ""));
414
+ const lineStarts = wrapped.map((l) => at[l.start]);
362
415
  const height = lines.length * style.fontSize * style.lineHeight;
363
416
  return { lines, lineStarts, height };
364
417
  }
@@ -391,8 +444,8 @@ function textLineBoxes(pose, opts = {}) {
391
444
  }
392
445
  return out;
393
446
  }
394
- function runMetrics(run, fontSize) {
395
- const script = run.script ? SCRIPT_METRICS[run.script] : void 0;
447
+ function runMetrics(run, fontSize, face) {
448
+ const script = run.script ? scriptMetrics(face)[run.script] : void 0;
396
449
  const scale = run.fontScale ?? script?.size ?? 1;
397
450
  const shiftEm = run.baselineShift ?? script?.shift ?? 0;
398
451
  return {
@@ -402,68 +455,84 @@ function runMetrics(run, fontSize) {
402
455
  y: shiftEm === 0 ? 0 : -shiftEm * fontSize
403
456
  };
404
457
  }
405
- function layoutMarkdown(runs, maxWidth, fontSize, measure, lineHeightFactor = 1.3) {
458
+ function layoutMarkdown(runs, maxWidth, fontSize, measure, lineHeightFactor = 1.3, faceOf) {
406
459
  if (runs.length === 0) return { lines: [], width: 0, height: 0 };
407
460
  const shown = transformRunTexts(runs.map((r) => r.text), runs.map((r) => r.textTransform ?? "none"));
408
- runs = runs.map((r, i) => shown[i].text === r.text ? r : { ...r, text: shown[i].text });
409
- const lines = [];
410
- let currentRuns = [];
411
- let lineX = 0;
412
- let lineMaxSize = 0;
413
- function commitLine() {
414
- const effectiveFontSize = lineMaxSize > 0 ? lineMaxSize : fontSize;
415
- const lineHeight = effectiveFontSize * lineHeightFactor;
416
- lines.push({ runs: currentRuns, width: lineX, height: lineHeight });
417
- currentRuns = [];
418
- lineX = 0;
419
- lineMaxSize = 0;
420
- }
421
- function processSegment(segRun) {
422
- const { size: effectiveSize, y: runY } = runMetrics(segRun, fontSize);
423
- lineMaxSize = Math.max(lineMaxSize, effectiveSize);
424
- if (maxWidth === Infinity) {
425
- const w = measure(segRun.text, effectiveSize, segRun.bold ?? false, segRun.italic ?? false);
426
- currentRuns.push({ ...segRun, x: lineX, width: w, y: runY, size: effectiveSize });
427
- lineX += w;
461
+ const segs = [];
462
+ runs.forEach((r, i) => {
463
+ const text = shown[i].text;
464
+ if (r.fontVariantCaps !== "small-caps") {
465
+ segs.push(text === r.text ? r : { ...r, text });
428
466
  return;
429
467
  }
430
- const words = segRun.text.split(/ /);
431
- let wordBuf = "";
432
- for (let wi = 0; wi < words.length; wi++) {
433
- const word = words[wi];
434
- const candidate = wordBuf.length > 0 ? wordBuf + " " + word : word;
435
- const candidateW = measure(candidate, effectiveSize, segRun.bold ?? false, segRun.italic ?? false);
436
- if (lineX + candidateW > maxWidth && (lineX > 0 || wordBuf.length > 0)) {
437
- if (wordBuf.length > 0) {
438
- const w = measure(wordBuf, effectiveSize, segRun.bold ?? false, segRun.italic ?? false);
439
- currentRuns.push({ ...segRun, text: wordBuf, x: lineX, width: w, y: runY, size: effectiveSize });
440
- lineX += w;
441
- }
442
- commitLine();
443
- lineMaxSize = Math.max(lineMaxSize, effectiveSize);
444
- wordBuf = word;
468
+ const caps = smallCapsText(text);
469
+ if (!caps.small) {
470
+ segs.push({ ...r, text: caps.text });
471
+ return;
472
+ }
473
+ let at = 0;
474
+ for (const piece of caps.text) {
475
+ const small = caps.small[at];
476
+ at += piece.length;
477
+ const prev = segs[segs.length - 1];
478
+ if (prev?.source === r && prev.smallCaps === true === small) prev.text += piece;
479
+ else segs.push({ ...r, text: piece, source: r, ...small ? { smallCaps: true } : {} });
480
+ }
481
+ });
482
+ const cps = [];
483
+ const runOf = [];
484
+ const startOf = [];
485
+ const endOf = [];
486
+ segs.forEach((r, ri) => {
487
+ let at = 0;
488
+ for (const ch of r.text) {
489
+ cps.push(ch.codePointAt(0));
490
+ runOf.push(ri);
491
+ startOf.push(at);
492
+ at += ch.length;
493
+ endOf.push(at);
494
+ }
495
+ });
496
+ const placed = segs.map(({ smallCaps, source: _source, ...run }) => {
497
+ const face = faceOf?.(run.bold ?? false, run.italic ?? false);
498
+ const { size: runSize, y } = runMetrics(run, fontSize, face);
499
+ return { run, face, y, runSize, size: smallCaps ? runSize * smallCapsScale(face) : runSize };
500
+ });
501
+ const pieceWidth = (p) => {
502
+ const { run, size } = placed[p.run];
503
+ return measure(run.text.slice(p.start, p.end), size, run.bold ?? false, run.italic ?? false);
504
+ };
505
+ function piecesOf(from, to) {
506
+ const out = [];
507
+ for (let k = from; k < to; k++) {
508
+ const last = out[out.length - 1];
509
+ if (last && last.run === runOf[k] && last.end === startOf[k]) {
510
+ out[out.length - 1] = { ...last, end: endOf[k] };
445
511
  } else {
446
- wordBuf = candidate;
512
+ out.push({ run: runOf[k], start: startOf[k], end: endOf[k] });
447
513
  }
448
514
  }
449
- if (wordBuf.length > 0) {
450
- const w = measure(wordBuf, effectiveSize, segRun.bold ?? false, segRun.italic ?? false);
451
- currentRuns.push({ ...segRun, text: wordBuf, x: lineX, width: w, y: runY, size: effectiveSize });
452
- lineX += w;
453
- }
515
+ return out;
454
516
  }
455
- for (const run of runs) {
456
- const segments = run.text.split("\n");
457
- for (let si = 0; si < segments.length; si++) {
458
- if (si > 0) commitLine();
459
- const seg = segments[si];
460
- if (seg.length > 0) {
461
- processSegment({ ...run, text: seg });
462
- }
517
+ const lines = [];
518
+ function commitLine(line) {
519
+ let x = 0;
520
+ let maxSize = 0;
521
+ const positioned = [];
522
+ for (const p of line) {
523
+ const { run, face, size, y, runSize } = placed[p.run];
524
+ const width2 = pieceWidth(p);
525
+ positioned.push({ ...run, text: run.text.slice(p.start, p.end), x, width: width2, y, size, ...face ? { face } : {} });
526
+ x += width2;
527
+ maxSize = Math.max(maxSize, runSize);
463
528
  }
529
+ lines.push({ runs: positioned, width: x, height: (maxSize > 0 ? maxSize : fontSize) * lineHeightFactor });
464
530
  }
465
- if (currentRuns.length > 0) {
466
- commitLine();
531
+ const widthOf = (start, end) => piecesOf(start, end).reduce((w, p) => w + pieceWidth(p), 0);
532
+ for (const line of wrapLines(cps, maxWidth, widthOf)) {
533
+ let end = line.end;
534
+ if (line.wrapped) while (end > line.start && cps[end - 1] === 32) end--;
535
+ commitLine(piecesOf(line.start, end));
467
536
  }
468
537
  const width = Math.max(...lines.map((l) => l.width));
469
538
  const height = lines.reduce((sum, l) => sum + l.height, 0);
@@ -512,7 +581,10 @@ function blockLeft(ctx, x, width) {
512
581
  function createMarkdownRenderer(ctx, text, fontSize, maxWidth = Infinity, fontOpts = {}) {
513
582
  const measure = canvasMeasure(ctx, fontOpts);
514
583
  const parsed = typeof text === "string" ? markdownToRuns(text) : text;
515
- const layout = layoutMarkdown(parsed, maxWidth, fontSize, measure, fontOpts.lineHeight);
584
+ const family = fontOpts.family ?? "sans-serif";
585
+ const weight = numericWeight(fontOpts.weight ?? 400);
586
+ const faceOf = (bold, italic) => faceMetricsFor(family, bold ? 700 : weight, italic ? "italic" : "normal");
587
+ const layout = layoutMarkdown(parsed, maxWidth, fontSize, measure, fontOpts.lineHeight, faceOf);
516
588
  function walk(c, x, y, drawRun) {
517
589
  const align = c.textAlign;
518
590
  const left = blockLeft(c, x, layout.width);
@@ -537,7 +609,7 @@ function createMarkdownRenderer(ctx, text, fontSize, maxWidth = Infinity, fontOp
537
609
  _ctx.fillStyle = s.fill;
538
610
  for (const kind of DECORATION_KINDS) {
539
611
  if (!s.flags[kind]) continue;
540
- const { y0, y1 } = decorationRule(kind, s.baselineY, s.size);
612
+ const { y0, y1 } = decorationRule(kind, s.baselineY, s.size, s.face);
541
613
  _ctx.fillRect(s.x0, y0, s.x1 - s.x0, y1 - y0);
542
614
  }
543
615
  };
@@ -556,11 +628,11 @@ function createMarkdownRenderer(ctx, text, fontSize, maxWidth = Infinity, fontOp
556
628
  };
557
629
  const baselineY = ry + alphabeticBaselineBelow(_ctx);
558
630
  const open = span;
559
- if (open !== null && open.line === line && open.x1 === rx && open.fill === fill && open.size === run.size && open.baselineY === baselineY && DECORATION_KINDS.every((k) => open.flags[k] === flags[k])) {
631
+ if (open !== null && open.line === line && open.x1 === rx && open.fill === fill && open.size === run.size && open.face === run.face && open.baselineY === baselineY && DECORATION_KINDS.every((k) => open.flags[k] === flags[k])) {
560
632
  open.x1 = rx + run.width;
561
633
  } else {
562
634
  flush();
563
- span = { flags, fill, size: run.size, baselineY, line, x0: rx, x1: rx + run.width };
635
+ span = { flags, fill, size: run.size, face: run.face, baselineY, line, x0: rx, x1: rx + run.width };
564
636
  }
565
637
  });
566
638
  flush();
@@ -571,6 +643,6 @@ function createMarkdownRenderer(ctx, text, fontSize, maxWidth = Infinity, fontOp
571
643
  return { renderer, strokeRenderer, width: layout.width, height: layout.height };
572
644
  }
573
645
 
574
- export { MARKDOWN_RUN_GRAMMAR, SCRIPT_METRICS, createMarkdownRenderer, layoutMarkdown, layoutTextPose, markdownToRuns, measureText, measureTextBounds, measuredWidth, resolveRuns, runsToMarkdown, runsToPlainText, textLineBoxes, textPoseLayoutInput, toRuns, transformRunTexts, verticalAlignOffset };
646
+ export { MARKDOWN_RUN_GRAMMAR, SCRIPT_METRICS, SMALL_CAPS_SCALE, createMarkdownRenderer, isBoldWeight, isSmallCapsLetter, layoutMarkdown, layoutTextPose, markdownToRuns, measureText, measureTextBounds, measuredWidth, numericWeight, resolveRunFace, resolveRuns, runsToMarkdown, runsToPlainText, scriptMetrics, scriptMetricsFor, smallCapsScale, smallCapsScaleFor, smallCapsText, textLineBoxes, textPoseLayoutInput, toRuns, transformRunTexts, verticalAlignOffset };
575
647
  //# sourceMappingURL=index.js.map
576
648
  //# sourceMappingURL=index.js.map