@nfcard/validation 0.18.0 → 0.20.0

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.d.ts CHANGED
@@ -1,5 +1,6 @@
1
- import { z } from 'zod';
1
+ import * as _nfcard_types from '@nfcard/types';
2
2
  import { CardAsset, CardElement, ElementTransform } from '@nfcard/types';
3
+ import { z } from 'zod';
3
4
 
4
5
  /**
5
6
  * Manufacturing-printability envelope for the free-transform element layer
@@ -49,6 +50,18 @@ declare function obbCorners(t: ElementTransform, wMm: number, hMm: number): Pt[]
49
50
  /** Are all corners inside the ID-1 rim minus the safety margin? */
50
51
  declare function cornersOnCard(corners: Pt[], margin?: number): boolean;
51
52
  type ChipAxis = 0 | 1 | 2;
53
+ /**
54
+ * The chip PLACEMENT envelope: the region the chip *centre* must stay inside so
55
+ * the pattern-engine's hole-masking + rim clearance can keep the NFC pocket
56
+ * enclosed. Sized so the (scaled) Ø27.5·scale chip island clears the rim margin
57
+ * with a 1 mm buffer. Scale-aware so a grown chip tightens the envelope. Mirrors
58
+ * cardgen `chip_placement_max_xy`; `chipMaxXY()` is exactly this at scale 1 (the
59
+ * domain of the legacy 3×3 anchor lattice).
60
+ */
61
+ declare function chipPlacementMaxXY(scale: number): {
62
+ maxX: number;
63
+ maxY: number;
64
+ };
52
65
  /** Grid cell → card-local mm centre (matches pattern-engine `chipCentreForAnchor`). */
53
66
  declare function chipAnchorToMm(col: ChipAxis, row: ChipAxis): Pt;
54
67
  /** Free mm → nearest grid cell (matches pattern-engine `nearestChipAnchor`). The
@@ -66,6 +79,33 @@ interface Violation {
66
79
  elementId: string;
67
80
  message: string;
68
81
  }
82
+ /** Unknown glyphs (outside the tables): reserve a generous em so any realistic exotic
83
+ * char OVER-reserves. (Not a mathematical bound — a pathological single-codepoint wide
84
+ * ligature can exceed it — but such glyphs aren't in the card font at all, so the print
85
+ * pipeline rejects them upstream of this estimate.) */
86
+ declare const ADVANCE_FALLBACK_EM = 1.1;
87
+ /** ITALIC ink overhang: slanted glyphs poke past their advance box (measured against the
88
+ * webfont: ≤ 0.20 em at 400, ≤ 0.23 em at 700 — descender tails like "jjj" are the worst
89
+ * case; upright overhang is exactly 0). Charged once per text box so the advance-sum
90
+ * width stays a true upper bound on the rendered ink for the italic toggle too. */
91
+ declare const ITALIC_OVERHANG_EM = 0.25;
92
+ /** Empty-text width floor — the OLD flat estimate's minimum (1 char × 0.6 em), kept so a
93
+ * blank element still has a box. NOT a live glyph metric (blank text is separately gated
94
+ * by `textEmpty`); it just anchors degenerate OBBs. */
95
+ declare const EMPTY_TEXT_FLOOR_EM = 0.6;
96
+ /** Baseline-to-baseline line advance for multi-line text (mirrors the render stack's
97
+ * TEXT_LINE_FACTOR in nfcard-web pocShared + cardgen text2d). */
98
+ declare const TEXT_LINE_FACTOR_EM = 1.32;
99
+ /** Advance-sum width of ONE line in em, in the weight's metric set (≥600 → SemiBold). */
100
+ declare function textAdvanceEm(line: string, weight: number): number;
101
+ /** Estimated card footprint (w×h, pre-scale) used for the on-card OBB check. Text is the
102
+ * per-glyph advance sum of the WIDEST line (untrimmed — spaces cost their real advance)
103
+ * with the multi-line stack in the height; a guaranteed upper bound on the rendered ink
104
+ * (see the table note above), so the gate can never accept an overflowing placement. */
105
+ declare function footprintOf(el: CardElement): {
106
+ w: number;
107
+ h: number;
108
+ };
69
109
  /** All hard manufacturing violations for one element. Empty ⇒ printable. */
70
110
  declare function elementPrintability(el: CardElement, ctx: PrintabilityCtx): Violation[];
71
111
 
@@ -5142,6 +5182,15 @@ declare const ctaButtonsSchema: z.ZodEffects<z.ZodArray<z.ZodObject<{
5142
5182
  url?: string | undefined;
5143
5183
  label?: string | undefined;
5144
5184
  }[]>;
5185
+ declare const tipJarSchema: z.ZodEffects<z.ZodUnknown, {
5186
+ provider: _nfcard_types.TipProviderId;
5187
+ handle: string;
5188
+ label: string;
5189
+ } | {
5190
+ provider: _nfcard_types.TipProviderId;
5191
+ handle: string;
5192
+ label?: undefined;
5193
+ } | undefined, unknown>;
5145
5194
  declare const digitalConfigSchema: z.ZodObject<{
5146
5195
  templateId: z.ZodEnum<["minimal", "bold", "card", "aurora", "classic", "spotlight", "marquee", "riso"]>;
5147
5196
  accentColor: z.ZodString;
@@ -5229,6 +5278,15 @@ declare const digitalConfigSchema: z.ZodObject<{
5229
5278
  url?: string | undefined;
5230
5279
  label?: string | undefined;
5231
5280
  }[]>>;
5281
+ tipJar: z.ZodOptional<z.ZodEffects<z.ZodUnknown, {
5282
+ provider: _nfcard_types.TipProviderId;
5283
+ handle: string;
5284
+ label: string;
5285
+ } | {
5286
+ provider: _nfcard_types.TipProviderId;
5287
+ handle: string;
5288
+ label?: undefined;
5289
+ } | undefined, unknown>>;
5232
5290
  }, "strip", z.ZodTypeAny, {
5233
5291
  fontKey: string;
5234
5292
  templateId: "minimal" | "bold" | "card" | "aurora" | "classic" | "spotlight" | "marquee" | "riso";
@@ -5264,6 +5322,15 @@ declare const digitalConfigSchema: z.ZodObject<{
5264
5322
  url: string;
5265
5323
  label: string;
5266
5324
  }[] | undefined;
5325
+ tipJar?: {
5326
+ provider: _nfcard_types.TipProviderId;
5327
+ handle: string;
5328
+ label: string;
5329
+ } | {
5330
+ provider: _nfcard_types.TipProviderId;
5331
+ handle: string;
5332
+ label?: undefined;
5333
+ } | undefined;
5267
5334
  }, {
5268
5335
  fontKey: string;
5269
5336
  templateId: "minimal" | "bold" | "card" | "aurora" | "classic" | "spotlight" | "marquee" | "riso";
@@ -5299,6 +5366,7 @@ declare const digitalConfigSchema: z.ZodObject<{
5299
5366
  url?: string | undefined;
5300
5367
  label?: string | undefined;
5301
5368
  }[] | undefined;
5369
+ tipJar?: unknown;
5302
5370
  }>;
5303
5371
  declare const entrySourceSchema: z.ZodObject<{
5304
5372
  output: z.ZodOptional<z.ZodEnum<["with-card", "digital-only"]>>;
@@ -8792,6 +8860,15 @@ declare const configurationCreateSchema: z.ZodObject<{
8792
8860
  url?: string | undefined;
8793
8861
  label?: string | undefined;
8794
8862
  }[]>>;
8863
+ tipJar: z.ZodOptional<z.ZodEffects<z.ZodUnknown, {
8864
+ provider: _nfcard_types.TipProviderId;
8865
+ handle: string;
8866
+ label: string;
8867
+ } | {
8868
+ provider: _nfcard_types.TipProviderId;
8869
+ handle: string;
8870
+ label?: undefined;
8871
+ } | undefined, unknown>>;
8795
8872
  }, "strip", z.ZodTypeAny, {
8796
8873
  fontKey: string;
8797
8874
  templateId: "minimal" | "bold" | "card" | "aurora" | "classic" | "spotlight" | "marquee" | "riso";
@@ -8827,6 +8904,15 @@ declare const configurationCreateSchema: z.ZodObject<{
8827
8904
  url: string;
8828
8905
  label: string;
8829
8906
  }[] | undefined;
8907
+ tipJar?: {
8908
+ provider: _nfcard_types.TipProviderId;
8909
+ handle: string;
8910
+ label: string;
8911
+ } | {
8912
+ provider: _nfcard_types.TipProviderId;
8913
+ handle: string;
8914
+ label?: undefined;
8915
+ } | undefined;
8830
8916
  }, {
8831
8917
  fontKey: string;
8832
8918
  templateId: "minimal" | "bold" | "card" | "aurora" | "classic" | "spotlight" | "marquee" | "riso";
@@ -8862,6 +8948,7 @@ declare const configurationCreateSchema: z.ZodObject<{
8862
8948
  url?: string | undefined;
8863
8949
  label?: string | undefined;
8864
8950
  }[] | undefined;
8951
+ tipJar?: unknown;
8865
8952
  }>;
8866
8953
  quantity: z.ZodDefault<z.ZodNumber>;
8867
8954
  existingProfileId: z.ZodOptional<z.ZodString>;
@@ -9246,6 +9333,15 @@ declare const configurationCreateSchema: z.ZodObject<{
9246
9333
  url: string;
9247
9334
  label: string;
9248
9335
  }[] | undefined;
9336
+ tipJar?: {
9337
+ provider: _nfcard_types.TipProviderId;
9338
+ handle: string;
9339
+ label: string;
9340
+ } | {
9341
+ provider: _nfcard_types.TipProviderId;
9342
+ handle: string;
9343
+ label?: undefined;
9344
+ } | undefined;
9249
9345
  };
9250
9346
  quantity: number;
9251
9347
  existingProfileId?: string | undefined;
@@ -9612,6 +9708,7 @@ declare const configurationCreateSchema: z.ZodObject<{
9612
9708
  url?: string | undefined;
9613
9709
  label?: string | undefined;
9614
9710
  }[] | undefined;
9711
+ tipJar?: unknown;
9615
9712
  };
9616
9713
  quantity?: number | undefined;
9617
9714
  existingProfileId?: string | undefined;
@@ -13090,6 +13187,15 @@ declare const configurationUpdateSchema: z.ZodObject<{
13090
13187
  url?: string | undefined;
13091
13188
  label?: string | undefined;
13092
13189
  }[]>>;
13190
+ tipJar: z.ZodOptional<z.ZodEffects<z.ZodUnknown, {
13191
+ provider: _nfcard_types.TipProviderId;
13192
+ handle: string;
13193
+ label: string;
13194
+ } | {
13195
+ provider: _nfcard_types.TipProviderId;
13196
+ handle: string;
13197
+ label?: undefined;
13198
+ } | undefined, unknown>>;
13093
13199
  }, "strip", z.ZodTypeAny, {
13094
13200
  fontKey: string;
13095
13201
  templateId: "minimal" | "bold" | "card" | "aurora" | "classic" | "spotlight" | "marquee" | "riso";
@@ -13125,6 +13231,15 @@ declare const configurationUpdateSchema: z.ZodObject<{
13125
13231
  url: string;
13126
13232
  label: string;
13127
13233
  }[] | undefined;
13234
+ tipJar?: {
13235
+ provider: _nfcard_types.TipProviderId;
13236
+ handle: string;
13237
+ label: string;
13238
+ } | {
13239
+ provider: _nfcard_types.TipProviderId;
13240
+ handle: string;
13241
+ label?: undefined;
13242
+ } | undefined;
13128
13243
  }, {
13129
13244
  fontKey: string;
13130
13245
  templateId: "minimal" | "bold" | "card" | "aurora" | "classic" | "spotlight" | "marquee" | "riso";
@@ -13160,6 +13275,7 @@ declare const configurationUpdateSchema: z.ZodObject<{
13160
13275
  url?: string | undefined;
13161
13276
  label?: string | undefined;
13162
13277
  }[] | undefined;
13278
+ tipJar?: unknown;
13163
13279
  }>>;
13164
13280
  quantity: z.ZodOptional<z.ZodNumber>;
13165
13281
  existingProfileId: z.ZodOptional<z.ZodString>;
@@ -13515,6 +13631,15 @@ declare const configurationUpdateSchema: z.ZodObject<{
13515
13631
  url: string;
13516
13632
  label: string;
13517
13633
  }[] | undefined;
13634
+ tipJar?: {
13635
+ provider: _nfcard_types.TipProviderId;
13636
+ handle: string;
13637
+ label: string;
13638
+ } | {
13639
+ provider: _nfcard_types.TipProviderId;
13640
+ handle: string;
13641
+ label?: undefined;
13642
+ } | undefined;
13518
13643
  } | undefined;
13519
13644
  quantity?: number | undefined;
13520
13645
  existingProfileId?: string | undefined;
@@ -13870,6 +13995,7 @@ declare const configurationUpdateSchema: z.ZodObject<{
13870
13995
  url?: string | undefined;
13871
13996
  label?: string | undefined;
13872
13997
  }[] | undefined;
13998
+ tipJar?: unknown;
13873
13999
  } | undefined;
13874
14000
  quantity?: number | undefined;
13875
14001
  existingProfileId?: string | undefined;
@@ -13976,6 +14102,15 @@ declare const cardUpdateSchema: z.ZodObject<{
13976
14102
  url?: string | undefined;
13977
14103
  label?: string | undefined;
13978
14104
  }[]>>>>;
14105
+ tipJar: z.ZodOptional<z.ZodOptional<z.ZodOptional<z.ZodEffects<z.ZodUnknown, {
14106
+ provider: _nfcard_types.TipProviderId;
14107
+ handle: string;
14108
+ label: string;
14109
+ } | {
14110
+ provider: _nfcard_types.TipProviderId;
14111
+ handle: string;
14112
+ label?: undefined;
14113
+ } | undefined, unknown>>>>;
13979
14114
  profilePhotoUrl: z.ZodOptional<z.ZodUnion<[z.ZodOptional<z.ZodString>, z.ZodLiteral<"">]>>;
13980
14115
  companyLogoUrl: z.ZodOptional<z.ZodUnion<[z.ZodOptional<z.ZodString>, z.ZodLiteral<"">]>>;
13981
14116
  }, "strip", z.ZodTypeAny, {
@@ -14015,6 +14150,15 @@ declare const cardUpdateSchema: z.ZodObject<{
14015
14150
  url: string;
14016
14151
  label: string;
14017
14152
  }[] | undefined;
14153
+ tipJar?: {
14154
+ provider: _nfcard_types.TipProviderId;
14155
+ handle: string;
14156
+ label: string;
14157
+ } | {
14158
+ provider: _nfcard_types.TipProviderId;
14159
+ handle: string;
14160
+ label?: undefined;
14161
+ } | undefined;
14018
14162
  }, {
14019
14163
  profilePhotoUrl?: string | undefined;
14020
14164
  companyLogoUrl?: string | undefined;
@@ -14052,6 +14196,7 @@ declare const cardUpdateSchema: z.ZodObject<{
14052
14196
  url?: string | undefined;
14053
14197
  label?: string | undefined;
14054
14198
  }[] | undefined;
14199
+ tipJar?: unknown;
14055
14200
  }>>;
14056
14201
  status: z.ZodOptional<z.ZodEnum<["active", "suspended", "archived"]>>;
14057
14202
  }, "strip", z.ZodTypeAny, {
@@ -14093,6 +14238,15 @@ declare const cardUpdateSchema: z.ZodObject<{
14093
14238
  url: string;
14094
14239
  label: string;
14095
14240
  }[] | undefined;
14241
+ tipJar?: {
14242
+ provider: _nfcard_types.TipProviderId;
14243
+ handle: string;
14244
+ label: string;
14245
+ } | {
14246
+ provider: _nfcard_types.TipProviderId;
14247
+ handle: string;
14248
+ label?: undefined;
14249
+ } | undefined;
14096
14250
  } | undefined;
14097
14251
  }, {
14098
14252
  status?: "active" | "suspended" | "archived" | undefined;
@@ -14133,6 +14287,7 @@ declare const cardUpdateSchema: z.ZodObject<{
14133
14287
  url?: string | undefined;
14134
14288
  label?: string | undefined;
14135
14289
  }[] | undefined;
14290
+ tipJar?: unknown;
14136
14291
  } | undefined;
14137
14292
  }>;
14138
14293
  declare const adminCardCreateSchema: z.ZodObject<{
@@ -14312,6 +14467,15 @@ declare const adminCardCreateSchema: z.ZodObject<{
14312
14467
  url?: string | undefined;
14313
14468
  label?: string | undefined;
14314
14469
  }[]>>;
14470
+ tipJar: z.ZodOptional<z.ZodEffects<z.ZodUnknown, {
14471
+ provider: _nfcard_types.TipProviderId;
14472
+ handle: string;
14473
+ label: string;
14474
+ } | {
14475
+ provider: _nfcard_types.TipProviderId;
14476
+ handle: string;
14477
+ label?: undefined;
14478
+ } | undefined, unknown>>;
14315
14479
  }, "strip", z.ZodTypeAny, {
14316
14480
  fontKey: string;
14317
14481
  templateId: "minimal" | "bold" | "card" | "aurora" | "classic" | "spotlight" | "marquee" | "riso";
@@ -14347,6 +14511,15 @@ declare const adminCardCreateSchema: z.ZodObject<{
14347
14511
  url: string;
14348
14512
  label: string;
14349
14513
  }[] | undefined;
14514
+ tipJar?: {
14515
+ provider: _nfcard_types.TipProviderId;
14516
+ handle: string;
14517
+ label: string;
14518
+ } | {
14519
+ provider: _nfcard_types.TipProviderId;
14520
+ handle: string;
14521
+ label?: undefined;
14522
+ } | undefined;
14350
14523
  }, {
14351
14524
  fontKey: string;
14352
14525
  templateId: "minimal" | "bold" | "card" | "aurora" | "classic" | "spotlight" | "marquee" | "riso";
@@ -14382,6 +14555,7 @@ declare const adminCardCreateSchema: z.ZodObject<{
14382
14555
  url?: string | undefined;
14383
14556
  label?: string | undefined;
14384
14557
  }[] | undefined;
14558
+ tipJar?: unknown;
14385
14559
  }>;
14386
14560
  physicalConfig: z.ZodOptional<z.ZodEffects<z.ZodEffects<z.ZodObject<{
14387
14561
  material: z.ZodEnum<["plastic", "3dprint_standard", "metal", "bamboo"]>;
@@ -17730,6 +17904,15 @@ declare const adminCardCreateSchema: z.ZodObject<{
17730
17904
  url: string;
17731
17905
  label: string;
17732
17906
  }[] | undefined;
17907
+ tipJar?: {
17908
+ provider: _nfcard_types.TipProviderId;
17909
+ handle: string;
17910
+ label: string;
17911
+ } | {
17912
+ provider: _nfcard_types.TipProviderId;
17913
+ handle: string;
17914
+ label?: undefined;
17915
+ } | undefined;
17733
17916
  };
17734
17917
  targetUserId: string;
17735
17918
  physicalConfig?: {
@@ -18083,6 +18266,7 @@ declare const adminCardCreateSchema: z.ZodObject<{
18083
18266
  url?: string | undefined;
18084
18267
  label?: string | undefined;
18085
18268
  }[] | undefined;
18269
+ tipJar?: unknown;
18086
18270
  };
18087
18271
  targetUserId: string;
18088
18272
  physicalConfig?: {
@@ -18647,6 +18831,15 @@ declare const profileCreateSchema: z.ZodObject<{
18647
18831
  url?: string | undefined;
18648
18832
  label?: string | undefined;
18649
18833
  }[]>>;
18834
+ tipJar: z.ZodOptional<z.ZodEffects<z.ZodUnknown, {
18835
+ provider: _nfcard_types.TipProviderId;
18836
+ handle: string;
18837
+ label: string;
18838
+ } | {
18839
+ provider: _nfcard_types.TipProviderId;
18840
+ handle: string;
18841
+ label?: undefined;
18842
+ } | undefined, unknown>>;
18650
18843
  }, "strip", z.ZodTypeAny, {
18651
18844
  fontKey: string;
18652
18845
  templateId: "minimal" | "bold" | "card" | "aurora" | "classic" | "spotlight" | "marquee" | "riso";
@@ -18682,6 +18875,15 @@ declare const profileCreateSchema: z.ZodObject<{
18682
18875
  url: string;
18683
18876
  label: string;
18684
18877
  }[] | undefined;
18878
+ tipJar?: {
18879
+ provider: _nfcard_types.TipProviderId;
18880
+ handle: string;
18881
+ label: string;
18882
+ } | {
18883
+ provider: _nfcard_types.TipProviderId;
18884
+ handle: string;
18885
+ label?: undefined;
18886
+ } | undefined;
18685
18887
  }, {
18686
18888
  fontKey: string;
18687
18889
  templateId: "minimal" | "bold" | "card" | "aurora" | "classic" | "spotlight" | "marquee" | "riso";
@@ -18717,6 +18919,7 @@ declare const profileCreateSchema: z.ZodObject<{
18717
18919
  url?: string | undefined;
18718
18920
  label?: string | undefined;
18719
18921
  }[] | undefined;
18922
+ tipJar?: unknown;
18720
18923
  }>;
18721
18924
  visibility: z.ZodDefault<z.ZodEnum<["public", "private", "link_only"]>>;
18722
18925
  accessPin: z.ZodUnion<[z.ZodOptional<z.ZodString>, z.ZodLiteral<"">]>;
@@ -18781,6 +18984,15 @@ declare const profileCreateSchema: z.ZodObject<{
18781
18984
  url: string;
18782
18985
  label: string;
18783
18986
  }[] | undefined;
18987
+ tipJar?: {
18988
+ provider: _nfcard_types.TipProviderId;
18989
+ handle: string;
18990
+ label: string;
18991
+ } | {
18992
+ provider: _nfcard_types.TipProviderId;
18993
+ handle: string;
18994
+ label?: undefined;
18995
+ } | undefined;
18784
18996
  };
18785
18997
  label?: string | undefined;
18786
18998
  cardId?: string | undefined;
@@ -18846,6 +19058,7 @@ declare const profileCreateSchema: z.ZodObject<{
18846
19058
  url?: string | undefined;
18847
19059
  label?: string | undefined;
18848
19060
  }[] | undefined;
19061
+ tipJar?: unknown;
18849
19062
  };
18850
19063
  label?: string | undefined;
18851
19064
  visibility?: "public" | "private" | "link_only" | undefined;
@@ -19038,6 +19251,15 @@ declare const signupWithProfileSchema: z.ZodObject<{
19038
19251
  url?: string | undefined;
19039
19252
  label?: string | undefined;
19040
19253
  }[]>>;
19254
+ tipJar: z.ZodOptional<z.ZodEffects<z.ZodUnknown, {
19255
+ provider: _nfcard_types.TipProviderId;
19256
+ handle: string;
19257
+ label: string;
19258
+ } | {
19259
+ provider: _nfcard_types.TipProviderId;
19260
+ handle: string;
19261
+ label?: undefined;
19262
+ } | undefined, unknown>>;
19041
19263
  }, "strip", z.ZodTypeAny, {
19042
19264
  fontKey: string;
19043
19265
  templateId: "minimal" | "bold" | "card" | "aurora" | "classic" | "spotlight" | "marquee" | "riso";
@@ -19073,6 +19295,15 @@ declare const signupWithProfileSchema: z.ZodObject<{
19073
19295
  url: string;
19074
19296
  label: string;
19075
19297
  }[] | undefined;
19298
+ tipJar?: {
19299
+ provider: _nfcard_types.TipProviderId;
19300
+ handle: string;
19301
+ label: string;
19302
+ } | {
19303
+ provider: _nfcard_types.TipProviderId;
19304
+ handle: string;
19305
+ label?: undefined;
19306
+ } | undefined;
19076
19307
  }, {
19077
19308
  fontKey: string;
19078
19309
  templateId: "minimal" | "bold" | "card" | "aurora" | "classic" | "spotlight" | "marquee" | "riso";
@@ -19108,6 +19339,7 @@ declare const signupWithProfileSchema: z.ZodObject<{
19108
19339
  url?: string | undefined;
19109
19340
  label?: string | undefined;
19110
19341
  }[] | undefined;
19342
+ tipJar?: unknown;
19111
19343
  }>;
19112
19344
  }, "strip", z.ZodTypeAny, {
19113
19345
  email: string;
@@ -19170,6 +19402,15 @@ declare const signupWithProfileSchema: z.ZodObject<{
19170
19402
  url: string;
19171
19403
  label: string;
19172
19404
  }[] | undefined;
19405
+ tipJar?: {
19406
+ provider: _nfcard_types.TipProviderId;
19407
+ handle: string;
19408
+ label: string;
19409
+ } | {
19410
+ provider: _nfcard_types.TipProviderId;
19411
+ handle: string;
19412
+ label?: undefined;
19413
+ } | undefined;
19173
19414
  };
19174
19415
  password: string;
19175
19416
  acceptedTerms: true;
@@ -19239,6 +19480,7 @@ declare const signupWithProfileSchema: z.ZodObject<{
19239
19480
  url?: string | undefined;
19240
19481
  label?: string | undefined;
19241
19482
  }[] | undefined;
19483
+ tipJar?: unknown;
19242
19484
  };
19243
19485
  password: string;
19244
19486
  acceptedTerms: true;
@@ -19427,6 +19669,15 @@ declare const profileUpdateSchema: z.ZodObject<{
19427
19669
  url?: string | undefined;
19428
19670
  label?: string | undefined;
19429
19671
  }[]>>>>;
19672
+ tipJar: z.ZodOptional<z.ZodOptional<z.ZodOptional<z.ZodEffects<z.ZodUnknown, {
19673
+ provider: _nfcard_types.TipProviderId;
19674
+ handle: string;
19675
+ label: string;
19676
+ } | {
19677
+ provider: _nfcard_types.TipProviderId;
19678
+ handle: string;
19679
+ label?: undefined;
19680
+ } | undefined, unknown>>>>;
19430
19681
  profilePhotoUrl: z.ZodOptional<z.ZodUnion<[z.ZodOptional<z.ZodString>, z.ZodLiteral<"">]>>;
19431
19682
  companyLogoUrl: z.ZodOptional<z.ZodUnion<[z.ZodOptional<z.ZodString>, z.ZodLiteral<"">]>>;
19432
19683
  }, "strip", z.ZodTypeAny, {
@@ -19466,6 +19717,15 @@ declare const profileUpdateSchema: z.ZodObject<{
19466
19717
  url: string;
19467
19718
  label: string;
19468
19719
  }[] | undefined;
19720
+ tipJar?: {
19721
+ provider: _nfcard_types.TipProviderId;
19722
+ handle: string;
19723
+ label: string;
19724
+ } | {
19725
+ provider: _nfcard_types.TipProviderId;
19726
+ handle: string;
19727
+ label?: undefined;
19728
+ } | undefined;
19469
19729
  }, {
19470
19730
  profilePhotoUrl?: string | undefined;
19471
19731
  companyLogoUrl?: string | undefined;
@@ -19503,6 +19763,7 @@ declare const profileUpdateSchema: z.ZodObject<{
19503
19763
  url?: string | undefined;
19504
19764
  label?: string | undefined;
19505
19765
  }[] | undefined;
19766
+ tipJar?: unknown;
19506
19767
  }>>;
19507
19768
  visibility: z.ZodOptional<z.ZodEnum<["public", "private", "link_only"]>>;
19508
19769
  accessPin: z.ZodUnion<[z.ZodOptional<z.ZodString>, z.ZodLiteral<"">]>;
@@ -19570,6 +19831,15 @@ declare const profileUpdateSchema: z.ZodObject<{
19570
19831
  url: string;
19571
19832
  label: string;
19572
19833
  }[] | undefined;
19834
+ tipJar?: {
19835
+ provider: _nfcard_types.TipProviderId;
19836
+ handle: string;
19837
+ label: string;
19838
+ } | {
19839
+ provider: _nfcard_types.TipProviderId;
19840
+ handle: string;
19841
+ label?: undefined;
19842
+ } | undefined;
19573
19843
  } | undefined;
19574
19844
  icon?: string | undefined;
19575
19845
  accessPin?: string | undefined;
@@ -19637,6 +19907,7 @@ declare const profileUpdateSchema: z.ZodObject<{
19637
19907
  url?: string | undefined;
19638
19908
  label?: string | undefined;
19639
19909
  }[] | undefined;
19910
+ tipJar?: unknown;
19640
19911
  } | undefined;
19641
19912
  icon?: string | undefined;
19642
19913
  accessPin?: string | undefined;
@@ -19785,4 +20056,4 @@ declare function validatePhysicalConfig(data: unknown): ValidationResult<z.infer
19785
20056
  declare function validateDigitalConfig(data: unknown): ValidationResult<z.infer<typeof digitalConfigSchema>>;
19786
20057
  declare function validateConfiguration(data: unknown): ValidationResult<z.infer<typeof configurationCreateSchema>>;
19787
20058
 
19788
- export { CARD_H_MM, CARD_W_MM, CHIP_RADIUS_MM, CHIP_SCALE_FLOOR, type ChipAxis, EDGE_MARGIN_MM, MAX_CTA_BUTTONS, MIN_WALL_MM, NFC_COIL_DIAMETER_MM, NFC_RADIAL_CLEARANCE_MM, type PrintabilityCtx, type Pt, QR_MATRIX_CELLS, QR_MIN_SIZE_MM, QR_MODULE_MIN_MM, TEXT_MIN_EM_MM, type ValidationResult, type Violation, adminCardCreateSchema, cardAssetSchema, cardDesignMaterialSchema, cardDesignPatternSchema, cardDesignSchema, cardElementSchema, cardElementsSchema, cardProfileUpdateSchema, cardUpdateSchema, chipAnchorToMm, comboIdSchema, configurationCreateSchema, configurationUpdateSchema, contactDetailTogglesSchema, contactExchangeSchema, cornersOnCard, createOrderSchema, ctaButtonSchema, ctaButtonsSchema, digitalConfigSchema, digitalVisibilitySchema, elementPrintability, elementTransformSchema, entrySourceSchema, hubConfigSchema, hubProfilesUpdateSchema, isFiniteTransform, materialSchema, mmToNearestChipAnchor, obbCorners, patternFamilySchema, physicalConfigSchema, physicalContentOverridesSchema, profileAccessTokenCreateSchema, profileCreateSchema, profilePinVerifySchema, profileUpdateSchema, profileUserDataSchema, profileVisibilitySchema, sanitizeHttpUrl, sanitizeText, signupWithProfileSchema, socialLinksSchema, socialOverridesSchema, userDataSchema, validateConfiguration, validateDigitalConfig, validatePhysicalConfig, validateUserData, webAddressSchema };
20059
+ export { ADVANCE_FALLBACK_EM, CARD_H_MM, CARD_W_MM, CHIP_RADIUS_MM, CHIP_SCALE_FLOOR, type ChipAxis, EDGE_MARGIN_MM, EMPTY_TEXT_FLOOR_EM, ITALIC_OVERHANG_EM, MAX_CTA_BUTTONS, MIN_WALL_MM, NFC_COIL_DIAMETER_MM, NFC_RADIAL_CLEARANCE_MM, type PrintabilityCtx, type Pt, QR_MATRIX_CELLS, QR_MIN_SIZE_MM, QR_MODULE_MIN_MM, TEXT_LINE_FACTOR_EM, TEXT_MIN_EM_MM, type ValidationResult, type Violation, adminCardCreateSchema, cardAssetSchema, cardDesignMaterialSchema, cardDesignPatternSchema, cardDesignSchema, cardElementSchema, cardElementsSchema, cardProfileUpdateSchema, cardUpdateSchema, chipAnchorToMm, chipPlacementMaxXY, comboIdSchema, configurationCreateSchema, configurationUpdateSchema, contactDetailTogglesSchema, contactExchangeSchema, cornersOnCard, createOrderSchema, ctaButtonSchema, ctaButtonsSchema, digitalConfigSchema, digitalVisibilitySchema, elementPrintability, elementTransformSchema, entrySourceSchema, footprintOf, hubConfigSchema, hubProfilesUpdateSchema, isFiniteTransform, materialSchema, mmToNearestChipAnchor, obbCorners, patternFamilySchema, physicalConfigSchema, physicalContentOverridesSchema, profileAccessTokenCreateSchema, profileCreateSchema, profilePinVerifySchema, profileUpdateSchema, profileUserDataSchema, profileVisibilitySchema, sanitizeHttpUrl, sanitizeText, signupWithProfileSchema, socialLinksSchema, socialOverridesSchema, textAdvanceEm, tipJarSchema, userDataSchema, validateConfiguration, validateDigitalConfig, validatePhysicalConfig, validateUserData, webAddressSchema };
package/dist/index.js CHANGED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nfcard/validation",
3
- "version": "0.18.0",
3
+ "version": "0.20.0",
4
4
  "description": "Shared Zod validation schemas for the NFCard product family.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -17,7 +17,7 @@
17
17
  ],
18
18
  "dependencies": {
19
19
  "zod": "^3.24.0",
20
- "@nfcard/types": "0.13.2"
20
+ "@nfcard/types": "0.14.0"
21
21
  },
22
22
  "devDependencies": {
23
23
  "tsup": "^8.0.0"
@@ -163,6 +163,48 @@ describe('cardDesignSchema — free-transform element layer (NFCARD-197/198)', (
163
163
  expect(codes(res)).toContain('elementOffCard')
164
164
  })
165
165
 
166
+ it('NFCARD-421: rejects a chip nudged past the pattern-engine placement envelope', () => {
167
+ // The repro design (order 99d2ef93): chip near the top-left corner at
168
+ // (−24.2, −11.15), scale 1. |y| 11.15 > envelope maxY 10.75, so the pocket
169
+ // wall gets cut at cardgen batch time. The looser elementOffCard OBB check
170
+ // (rim 25.5) let it through checkout before this gate.
171
+ const res = cardDesignSchema.safeParse(
172
+ withElements([chipEl({ transform: { xMm: -24.2, yMm: -11.15, rotationDeg: 0, scale: 1, z: 0 } })], {
173
+ layout: { chipAnchor: { col: 0, row: 0 } },
174
+ }),
175
+ )
176
+ expect(res.success).toBe(false)
177
+ expect(codes(res)).toContain('chipOutsideEnvelope')
178
+ })
179
+
180
+ it('NFCARD-421: accepts a chip sitting exactly at the envelope edge', () => {
181
+ // maxY at scale 1 = 54/2 − 1.5 − 13.75 − 1 = 10.75.
182
+ const res = cardDesignSchema.safeParse(
183
+ withElements([chipEl({ transform: { xMm: 0, yMm: 10.75, rotationDeg: 0, scale: 1, z: 0 } })], {
184
+ layout: { chipAnchor: { col: 1, row: 2 } },
185
+ }),
186
+ )
187
+ expect(res.success).toBe(true)
188
+ })
189
+
190
+ it('NFCARD-421: the placement envelope tightens as the chip scales up', () => {
191
+ // A chip at y = 9 is inside the scale-1 envelope (10.75) but a chip scaled to
192
+ // 1.3 (island Ø35.75) no longer clears the rim there — envelope maxY ≈ 6.63.
193
+ const ok = cardDesignSchema.safeParse(
194
+ withElements([chipEl({ transform: { xMm: 0, yMm: 9, rotationDeg: 0, scale: 1, z: 0 } })], {
195
+ layout: { chipAnchor: { col: 1, row: 2 } },
196
+ }),
197
+ )
198
+ expect(ok.success).toBe(true)
199
+ const tooBig = cardDesignSchema.safeParse(
200
+ withElements([chipEl({ transform: { xMm: 0, yMm: 9, rotationDeg: 0, scale: 1.3, z: 0 } })], {
201
+ layout: { chipAnchor: { col: 1, row: 2 } },
202
+ }),
203
+ )
204
+ expect(tooBig.success).toBe(false)
205
+ expect(codes(tooBig)).toContain('chipOutsideEnvelope')
206
+ })
207
+
166
208
  it('rejects duplicate element ids', () => {
167
209
  const res = cardDesignSchema.safeParse(withElements([chipEl(), textEl({ id: 'chip1' })]))
168
210
  expect(res.success).toBe(false)
package/src/index.ts CHANGED
Binary file
@@ -0,0 +1,107 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import type { CardElement } from '@nfcard/types'
3
+ import {
4
+ ADVANCE_FALLBACK_EM,
5
+ ITALIC_OVERHANG_EM,
6
+ TEXT_LINE_FACTOR_EM,
7
+ elementPrintability,
8
+ footprintOf,
9
+ textAdvanceEm,
10
+ CARD_W_MM,
11
+ EDGE_MARGIN_MM,
12
+ } from './printability'
13
+
14
+ // ── NFCARD-530 — per-glyph advance-table text footprint ──
15
+ // The flat 0.6 em/char estimate over-reserved ~25 % on lowercase strings (false
16
+ // elementOffCard far from the rim) and UNDER-reserved wide glyphs (W = 1.089 em).
17
+ // The tables are generated from the Instrument Sans webfont; sum-of-advances is a
18
+ // verified upper bound on the kerned ink (kerning in this font only subtracts).
19
+
20
+ const textEl = (over: Partial<Record<string, unknown>> = {}): CardElement =>
21
+ ({
22
+ id: 't1',
23
+ type: 'text',
24
+ text: 'Anna Kovács',
25
+ sizeMm: 4,
26
+ weight: 400,
27
+ role: 'text',
28
+ transform: { xMm: 0, yMm: 0, rotationDeg: 0, scale: 1, z: 1 },
29
+ ...over,
30
+ }) as unknown as CardElement
31
+
32
+ const ctx = { assetsById: new Map() }
33
+
34
+ describe('NFCARD-530 — textAdvanceEm (per-glyph advance tables)', () => {
35
+ it('sums real glyph advances — narrow and wide glyphs differ (the flat 0.6 could not)', () => {
36
+ expect(textAdvanceEm('l', 400)).toBeCloseTo(0.24, 6)
37
+ expect(textAdvanceEm('W', 400)).toBeCloseTo(1.089, 6)
38
+ expect(textAdvanceEm('Hello', 400)).toBeCloseTo(0.736 + 0.564 + 0.24 + 0.24 + 0.584, 6)
39
+ })
40
+
41
+ it('weight ≥ 600 uses the SemiBold metric set', () => {
42
+ expect(textAdvanceEm('a', 600)).toBeCloseTo(0.571, 6)
43
+ expect(textAdvanceEm('a', 700)).toBeCloseTo(0.571, 6)
44
+ expect(textAdvanceEm('a', 400)).toBeCloseTo(0.533, 6)
45
+ })
46
+
47
+ it('unknown glyphs reserve the generous fallback (over-reserve, never under)', () => {
48
+ expect(textAdvanceEm('あ', 400)).toBeCloseTo(ADVANCE_FALLBACK_EM, 6)
49
+ expect(textAdvanceEm('a😀b', 400)).toBeCloseTo(0.533 + ADVANCE_FALLBACK_EM + 0.606, 6)
50
+ })
51
+ })
52
+
53
+ describe('NFCARD-530 — footprintOf text branch', () => {
54
+ it('width = widest line advance sum × sizeMm; spaces cost their real advance (untrimmed)', () => {
55
+ const el = textEl({ text: 'a a', sizeMm: 4 })
56
+ expect(footprintOf(el).w).toBeCloseTo((0.533 + 0.2 + 0.533) * 4, 6)
57
+ })
58
+
59
+ it('multi-line: widest line wins the width; height gains the 1.32 em line stack', () => {
60
+ const el = textEl({ text: 'mmmm\nil', sizeMm: 4 })
61
+ const wide = textAdvanceEm('mmmm', 400)
62
+ expect(footprintOf(el).w).toBeCloseTo(wide * 4, 6)
63
+ expect(footprintOf(el).h).toBeCloseTo(4 * (1.3 + TEXT_LINE_FACTOR_EM), 6)
64
+ })
65
+
66
+ it('ITALIC charges the slant-overhang term — the ink of slanted tails pokes past the advance box', () => {
67
+ // Measured against the webfont: italic ink exceeds the advance sum by ≤ 0.23 em
68
+ // ("jjj" descender tails are the worst case); upright overhang is exactly 0. Without
69
+ // this term the "upper bound on rendered ink" claim silently broke for the italic toggle.
70
+ const upright = footprintOf(textEl({ text: 'jjj' }))
71
+ const italic = footprintOf(textEl({ text: 'jjj', italic: true }))
72
+ expect(italic.w - upright.w).toBeCloseTo(ITALIC_OVERHANG_EM * 4, 6)
73
+ expect(italic.h).toBeCloseTo(upright.h, 6)
74
+ })
75
+
76
+ it('empty text keeps the historical 0.6 em floor', () => {
77
+ expect(footprintOf(textEl({ text: '' })).w).toBeCloseTo(0.6 * 4, 6)
78
+ expect(footprintOf(textEl({ text: '' })).h).toBeCloseTo(4 * 1.3, 6)
79
+ })
80
+
81
+ it('SAFETY: wide-glyph strings now reserve MORE than the old flat 0.6 em/char', () => {
82
+ const el = textEl({ text: 'WWWW' })
83
+ const oldFlat = 4 * 4 * 0.6
84
+ expect(footprintOf(el).w).toBeGreaterThan(oldFlat)
85
+ })
86
+ })
87
+
88
+ describe('NFCARD-530 — elementOffCard verdict with the accurate estimate', () => {
89
+ const rimX = CARD_W_MM / 2 - EDGE_MARGIN_MM
90
+
91
+ it('no longer flags a long lowercase string sitting clearly inside the rim', () => {
92
+ // 18-char lowercase @ 2 mm × scale 2: flat estimate = 43.2 mm wide; advance sum = 38.3 mm.
93
+ // Place it in the band where the FLAT box crossed the rim but the accurate one does not.
94
+ const text = 'asdasdqwet4t6 7e56'
95
+ const el = textEl({ text, sizeMm: 2, transform: { xMm: -21, yMm: 0, rotationDeg: 0, scale: 2, z: 1 } })
96
+ const accurateHalf = (textAdvanceEm(text, 400) * 2 * 2) / 2
97
+ const flatHalf = (text.length * 2 * 0.6 * 2) / 2
98
+ expect(-21 - flatHalf).toBeLessThan(-rimX) // the OLD estimate tripped here…
99
+ expect(-21 - accurateHalf).toBeGreaterThan(-rimX) // …the accurate one fits…
100
+ expect(elementPrintability(el, ctx).map((v) => v.code)).toEqual([]) // …and the gate agrees.
101
+ })
102
+
103
+ it('still rejects text genuinely past the rim (the historical xMm 60 case)', () => {
104
+ const el = textEl({ transform: { xMm: 60, yMm: 0, rotationDeg: 0, scale: 1, z: 1 } })
105
+ expect(elementPrintability(el, ctx).map((v) => v.code)).toContain('elementOffCard')
106
+ })
107
+ })
@@ -98,13 +98,25 @@ export function cornersOnCard(corners: Pt[], margin = EDGE_MARGIN_MM): boolean {
98
98
  const ANCHOR_NORM = [-0.85, 0, 0.85] as const
99
99
  export type ChipAxis = 0 | 1 | 2
100
100
 
101
- function chipMaxXY(): { maxX: number; maxY: number } {
101
+ /**
102
+ * The chip PLACEMENT envelope: the region the chip *centre* must stay inside so
103
+ * the pattern-engine's hole-masking + rim clearance can keep the NFC pocket
104
+ * enclosed. Sized so the (scaled) Ø27.5·scale chip island clears the rim margin
105
+ * with a 1 mm buffer. Scale-aware so a grown chip tightens the envelope. Mirrors
106
+ * cardgen `chip_placement_max_xy`; `chipMaxXY()` is exactly this at scale 1 (the
107
+ * domain of the legacy 3×3 anchor lattice).
108
+ */
109
+ export function chipPlacementMaxXY(scale: number): { maxX: number; maxY: number } {
102
110
  return {
103
- maxX: CARD_W_MM / 2 - EDGE_MARGIN_MM - CHIP_RADIUS_MM - 1,
104
- maxY: CARD_H_MM / 2 - EDGE_MARGIN_MM - CHIP_RADIUS_MM - 1,
111
+ maxX: CARD_W_MM / 2 - EDGE_MARGIN_MM - CHIP_RADIUS_MM * scale - 1,
112
+ maxY: CARD_H_MM / 2 - EDGE_MARGIN_MM - CHIP_RADIUS_MM * scale - 1,
105
113
  }
106
114
  }
107
115
 
116
+ function chipMaxXY(): { maxX: number; maxY: number } {
117
+ return chipPlacementMaxXY(1)
118
+ }
119
+
108
120
  /** Grid cell → card-local mm centre (matches pattern-engine `chipCentreForAnchor`). */
109
121
  export function chipAnchorToMm(col: ChipAxis, row: ChipAxis): Pt {
110
122
  const { maxX, maxY } = chipMaxXY()
@@ -141,9 +153,88 @@ export interface Violation {
141
153
  message: string
142
154
  }
143
155
 
144
- /** Estimated card footprint (w×h, pre-scale) used for the on-card OBB check. The
145
- * editor measures real glyph widths; this is the schema-side backstop. */
146
- function footprintOf(el: CardElement): { w: number; h: number } {
156
+ // ── Text width estimate: per-glyph advance tables (NFCARD-530) ──
157
+ // GENERATED from the Instrument Sans webfont the card actually renders with (canvas
158
+ // measureText at 100 px → em, Chrome). The font ships two metric sets — <600 maps to the
159
+ // Regular face, ≥600 to the SemiBold face — and italic advances equal the upright ones
160
+ // (the italic INK overhang past the advance box is charged separately, ITALIC_OVERHANG_EM).
161
+ // Kerning in this font only ever SUBTRACTS (verified across mixed-case/HU samples:
162
+ // max real/sum ratio = 1.0), so a plain sum of advances is a guaranteed UPPER bound on
163
+ // the rendered ink, typically within 1–8 %. This replaces the flat 0.6 em/char estimate,
164
+ // which over-reserved ~25 % on lowercase strings (false `elementOffCard` far from the
165
+ // rim — NFCARD-530) while UNDER-reserving wide glyphs (`W` = 1.089 em — a real print
166
+ // overflow hole). cardgen `validate.py` mirrors these tables 1:1 (parity golden).
167
+ const ADVANCE_EM_REGULAR: Record<string, number> = {
168
+ '0': 0.666, '1': 0.391, '2': 0.545, '3': 0.574, '4': 0.6, '5': 0.574, '6': 0.599, '7': 0.532,
169
+ '8': 0.582, '9': 0.61, ' ': 0.2, '!': 0.273, '"': 0.384, '#': 0.716, '$': 0.608, '%': 0.786,
170
+ '&': 0.755, "'": 0.232, '(': 0.406, ')': 0.406, '*': 0.408, '+': 0.531, ',': 0.255, '-': 0.506,
171
+ '.': 0.255, '/': 0.443, ':': 0.255, ';': 0.255, '<': 0.531, '=': 0.531, '>': 0.531, '?': 0.567,
172
+ '@': 0.853, 'A': 0.728, 'B': 0.636, 'C': 0.741, 'D': 0.752, 'E': 0.638, 'F': 0.602, 'G': 0.765,
173
+ 'H': 0.736, 'I': 0.254, 'J': 0.455, 'K': 0.692, 'L': 0.588, 'M': 0.906, 'N': 0.736, 'O': 0.786,
174
+ 'P': 0.656, 'Q': 0.787, 'R': 0.656, 'S': 0.608, 'T': 0.648, 'U': 0.712, 'V': 0.728, 'W': 1.089,
175
+ 'X': 0.688, 'Y': 0.676, 'Z': 0.623, '[': 0.406, '\\': 0.443, ']': 0.406, '^': 0.531, '_': 0.426,
176
+ '`': 0.354, 'a': 0.533, 'b': 0.606, 'c': 0.533, 'd': 0.606, 'e': 0.564, 'f': 0.354, 'g': 0.606,
177
+ 'h': 0.599, 'i': 0.24, 'j': 0.24, 'k': 0.535, 'l': 0.24, 'm': 0.922, 'n': 0.599, 'o': 0.584,
178
+ 'p': 0.606, 'q': 0.606, 'r': 0.375, 's': 0.473, 't': 0.377, 'u': 0.589, 'v': 0.523, 'w': 0.767,
179
+ 'x': 0.551, 'y': 0.523, 'z': 0.496, '{': 0.406, '|': 0.242, '}': 0.406, '~': 0.531, 'Á': 0.728,
180
+ 'É': 0.638, 'Í': 0.254, 'Ó': 0.786, 'Ö': 0.786, 'Ő': 0.786, 'Ú': 0.712, 'Ü': 0.712, 'Ű': 0.712,
181
+ 'á': 0.533, 'é': 0.564, 'í': 0.24, 'ó': 0.584, 'ö': 0.584, 'ő': 0.584, 'ú': 0.589, 'ü': 0.589,
182
+ 'ű': 0.589, 'Ä': 0.728, 'ä': 0.533, 'Ë': 0.638, 'ë': 0.564, 'Ï': 0.254, 'ï': 0.24, 'Ñ': 0.736,
183
+ 'ñ': 0.599, 'Ç': 0.741, 'ç': 0.533, 'ß': 0.612, 'ẞ': 0.713, '€': 0.701, '°': 0.386, '§': 0.498,
184
+ '·': 0.103, '•': 0.306, '–': 0.586, '—': 0.886, '…': 0.655, '‘': 0.255, '’': 0.255, '“': 0.409,
185
+ '”': 0.409, '„': 0.398, '±': 0.5488, '×': 0.531, '÷': 0.531,
186
+ }
187
+ const ADVANCE_EM_BOLD: Record<string, number> = {
188
+ '0': 0.682, '1': 0.385, '2': 0.563, '3': 0.582, '4': 0.622, '5': 0.585, '6': 0.623, '7': 0.575,
189
+ '8': 0.606, '9': 0.624, ' ': 0.19, '!': 0.306, '"': 0.482, '#': 0.732, '$': 0.652, '%': 0.786,
190
+ '&': 0.774, "'": 0.268, '(': 0.456, ')': 0.456, '*': 0.441, '+': 0.551, ',': 0.285, '-': 0.486,
191
+ '.': 0.285, '/': 0.445, ':': 0.285, ';': 0.285, '<': 0.551, '=': 0.551, '>': 0.551, '?': 0.587,
192
+ '@': 0.874, 'A': 0.736, 'B': 0.649, 'C': 0.75, 'D': 0.756, 'E': 0.626, 'F': 0.59, 'G': 0.763,
193
+ 'H': 0.72, 'I': 0.254, 'J': 0.413, 'K': 0.722, 'L': 0.59, 'M': 0.894, 'N': 0.72, 'O': 0.798,
194
+ 'P': 0.672, 'Q': 0.815, 'R': 0.668, 'S': 0.652, 'T': 0.672, 'U': 0.7, 'V': 0.736, 'W': 1.086,
195
+ 'X': 0.716, 'Y': 0.712, 'Z': 0.635, '[': 0.456, '\\': 0.445, ']': 0.456, '^': 0.551, '_': 0.486,
196
+ '`': 0.354, 'a': 0.571, 'b': 0.628, 'c': 0.562, 'd': 0.628, 'e': 0.574, 'f': 0.378, 'g': 0.628,
197
+ 'h': 0.619, 'i': 0.27, 'j': 0.27, 'k': 0.569, 'l': 0.27, 'm': 0.95, 'n': 0.619, 'o': 0.604,
198
+ 'p': 0.628, 'q': 0.628, 'r': 0.398, 's': 0.504, 't': 0.421, 'u': 0.611, 'v': 0.543, 'w': 0.816,
199
+ 'x': 0.605, 'y': 0.543, 'z': 0.522, '{': 0.456, '|': 0.23, '}': 0.456, '~': 0.551, 'Á': 0.736,
200
+ 'É': 0.626, 'Í': 0.254, 'Ó': 0.798, 'Ö': 0.798, 'Ő': 0.798, 'Ú': 0.7, 'Ü': 0.7, 'Ű': 0.7,
201
+ 'á': 0.571, 'é': 0.574, 'í': 0.27, 'ó': 0.604, 'ö': 0.604, 'ő': 0.604, 'ú': 0.611, 'ü': 0.611,
202
+ 'ű': 0.611, 'Ä': 0.736, 'ä': 0.571, 'Ë': 0.626, 'ë': 0.574, 'Ï': 0.254, 'ï': 0.27, 'Ñ': 0.72,
203
+ 'ñ': 0.619, 'Ç': 0.75, 'ç': 0.562, 'ß': 0.64, 'ẞ': 0.717, '€': 0.722, '°': 0.366, '§': 0.53,
204
+ '·': 0.165, '•': 0.351, '–': 0.606, '—': 0.906, '…': 0.765, '‘': 0.285, '’': 0.285, '“': 0.502,
205
+ '”': 0.502, '„': 0.497, '±': 0.5488, '×': 0.551, '÷': 0.551,
206
+ }
207
+ /** Unknown glyphs (outside the tables): reserve a generous em so any realistic exotic
208
+ * char OVER-reserves. (Not a mathematical bound — a pathological single-codepoint wide
209
+ * ligature can exceed it — but such glyphs aren't in the card font at all, so the print
210
+ * pipeline rejects them upstream of this estimate.) */
211
+ export const ADVANCE_FALLBACK_EM = 1.1
212
+ /** ITALIC ink overhang: slanted glyphs poke past their advance box (measured against the
213
+ * webfont: ≤ 0.20 em at 400, ≤ 0.23 em at 700 — descender tails like "jjj" are the worst
214
+ * case; upright overhang is exactly 0). Charged once per text box so the advance-sum
215
+ * width stays a true upper bound on the rendered ink for the italic toggle too. */
216
+ export const ITALIC_OVERHANG_EM = 0.25
217
+ /** Empty-text width floor — the OLD flat estimate's minimum (1 char × 0.6 em), kept so a
218
+ * blank element still has a box. NOT a live glyph metric (blank text is separately gated
219
+ * by `textEmpty`); it just anchors degenerate OBBs. */
220
+ export const EMPTY_TEXT_FLOOR_EM = 0.6
221
+ /** Baseline-to-baseline line advance for multi-line text (mirrors the render stack's
222
+ * TEXT_LINE_FACTOR in nfcard-web pocShared + cardgen text2d). */
223
+ export const TEXT_LINE_FACTOR_EM = 1.32
224
+
225
+ /** Advance-sum width of ONE line in em, in the weight's metric set (≥600 → SemiBold). */
226
+ export function textAdvanceEm(line: string, weight: number): number {
227
+ const table = weight >= 600 ? ADVANCE_EM_BOLD : ADVANCE_EM_REGULAR
228
+ let em = 0
229
+ for (const ch of line) em += table[ch] ?? ADVANCE_FALLBACK_EM
230
+ return em
231
+ }
232
+
233
+ /** Estimated card footprint (w×h, pre-scale) used for the on-card OBB check. Text is the
234
+ * per-glyph advance sum of the WIDEST line (untrimmed — spaces cost their real advance)
235
+ * with the multi-line stack in the height; a guaranteed upper bound on the rendered ink
236
+ * (see the table note above), so the gate can never accept an overflowing placement. */
237
+ export function footprintOf(el: CardElement): { w: number; h: number } {
147
238
  switch (el.type) {
148
239
  case 'chip':
149
240
  return { w: 2 * CHIP_RADIUS_MM, h: 2 * CHIP_RADIUS_MM }
@@ -153,9 +244,14 @@ function footprintOf(el: CardElement): { w: number; h: number } {
153
244
  case 'logo':
154
245
  return { w: el.widthMm, h: el.heightMm }
155
246
  case 'text': {
156
- // Coarse: ~0.6 em per glyph wide, ~1.3 em tall. Editor does the precise check.
157
- const w = Math.max(1, el.text.length) * el.sizeMm * 0.6
158
- return { w, h: el.sizeMm * 1.3 }
247
+ const lines = el.text.split('\n')
248
+ let maxEm = 0
249
+ for (const line of lines) maxEm = Math.max(maxEm, textAdvanceEm(line, el.weight))
250
+ const widthEm = Math.max(EMPTY_TEXT_FLOOR_EM, maxEm) + (el.italic ? ITALIC_OVERHANG_EM : 0)
251
+ return {
252
+ w: widthEm * el.sizeMm,
253
+ h: el.sizeMm * (1.3 + (lines.length - 1) * TEXT_LINE_FACTOR_EM),
254
+ }
159
255
  }
160
256
  }
161
257
  }
@@ -171,11 +267,29 @@ export function elementPrintability(el: CardElement, ctx: PrintabilityCtx): Viol
171
267
  }
172
268
 
173
269
  switch (el.type) {
174
- case 'chip':
270
+ case 'chip': {
175
271
  if (el.transform.scale < CHIP_SCALE_FLOOR - EPS) {
176
272
  push('chipScaleBelowCoil', 'The chip cannot shrink below the 25 mm NFC coil.')
177
273
  }
274
+ // NFCARD-421: the NFC pocket is only guaranteed enclosable when the chip
275
+ // island sits inside the pattern-engine PLACEMENT envelope — the domain
276
+ // under which hole-masking + rim clearance keep the perforation off the
277
+ // pocket wall. The generic `elementOffCard` OBB check is far looser (it lets
278
+ // the island edge reach the rim), so a chip nudged just past the envelope
279
+ // passes checkout yet fails cardgen's pocket-enclosure invariant at batch
280
+ // time — the buyer paid for an un-manufacturable card.
281
+ const env = chipPlacementMaxXY(el.transform.scale)
282
+ if (
283
+ Math.abs(el.transform.xMm) > env.maxX + EPS ||
284
+ Math.abs(el.transform.yMm) > env.maxY + EPS
285
+ ) {
286
+ push(
287
+ 'chipOutsideEnvelope',
288
+ 'The NFC chip is too close to the card edge — the pocket cannot be sealed.',
289
+ )
290
+ }
178
291
  break
292
+ }
179
293
  case 'qr':
180
294
  if (el.sizeMm * el.transform.scale < QR_MIN_SIZE_MM - EPS) {
181
295
  push('qrTooSmall', 'The QR code is below the scannable size floor.')
@@ -0,0 +1,77 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { tipJarSchema, digitalConfigSchema } from './index'
3
+
4
+ const baseConfig = {
5
+ templateId: 'classic' as const,
6
+ accentColor: '#123456',
7
+ fontKey: 'inter',
8
+ showAvatar: true,
9
+ }
10
+
11
+ describe('tipJarSchema', () => {
12
+ it('accepts a valid provider + handle and stores only { provider, handle }', () => {
13
+ expect(tipJarSchema.parse({ provider: 'kofi', handle: 'ricsi' })).toEqual({
14
+ provider: 'kofi',
15
+ handle: 'ricsi',
16
+ })
17
+ })
18
+
19
+ it('normalizes a pasted full URL down to the handle', () => {
20
+ expect(tipJarSchema.parse({ provider: 'kofi', handle: 'https://ko-fi.com/ricsi?x=1' })).toEqual(
21
+ { provider: 'kofi', handle: 'ricsi' },
22
+ )
23
+ })
24
+
25
+ it('lowercases revolut handles', () => {
26
+ expect(tipJarSchema.parse({ provider: 'revolut', handle: 'RicSi' })).toEqual({
27
+ provider: 'revolut',
28
+ handle: 'ricsi',
29
+ })
30
+ })
31
+
32
+ it('keeps a sanitized custom label, capped at 40 chars', () => {
33
+ const out = tipJarSchema.parse({
34
+ provider: 'kofi',
35
+ handle: 'ricsi',
36
+ label: '<b>Támogass</b> ' + 'x'.repeat(60),
37
+ })
38
+ expect(out?.provider).toBe('kofi')
39
+ expect(out?.label).not.toContain('<')
40
+ expect((out?.label ?? '').length).toBeLessThanOrEqual(40)
41
+ })
42
+
43
+ it('drops everything invalid to undefined instead of throwing (never 400)', () => {
44
+ expect(tipJarSchema.parse(undefined)).toBeUndefined()
45
+ expect(tipJarSchema.parse(null)).toBeUndefined()
46
+ expect(tipJarSchema.parse('nonsense')).toBeUndefined()
47
+ expect(tipJarSchema.parse({})).toBeUndefined()
48
+ expect(tipJarSchema.parse({ provider: 'stripe', handle: 'ricsi' })).toBeUndefined()
49
+ expect(tipJarSchema.parse({ provider: 'kofi', handle: 'x' })).toBeUndefined() // too short
50
+ expect(tipJarSchema.parse({ provider: 'kofi', handle: 'https://evil.com/ricsi' })).toBeUndefined()
51
+ expect(tipJarSchema.parse({ provider: 'kofi', handle: 'javascript:alert(1)' })).toBeUndefined()
52
+ expect(tipJarSchema.parse({ provider: 'kofi', handle: 123 })).toBeUndefined()
53
+ })
54
+ })
55
+
56
+ describe('digitalConfigSchema — tipJar field', () => {
57
+ it('round-trips a valid tipJar', () => {
58
+ const parsed = digitalConfigSchema.parse({
59
+ ...baseConfig,
60
+ tipJar: { provider: 'revolut', handle: 'ricsi' },
61
+ })
62
+ expect(parsed.tipJar).toEqual({ provider: 'revolut', handle: 'ricsi' })
63
+ })
64
+
65
+ it('drops an invalid tipJar without failing the whole config save', () => {
66
+ const parsed = digitalConfigSchema.parse({
67
+ ...baseConfig,
68
+ tipJar: { provider: 'kofi', handle: 'https://evil.com/x' },
69
+ })
70
+ expect(parsed.tipJar).toBeUndefined()
71
+ })
72
+
73
+ it('is optional — a config without a tipJar parses fine', () => {
74
+ const parsed = digitalConfigSchema.parse(baseConfig)
75
+ expect(parsed.tipJar).toBeUndefined()
76
+ })
77
+ })