@weasel-js/text 1.6.1 → 1.7.1

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-GNAAPRO5.js';
2
- export { DEFAULT_TEXT_STYLE, LAYOUT_CACHE_STRUCTURAL_LIMIT, LAYOUT_CACHE_VARIANT_LIMIT, cachedLayoutRuns, fontString, layoutRuns, resolveAlign, resolveTextStyle } from './chunk-GNAAPRO5.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,41 +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];
258
- const script = run.script ? SCRIPT_METRICS[run.script] : void 0;
323
+ let { text, srcMap } = shown[i];
324
+ const scriptKey = run.script ?? style.script;
325
+ const { fontFamily, fontWeight, fontStyle } = resolveRunFace(run, style);
326
+ const script = scriptKey ? scriptMetricsFor(fontFamily, fontWeight, fontStyle)[scriptKey] : void 0;
259
327
  const shiftEm = run.baselineShift ?? script?.shift ?? 0;
260
328
  const scale = run.fontScale ?? script?.size ?? 1;
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
+ }
261
341
  out.push({
262
342
  text,
263
343
  ...srcMap ? { srcMap } : {},
264
- fontFamily: run.fontFamily ?? style.fontFamily,
265
- // An absolute size wins over a relative one; naming both is a consumer
266
- // saying "this size exactly", which a multiplier cannot improve on.
267
- fontSize: run.fontSize !== void 0 ? resolveScreenLength(run.fontSize, viewScale) : style.fontSize * scale,
268
- fontWeight: run.bold ? 700 : baseWeight,
269
- fontStyle: run.italic ? "italic" : style.fontStyle,
344
+ fontFamily,
345
+ fontSize,
346
+ ...sizeMap ? { sizeMap } : {},
347
+ fontWeight,
348
+ fontStyle,
270
349
  fill: run.fill ?? style.fill,
271
350
  // Unlike the decorations below, a run's stroke *replaces* the node's
272
351
  // rather than adding to it — there is only one outline to paint.
@@ -276,7 +355,8 @@ function resolveRuns(runs, style, viewScale = 1) {
276
355
  underline: run.underline || style.underline,
277
356
  strikethrough: run.strikethrough || style.strikethrough,
278
357
  overline: run.overline || style.overline,
279
- baselineShift: shiftEm * style.fontSize
358
+ baselineShift: shiftEm * style.fontSize,
359
+ ...relative ? { strutSize: style.fontSize } : {}
280
360
  });
281
361
  }
282
362
  return out;
@@ -300,7 +380,8 @@ function textPoseLayoutInput(pose, scale = 1) {
300
380
  maxWidth: style.wrap ? pose.width : Infinity,
301
381
  alignWidth: pose.width,
302
382
  lineHeight: style.lineHeight,
303
- align: resolveAlign(style.align, style.direction)
383
+ align: resolveAlign(style.align, style.direction),
384
+ justify: style.align === "justify"
304
385
  }
305
386
  };
306
387
  }
@@ -316,46 +397,21 @@ function layoutTextPose(pose, scale = 1) {
316
397
 
317
398
  // src/measure/measureText.ts
318
399
  function measuredWidth(ctx, text, style) {
319
- return ctx.measureText(text).width + text.length * style.letterSpacing;
400
+ return ctx.measureText(text).width + (style.letterSpacing === 0 ? 0 : graphemeCount(text) * style.letterSpacing);
320
401
  }
321
402
  function measureText(ctx, text, maxWidth, style) {
322
- const lines = [];
323
- const lineStarts = [];
324
- const paragraphs = text.split("\n");
325
- let pos = 0;
326
- for (let pi = 0; pi < paragraphs.length; pi++) {
327
- if (pi > 0) pos += 1;
328
- const paragraph = paragraphs[pi];
329
- if (paragraph === "") {
330
- lines.push("");
331
- lineStarts.push(pos);
332
- continue;
333
- }
334
- const words = paragraph.split(/(\s+)/).filter((w) => w.length > 0);
335
- let current = "";
336
- let currentStart = pos;
337
- let consumed = 0;
338
- for (const word of words) {
339
- if (current === "") currentStart = pos + consumed;
340
- const candidate = current + word;
341
- if (current === "" || measuredWidth(ctx, candidate, style) <= maxWidth) {
342
- current = candidate;
343
- } else {
344
- lines.push(current.trimEnd());
345
- lineStarts.push(currentStart);
346
- if (/^\s+$/.test(word)) {
347
- current = "";
348
- } else {
349
- current = word;
350
- currentStart = pos + consumed;
351
- }
352
- }
353
- consumed += word.length;
354
- }
355
- lines.push(current.trimEnd());
356
- lineStarts.push(currentStart);
357
- 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;
358
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]);
359
415
  const height = lines.length * style.fontSize * style.lineHeight;
360
416
  return { lines, lineStarts, height };
361
417
  }
@@ -388,8 +444,8 @@ function textLineBoxes(pose, opts = {}) {
388
444
  }
389
445
  return out;
390
446
  }
391
- function runMetrics(run, fontSize) {
392
- 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;
393
449
  const scale = run.fontScale ?? script?.size ?? 1;
394
450
  const shiftEm = run.baselineShift ?? script?.shift ?? 0;
395
451
  return {
@@ -399,68 +455,84 @@ function runMetrics(run, fontSize) {
399
455
  y: shiftEm === 0 ? 0 : -shiftEm * fontSize
400
456
  };
401
457
  }
402
- function layoutMarkdown(runs, maxWidth, fontSize, measure, lineHeightFactor = 1.3) {
458
+ function layoutMarkdown(runs, maxWidth, fontSize, measure, lineHeightFactor = 1.3, faceOf) {
403
459
  if (runs.length === 0) return { lines: [], width: 0, height: 0 };
404
460
  const shown = transformRunTexts(runs.map((r) => r.text), runs.map((r) => r.textTransform ?? "none"));
405
- runs = runs.map((r, i) => shown[i].text === r.text ? r : { ...r, text: shown[i].text });
406
- const lines = [];
407
- let currentRuns = [];
408
- let lineX = 0;
409
- let lineMaxSize = 0;
410
- function commitLine() {
411
- const effectiveFontSize = lineMaxSize > 0 ? lineMaxSize : fontSize;
412
- const lineHeight = effectiveFontSize * lineHeightFactor;
413
- lines.push({ runs: currentRuns, width: lineX, height: lineHeight });
414
- currentRuns = [];
415
- lineX = 0;
416
- lineMaxSize = 0;
417
- }
418
- function processSegment(segRun) {
419
- const { size: effectiveSize, y: runY } = runMetrics(segRun, fontSize);
420
- lineMaxSize = Math.max(lineMaxSize, effectiveSize);
421
- if (maxWidth === Infinity) {
422
- const w = measure(segRun.text, effectiveSize, segRun.bold ?? false, segRun.italic ?? false);
423
- currentRuns.push({ ...segRun, x: lineX, width: w, y: runY, size: effectiveSize });
424
- 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 });
425
466
  return;
426
467
  }
427
- const words = segRun.text.split(/ /);
428
- let wordBuf = "";
429
- for (let wi = 0; wi < words.length; wi++) {
430
- const word = words[wi];
431
- const candidate = wordBuf.length > 0 ? wordBuf + " " + word : word;
432
- const candidateW = measure(candidate, effectiveSize, segRun.bold ?? false, segRun.italic ?? false);
433
- if (lineX + candidateW > maxWidth && (lineX > 0 || wordBuf.length > 0)) {
434
- if (wordBuf.length > 0) {
435
- const w = measure(wordBuf, effectiveSize, segRun.bold ?? false, segRun.italic ?? false);
436
- currentRuns.push({ ...segRun, text: wordBuf, x: lineX, width: w, y: runY, size: effectiveSize });
437
- lineX += w;
438
- }
439
- commitLine();
440
- lineMaxSize = Math.max(lineMaxSize, effectiveSize);
441
- 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] };
442
511
  } else {
443
- wordBuf = candidate;
512
+ out.push({ run: runOf[k], start: startOf[k], end: endOf[k] });
444
513
  }
445
514
  }
446
- if (wordBuf.length > 0) {
447
- const w = measure(wordBuf, effectiveSize, segRun.bold ?? false, segRun.italic ?? false);
448
- currentRuns.push({ ...segRun, text: wordBuf, x: lineX, width: w, y: runY, size: effectiveSize });
449
- lineX += w;
450
- }
515
+ return out;
451
516
  }
452
- for (const run of runs) {
453
- const segments = run.text.split("\n");
454
- for (let si = 0; si < segments.length; si++) {
455
- if (si > 0) commitLine();
456
- const seg = segments[si];
457
- if (seg.length > 0) {
458
- processSegment({ ...run, text: seg });
459
- }
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);
460
528
  }
529
+ lines.push({ runs: positioned, width: x, height: (maxSize > 0 ? maxSize : fontSize) * lineHeightFactor });
461
530
  }
462
- if (currentRuns.length > 0) {
463
- 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));
464
536
  }
465
537
  const width = Math.max(...lines.map((l) => l.width));
466
538
  const height = lines.reduce((sum, l) => sum + l.height, 0);
@@ -509,7 +581,10 @@ function blockLeft(ctx, x, width) {
509
581
  function createMarkdownRenderer(ctx, text, fontSize, maxWidth = Infinity, fontOpts = {}) {
510
582
  const measure = canvasMeasure(ctx, fontOpts);
511
583
  const parsed = typeof text === "string" ? markdownToRuns(text) : text;
512
- 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);
513
588
  function walk(c, x, y, drawRun) {
514
589
  const align = c.textAlign;
515
590
  const left = blockLeft(c, x, layout.width);
@@ -534,7 +609,7 @@ function createMarkdownRenderer(ctx, text, fontSize, maxWidth = Infinity, fontOp
534
609
  _ctx.fillStyle = s.fill;
535
610
  for (const kind of DECORATION_KINDS) {
536
611
  if (!s.flags[kind]) continue;
537
- const { y0, y1 } = decorationRule(kind, s.baselineY, s.size);
612
+ const { y0, y1 } = decorationRule(kind, s.baselineY, s.size, s.face);
538
613
  _ctx.fillRect(s.x0, y0, s.x1 - s.x0, y1 - y0);
539
614
  }
540
615
  };
@@ -553,11 +628,11 @@ function createMarkdownRenderer(ctx, text, fontSize, maxWidth = Infinity, fontOp
553
628
  };
554
629
  const baselineY = ry + alphabeticBaselineBelow(_ctx);
555
630
  const open = span;
556
- 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])) {
557
632
  open.x1 = rx + run.width;
558
633
  } else {
559
634
  flush();
560
- 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 };
561
636
  }
562
637
  });
563
638
  flush();
@@ -568,6 +643,6 @@ function createMarkdownRenderer(ctx, text, fontSize, maxWidth = Infinity, fontOp
568
643
  return { renderer, strokeRenderer, width: layout.width, height: layout.height };
569
644
  }
570
645
 
571
- 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 };
572
647
  //# sourceMappingURL=index.js.map
573
648
  //# sourceMappingURL=index.js.map