@embedpdf/core-annotation 3.0.0-next.11 → 3.0.0-next.13

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.cjs CHANGED
@@ -207,6 +207,18 @@ const TEXT_PROPS = [
207
207
  key: "fontColor",
208
208
  label: "Text color"
209
209
  },
210
+ {
211
+ key: "bold",
212
+ label: "Bold"
213
+ },
214
+ {
215
+ key: "italic",
216
+ label: "Italic"
217
+ },
218
+ {
219
+ key: "underline",
220
+ label: "Underline"
221
+ },
210
222
  {
211
223
  key: "textAlign",
212
224
  label: "Align"
@@ -2687,41 +2699,60 @@ function geomDragHandle(g, handle, to) {
2687
2699
  }
2688
2700
  return g;
2689
2701
  }
2702
+ /**
2703
+ * The text PLATE inset of a free-text box: twice the border width. The plate
2704
+ * — where text lays out, clips and scrolls — is the box deflated by this on
2705
+ * every side: the ink band and an equal breathing band, so the text never
2706
+ * touches the stroke. Acrobat's rule, measured 1–12 pt on plain boxes and
2707
+ * 1–7 pt on callouts (plan `2026-09-15-free-text-plate-inset.md`); the
2708
+ * engine's `FreeTextPlate` is the same formula, so the live editor sits
2709
+ * exactly where the baked text lands. Acrobat's thinnest border is 1 pt; a
2710
+ * width of 0 is ours alone and gives no inset (the plate is the box).
2711
+ */
2712
+ function textPlateInset(strokeWidth) {
2713
+ return 2 * Math.max(0, strokeWidth);
2714
+ }
2715
+ /** A callout's leader (open polyline, its connection point extended under
2716
+ * the box border by half the stroke — the AP generator's `adjusted_conn` —
2717
+ * so the leader meets the border ink without an angular gap) + the arrow at
2718
+ * its tip. */
2719
+ function calloutLeaderNodes(g, callout, strokeWidth) {
2720
+ const pts = [...calloutLinePoints(g)];
2721
+ if (strokeWidth > 0 && pts.length >= 2) {
2722
+ const last = pts[pts.length - 1];
2723
+ const prev = pts[pts.length - 2];
2724
+ const dx = last.x - prev.x;
2725
+ const dy = last.y - prev.y;
2726
+ const len = Math.hypot(dx, dy);
2727
+ if (len > 0) pts[pts.length - 1] = {
2728
+ x: last.x + dx / len * (strokeWidth / 2),
2729
+ y: last.y + dy / len * (strokeWidth / 2)
2730
+ };
2731
+ }
2732
+ const nodes = [{
2733
+ kind: "poly",
2734
+ points: pts,
2735
+ closed: false
2736
+ }];
2737
+ const seg = calloutEndingSeg(pts, callout.ending);
2738
+ if (seg) nodes.push(...endingNodes(seg.tip, seg.angle, seg.ending, strokeWidth));
2739
+ return nodes;
2740
+ }
2690
2741
  function geomScene(g, strokeWidth = 0, border) {
2691
2742
  if (g.t === "text") {
2692
- if (!g.callout) return [];
2693
- const pts = [...calloutLinePoints(g)];
2694
- if (strokeWidth > 0 && pts.length >= 2) {
2695
- const last = pts[pts.length - 1];
2696
- const prev = pts[pts.length - 2];
2697
- const dx = last.x - prev.x;
2698
- const dy = last.y - prev.y;
2699
- const len = Math.hypot(dx, dy);
2700
- if (len > 0) pts[pts.length - 1] = {
2701
- x: last.x + dx / len * (strokeWidth / 2),
2702
- y: last.y + dy / len * (strokeWidth / 2)
2703
- };
2704
- }
2705
- const nodes = [{
2743
+ const nodes = [];
2744
+ if (g.callout) nodes.push(...calloutLeaderNodes(g, g.callout, strokeWidth));
2745
+ const r = insetRect(g.rect, strokeWidth / 2);
2746
+ const rot = g.rot ?? 0;
2747
+ const c = rectCenter(g.rect);
2748
+ nodes.push(rot ? {
2706
2749
  kind: "poly",
2707
- points: pts,
2708
- closed: false
2709
- }];
2710
- const seg = calloutEndingSeg(pts, g.callout.ending);
2711
- if (seg) nodes.push(...endingNodes(seg.tip, seg.angle, seg.ending, strokeWidth));
2712
- if (strokeWidth > 0) {
2713
- const r = insetRect(g.rect, strokeWidth / 2);
2714
- const rot = g.rot ?? 0;
2715
- const c = rectCenter(g.rect);
2716
- nodes.push(rot ? {
2717
- kind: "poly",
2718
- points: rectCornerPoints(r).map((p) => rotatePoint(p, c, rot)),
2719
- closed: true
2720
- } : {
2721
- kind: "rect",
2722
- rect: r
2723
- });
2724
- }
2750
+ points: rectCornerPoints(r).map((p) => rotatePoint(p, c, rot)),
2751
+ closed: true
2752
+ } : {
2753
+ kind: "rect",
2754
+ rect: r
2755
+ });
2725
2756
  return nodes;
2726
2757
  }
2727
2758
  if (g.t === "caret") {
@@ -3227,7 +3258,10 @@ const textStyleFromProps = (p) => ({
3227
3258
  fontFamily: p.fontFamily,
3228
3259
  fontSize: p.fontSize,
3229
3260
  fontColor: p.fontColor,
3230
- textAlign: p.textAlign
3261
+ textAlign: p.textAlign,
3262
+ ...p.bold !== void 0 ? { bold: p.bold } : {},
3263
+ ...p.italic !== void 0 ? { italic: p.italic } : {},
3264
+ ...p.underline !== void 0 ? { underline: p.underline } : {}
3231
3265
  });
3232
3266
  /** Does this kind's prop table declare `link` (attachable link)? The link
3233
3267
  * KIND itself also declares it, but stores its own `/A` instead. */
@@ -3253,6 +3287,9 @@ function readProp(a, key) {
3253
3287
  case "fontSize": return a.text?.fontSize;
3254
3288
  case "fontColor": return a.text?.fontColor;
3255
3289
  case "textAlign": return a.text?.textAlign;
3290
+ case "bold": return a.text ? a.text.bold ?? false : void 0;
3291
+ case "italic": return a.text ? a.text.italic ?? false : void 0;
3292
+ case "underline": return a.text ? a.text.underline ?? false : void 0;
3256
3293
  case "icon": return a.icon;
3257
3294
  case "link": return a.link;
3258
3295
  }
@@ -3340,6 +3377,17 @@ function applyProps(a, patch) {
3340
3377
  text.textAlign = patch.textAlign;
3341
3378
  textChanged = true;
3342
3379
  }
3380
+ for (const key of [
3381
+ "bold",
3382
+ "italic",
3383
+ "underline"
3384
+ ]) {
3385
+ const value = patch[key];
3386
+ if (value !== void 0 && takes.has(key) && (text[key] ?? false) !== value) {
3387
+ text[key] = value;
3388
+ textChanged = true;
3389
+ }
3390
+ }
3343
3391
  if (textChanged) next = {
3344
3392
  ...next,
3345
3393
  text
@@ -3469,6 +3517,236 @@ function clickCreateGeom(subtype, placement, def) {
3469
3517
  return null;
3470
3518
  }
3471
3519
  //#endregion
3520
+ //#region src/richtext.ts
3521
+ /** Length of the plain projection. */
3522
+ function richTextLength(doc) {
3523
+ let n = 0;
3524
+ doc.paragraphs.forEach((p, i) => {
3525
+ if (i > 0) n += 1;
3526
+ for (const r of p.runs) n += r.text.length;
3527
+ });
3528
+ return n;
3529
+ }
3530
+ const DELTA_KEYS = [
3531
+ "family",
3532
+ "weight",
3533
+ "italic",
3534
+ "size",
3535
+ "color",
3536
+ "decoration",
3537
+ "script",
3538
+ "letterSpacing",
3539
+ "horizontalScale",
3540
+ "unknown"
3541
+ ];
3542
+ function sameDecoration(a, b) {
3543
+ if (a === b) return true;
3544
+ if (!a || !b || a.length !== b.length) return false;
3545
+ const sorted = (x) => [...x].sort();
3546
+ const sa = sorted(a);
3547
+ const sb = sorted(b);
3548
+ return sa.every((v, i) => v === sb[i]);
3549
+ }
3550
+ /** Two deltas override the same properties with the same values. */
3551
+ function sameStyleDelta(a, b) {
3552
+ const da = a ?? {};
3553
+ const db = b ?? {};
3554
+ for (const key of DELTA_KEYS) if (key === "decoration") {
3555
+ if (!sameDecoration(da.decoration, db.decoration)) return false;
3556
+ } else if (da[key] !== db[key]) return false;
3557
+ return true;
3558
+ }
3559
+ /** A delta with every `undefined` member removed; `undefined` when empty. */
3560
+ function cleanDelta(delta) {
3561
+ if (!delta) return void 0;
3562
+ const out = {};
3563
+ let any = false;
3564
+ for (const key of DELTA_KEYS) {
3565
+ const value = delta[key];
3566
+ if (value !== void 0) {
3567
+ out[key] = value;
3568
+ any = true;
3569
+ }
3570
+ }
3571
+ return any ? out : void 0;
3572
+ }
3573
+ /**
3574
+ * Merge adjacent runs whose deltas agree, drop empty runs, strip empty
3575
+ * deltas. A paragraph with no text keeps a single empty run so the editor
3576
+ * has somewhere to put the caret.
3577
+ */
3578
+ function normalizeRuns(doc) {
3579
+ const paragraphs = doc.paragraphs.map((p) => {
3580
+ const runs = [];
3581
+ for (const run of p.runs) {
3582
+ if (run.text.length === 0) continue;
3583
+ const style = cleanDelta(run.style);
3584
+ const last = runs[runs.length - 1];
3585
+ if (last && sameStyleDelta(last.style, style)) runs[runs.length - 1] = {
3586
+ ...last,
3587
+ text: last.text + run.text
3588
+ };
3589
+ else runs.push(style ? {
3590
+ text: run.text,
3591
+ style
3592
+ } : { text: run.text });
3593
+ }
3594
+ if (runs.length === 0) runs.push({ text: "" });
3595
+ return {
3596
+ ...p,
3597
+ runs
3598
+ };
3599
+ });
3600
+ return {
3601
+ ...doc,
3602
+ paragraphs
3603
+ };
3604
+ }
3605
+ /** Where a flat offset falls: paragraph index and offset within it (the
3606
+ * separator between paragraphs belongs to the end of the earlier one). */
3607
+ function locateOffset(doc, offset) {
3608
+ let pos = 0;
3609
+ for (let i = 0; i < doc.paragraphs.length; i++) {
3610
+ const length = doc.paragraphs[i].runs.reduce((n, r) => n + r.text.length, 0);
3611
+ if (offset <= pos + length || i === doc.paragraphs.length - 1) return {
3612
+ paragraph: i,
3613
+ offset: Math.max(0, Math.min(length, offset - pos))
3614
+ };
3615
+ pos += length + 1;
3616
+ }
3617
+ return {
3618
+ paragraph: 0,
3619
+ offset: 0
3620
+ };
3621
+ }
3622
+ /** The paragraph with a run boundary guaranteed at a local offset. */
3623
+ function splitRunsAt(paragraph, offset) {
3624
+ const runs = [];
3625
+ let pos = 0;
3626
+ for (const run of paragraph.runs) {
3627
+ const end = pos + run.text.length;
3628
+ if (offset > pos && offset < end) {
3629
+ const cut = offset - pos;
3630
+ runs.push({
3631
+ ...run,
3632
+ text: run.text.slice(0, cut)
3633
+ });
3634
+ runs.push({
3635
+ ...run,
3636
+ text: run.text.slice(cut)
3637
+ });
3638
+ } else runs.push(run);
3639
+ pos = end;
3640
+ }
3641
+ return {
3642
+ ...paragraph,
3643
+ runs
3644
+ };
3645
+ }
3646
+ /**
3647
+ * Apply a style delta to every character of a range: the runs it touches are
3648
+ * split at its boundaries, the covered pieces take the delta on top of what
3649
+ * they already override (`clear` names properties to stop overriding, so
3650
+ * they follow the body again), and the result is normalised. An empty range
3651
+ * is a no-op.
3652
+ */
3653
+ function applyStyleToRange(doc, range, delta, clear = []) {
3654
+ const start = Math.max(0, Math.min(range.start, range.end));
3655
+ const end = Math.max(range.start, range.end);
3656
+ if (end <= start) return doc;
3657
+ const from = locateOffset(doc, start);
3658
+ const to = locateOffset(doc, end);
3659
+ const paragraphs = doc.paragraphs.map((paragraph, index) => {
3660
+ if (index < from.paragraph || index > to.paragraph) return paragraph;
3661
+ const localStart = index === from.paragraph ? from.offset : 0;
3662
+ const localEnd = index === to.paragraph ? to.offset : paragraph.runs.reduce((n, r) => n + r.text.length, 0);
3663
+ if (localEnd <= localStart) return paragraph;
3664
+ const split = splitRunsAt(splitRunsAt(paragraph, localStart), localEnd);
3665
+ let pos = 0;
3666
+ const runs = split.runs.map((run) => {
3667
+ const runStart = pos;
3668
+ pos += run.text.length;
3669
+ if (runStart < localStart || runStart >= localEnd || run.text.length === 0) return run;
3670
+ const style = {
3671
+ ...run.style ?? {},
3672
+ ...delta
3673
+ };
3674
+ for (const key of clear) delete style[key];
3675
+ return {
3676
+ ...run,
3677
+ style
3678
+ };
3679
+ });
3680
+ return {
3681
+ ...split,
3682
+ runs
3683
+ };
3684
+ });
3685
+ return normalizeRuns({
3686
+ ...doc,
3687
+ paragraphs
3688
+ });
3689
+ }
3690
+ /**
3691
+ * The delta in force at a caret: the run the offset falls in, or, at a run
3692
+ * boundary, the run before it (what typing there would inherit). Undefined
3693
+ * for an empty document.
3694
+ */
3695
+ function styleAt(doc, offset) {
3696
+ const at = locateOffset(doc, offset);
3697
+ const paragraph = doc.paragraphs[at.paragraph];
3698
+ if (!paragraph) return void 0;
3699
+ let pos = 0;
3700
+ let previous;
3701
+ for (const run of paragraph.runs) {
3702
+ const end = pos + run.text.length;
3703
+ if (at.offset > pos && at.offset <= end) return run.style ?? {};
3704
+ if (at.offset === pos && pos === 0) return run.style ?? {};
3705
+ previous = run;
3706
+ pos = end;
3707
+ }
3708
+ return previous?.style ?? {};
3709
+ }
3710
+ /** Every run overlapping the range satisfies `predicate` (an empty range
3711
+ * tests the caret's style). */
3712
+ function rangeHasStyle(doc, range, predicate) {
3713
+ const start = Math.min(range.start, range.end);
3714
+ const end = Math.max(range.start, range.end);
3715
+ if (end <= start) return predicate(styleAt(doc, start) ?? {});
3716
+ const from = locateOffset(doc, start);
3717
+ const to = locateOffset(doc, end);
3718
+ let any = false;
3719
+ for (let index = from.paragraph; index <= to.paragraph; index++) {
3720
+ const paragraph = doc.paragraphs[index];
3721
+ const localStart = index === from.paragraph ? from.offset : 0;
3722
+ const localEnd = index === to.paragraph ? to.offset : paragraph.runs.reduce((n, r) => n + r.text.length, 0);
3723
+ let pos = 0;
3724
+ for (const run of paragraph.runs) {
3725
+ const runEnd = pos + run.text.length;
3726
+ if (runEnd > localStart && pos < localEnd && run.text.length > 0) {
3727
+ any = true;
3728
+ if (!predicate(run.style ?? {})) return false;
3729
+ }
3730
+ pos = runEnd;
3731
+ }
3732
+ }
3733
+ return any;
3734
+ }
3735
+ /** Plain paragraphs from text: one per line break, one unstyled run each. */
3736
+ function paragraphsFromPlainText(text) {
3737
+ return text.split(/\r\n|\r|\n/).map((line) => ({ runs: [{ text: line }] }));
3738
+ }
3739
+ /** The plain projection: paragraphs joined by `\r`, runs concatenated. */
3740
+ function plainTextOf(doc) {
3741
+ return doc.paragraphs.map((p) => p.runs.map((r) => r.text).join("")).join("\r");
3742
+ }
3743
+ /** True when nothing in the document overrides the body: no run delta, no
3744
+ * paragraph property. Such a document commits as plain `contents`. */
3745
+ function isPlainRichText(doc) {
3746
+ if (doc.body && Object.keys(doc.body).length > 0) return false;
3747
+ return doc.paragraphs.every((p) => p.align === void 0 && p.dir === void 0 && p.lineHeight === void 0 && p.margins === void 0 && p.textIndent === void 0 && p.unknown === void 0 && p.runs.every((r) => cleanDelta(r.style) === void 0));
3748
+ }
3749
+ //#endregion
3472
3750
  //#region src/snap.ts
3473
3751
  /**
3474
3752
  * Pure alignment snapping for the move gesture. Compares the moving selection's
@@ -3902,6 +4180,7 @@ function update(m, msg) {
3902
4180
  draft: null
3903
4181
  }, []] : [m, []];
3904
4182
  case "setText": return setText(m, msg.id, msg.text);
4183
+ case "setRichText": return setRichText(m, msg.id, msg.doc);
3905
4184
  case "endTextEdit": return m.editing ? [{
3906
4185
  ...m,
3907
4186
  editing: null
@@ -3915,12 +4194,49 @@ function update(m, msg) {
3915
4194
  function setText(m, id, text) {
3916
4195
  const a = m.byId[id];
3917
4196
  if (!a) return [m, []];
4197
+ const data = a.data && a.data.subtype === "free-text" ? {
4198
+ ...a.data,
4199
+ contents: text,
4200
+ richText: {
4201
+ ...a.data.richText,
4202
+ paragraphs: paragraphsFromPlainText(text)
4203
+ }
4204
+ } : a.data ? {
4205
+ ...a.data,
4206
+ contents: text
4207
+ } : a.data;
4208
+ const next = toVector({
4209
+ ...a,
4210
+ data
4211
+ });
4212
+ return [{
4213
+ ...m,
4214
+ byId: {
4215
+ ...m.byId,
4216
+ [id]: next
4217
+ }
4218
+ }, []];
4219
+ }
4220
+ /** Apply the editor's rich document optimistically. The DTO's body is kept
4221
+ * (a partial input body layers on it); `contents` is the projection. */
4222
+ function setRichText(m, id, doc) {
4223
+ const a = m.byId[id];
4224
+ if (!a || !a.data || a.data.subtype !== "free-text") return [m, []];
4225
+ const normalized = normalizeRuns(doc);
4226
+ const richText = {
4227
+ body: {
4228
+ ...a.data.richText.body,
4229
+ ...normalized.body ?? {}
4230
+ },
4231
+ paragraphs: normalized.paragraphs
4232
+ };
3918
4233
  const next = toVector({
3919
4234
  ...a,
3920
- data: a.data ? {
4235
+ data: {
3921
4236
  ...a.data,
3922
- contents: text
3923
- } : a.data
4237
+ richText,
4238
+ contents: plainTextOf(richText)
4239
+ }
3924
4240
  });
3925
4241
  return [{
3926
4242
  ...m,
@@ -5522,7 +5838,7 @@ function effAp(m, id, view) {
5522
5838
  function effSource(m, id) {
5523
5839
  const a = m.byId[id];
5524
5840
  if (capsFor(a.subtype).opaqueBody) return a.source;
5525
- if (m.editing === id && a.geom.t === "text" && a.geom.callout) return "vector";
5841
+ if (m.editing === id && a.geom.t === "text") return "vector";
5526
5842
  const d = m.draft;
5527
5843
  if (d?.g === "handle" && d.id === id) return "vector";
5528
5844
  if ((d?.g === "rotate" || d?.g === "group") && d.ids.includes(id)) return "vector";
@@ -5538,7 +5854,6 @@ function pageItems(m, pon, view) {
5538
5854
  const items = [];
5539
5855
  for (const id of paintOrder(m, pon)) {
5540
5856
  const a = m.byId[id];
5541
- if (a.geom.t === "text" && !a.geom.callout && textIsLive(m, id)) continue;
5542
5857
  const geom = effGeom(m, id, view);
5543
5858
  const style = effStyle(a, view);
5544
5859
  const ap = effAp(m, id, view);
@@ -5933,6 +6248,7 @@ exports.annotDeletable = annotDeletable;
5933
6248
  exports.annotInteractive = annotInteractive;
5934
6249
  exports.annotTransformable = annotTransformable;
5935
6250
  exports.applyProps = applyProps;
6251
+ exports.applyStyleToRange = applyStyleToRange;
5936
6252
  exports.calloutConnection = calloutConnection;
5937
6253
  exports.calloutLinePoints = calloutLinePoints;
5938
6254
  exports.canMove = canMove;
@@ -5980,28 +6296,36 @@ exports.initialTextStyle = initialTextStyle;
5980
6296
  exports.interactive = interactive;
5981
6297
  exports.isAttachedLink = isAttachedLink;
5982
6298
  exports.isConversationOnly = isConversationOnly;
6299
+ exports.isPlainRichText = isPlainRichText;
5983
6300
  exports.isRotatableGeom = isRotatableGeom;
5984
6301
  exports.isSelectable = isSelectable;
5985
6302
  exports.isSubstrateOnly = isSubstrateOnly;
5986
6303
  exports.linkChildrenOf = linkChildrenOf;
5987
6304
  exports.linkOf = linkOf;
6305
+ exports.locateOffset = locateOffset;
5988
6306
  exports.mergeFlags = mergeFlags;
5989
6307
  exports.normalizeDeg = normalizeDeg;
6308
+ exports.normalizeRuns = normalizeRuns;
5990
6309
  exports.obbFromGeom = obbFromGeom;
5991
6310
  exports.pageItems = pageItems;
6311
+ exports.paragraphsFromPlainText = paragraphsFromPlainText;
5992
6312
  exports.pdfToContentPoint = pdfToContentPoint;
5993
6313
  exports.pdfToContentRect = pdfToContentRect;
5994
6314
  exports.placeRotateKnob = placeRotateKnob;
6315
+ exports.plainTextOf = plainTextOf;
5995
6316
  exports.pointInQuad = pointInQuad;
5996
6317
  exports.propsFor = propsFor;
5997
6318
  exports.quadIntersectsRect = quadIntersectsRect;
6319
+ exports.rangeHasStyle = rangeHasStyle;
5998
6320
  exports.readProp = readProp;
5999
6321
  exports.rectFromPoints = rectFromPoints;
6000
6322
  exports.resolveClickPlacement = resolveClickPlacement;
6323
+ exports.richTextLength = richTextLength;
6001
6324
  exports.rotateDraftDelta = rotateDraftDelta;
6002
6325
  exports.rotateKnob = rotateKnob;
6003
6326
  exports.rotatedAabb = rotatedAabb;
6004
6327
  exports.rotatedHandleCursor = rotatedHandleCursor;
6328
+ exports.sameStyleDelta = sameStyleDelta;
6005
6329
  exports.scene = scene;
6006
6330
  exports.selectedItems = selectedItems;
6007
6331
  exports.selectionAnchor = selectionAnchor;
@@ -6012,9 +6336,12 @@ exports.selectionKnob = selectionKnob;
6012
6336
  exports.selectionQuad = selectionQuad;
6013
6337
  exports.shapeRectFor = shapeRectFor;
6014
6338
  exports.sharedProps = sharedProps;
6339
+ exports.splitRunsAt = splitRunsAt;
6015
6340
  exports.straightenInkStroke = straightenInkStroke;
6341
+ exports.styleAt = styleAt;
6016
6342
  exports.styleFromProps = styleFromProps;
6017
6343
  exports.textBoxes = textBoxes;
6344
+ exports.textPlateInset = textPlateInset;
6018
6345
  exports.textStyleFromProps = textStyleFromProps;
6019
6346
  exports.transposedAboutCenter = transposedAboutCenter;
6020
6347
  exports.unanchoredGeom = unanchoredGeom;