@nfcard/validation 0.17.0 → 0.19.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
@@ -49,6 +49,18 @@ declare function obbCorners(t: ElementTransform, wMm: number, hMm: number): Pt[]
49
49
  /** Are all corners inside the ID-1 rim minus the safety margin? */
50
50
  declare function cornersOnCard(corners: Pt[], margin?: number): boolean;
51
51
  type ChipAxis = 0 | 1 | 2;
52
+ /**
53
+ * The chip PLACEMENT envelope: the region the chip *centre* must stay inside so
54
+ * the pattern-engine's hole-masking + rim clearance can keep the NFC pocket
55
+ * enclosed. Sized so the (scaled) Ø27.5·scale chip island clears the rim margin
56
+ * with a 1 mm buffer. Scale-aware so a grown chip tightens the envelope. Mirrors
57
+ * cardgen `chip_placement_max_xy`; `chipMaxXY()` is exactly this at scale 1 (the
58
+ * domain of the legacy 3×3 anchor lattice).
59
+ */
60
+ declare function chipPlacementMaxXY(scale: number): {
61
+ maxX: number;
62
+ maxY: number;
63
+ };
52
64
  /** Grid cell → card-local mm centre (matches pattern-engine `chipCentreForAnchor`). */
53
65
  declare function chipAnchorToMm(col: ChipAxis, row: ChipAxis): Pt;
54
66
  /** Free mm → nearest grid cell (matches pattern-engine `nearestChipAnchor`). The
@@ -66,6 +78,33 @@ interface Violation {
66
78
  elementId: string;
67
79
  message: string;
68
80
  }
81
+ /** Unknown glyphs (outside the tables): reserve a generous em so any realistic exotic
82
+ * char OVER-reserves. (Not a mathematical bound — a pathological single-codepoint wide
83
+ * ligature can exceed it — but such glyphs aren't in the card font at all, so the print
84
+ * pipeline rejects them upstream of this estimate.) */
85
+ declare const ADVANCE_FALLBACK_EM = 1.1;
86
+ /** ITALIC ink overhang: slanted glyphs poke past their advance box (measured against the
87
+ * webfont: ≤ 0.20 em at 400, ≤ 0.23 em at 700 — descender tails like "jjj" are the worst
88
+ * case; upright overhang is exactly 0). Charged once per text box so the advance-sum
89
+ * width stays a true upper bound on the rendered ink for the italic toggle too. */
90
+ declare const ITALIC_OVERHANG_EM = 0.25;
91
+ /** Empty-text width floor — the OLD flat estimate's minimum (1 char × 0.6 em), kept so a
92
+ * blank element still has a box. NOT a live glyph metric (blank text is separately gated
93
+ * by `textEmpty`); it just anchors degenerate OBBs. */
94
+ declare const EMPTY_TEXT_FLOOR_EM = 0.6;
95
+ /** Baseline-to-baseline line advance for multi-line text (mirrors the render stack's
96
+ * TEXT_LINE_FACTOR in nfcard-web pocShared + cardgen text2d). */
97
+ declare const TEXT_LINE_FACTOR_EM = 1.32;
98
+ /** Advance-sum width of ONE line in em, in the weight's metric set (≥600 → SemiBold). */
99
+ declare function textAdvanceEm(line: string, weight: number): number;
100
+ /** Estimated card footprint (w×h, pre-scale) used for the on-card OBB check. Text is the
101
+ * per-glyph advance sum of the WIDEST line (untrimmed — spaces cost their real advance)
102
+ * with the multi-line stack in the height; a guaranteed upper bound on the rendered ink
103
+ * (see the table note above), so the gate can never accept an overflowing placement. */
104
+ declare function footprintOf(el: CardElement): {
105
+ w: number;
106
+ h: number;
107
+ };
69
108
  /** All hard manufacturing violations for one element. Empty ⇒ printable. */
70
109
  declare function elementPrintability(el: CardElement, ctx: PrintabilityCtx): Violation[];
71
110
 
@@ -19785,4 +19824,4 @@ declare function validatePhysicalConfig(data: unknown): ValidationResult<z.infer
19785
19824
  declare function validateDigitalConfig(data: unknown): ValidationResult<z.infer<typeof digitalConfigSchema>>;
19786
19825
  declare function validateConfiguration(data: unknown): ValidationResult<z.infer<typeof configurationCreateSchema>>;
19787
19826
 
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 };
19827
+ 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, userDataSchema, validateConfiguration, validateDigitalConfig, validatePhysicalConfig, validateUserData, webAddressSchema };
package/dist/index.js CHANGED
Binary file
package/package.json CHANGED
@@ -1,45 +1,39 @@
1
- {
2
- "name": "@nfcard/validation",
3
- "version": "0.17.0",
4
- "description": "Shared Zod validation schemas for the NFCard product family.",
5
- "type": "module",
6
- "main": "./src/index.ts",
7
- "types": "./src/index.ts",
8
- "exports": {
9
- ".": "./src/index.ts"
10
- },
11
- "files": [
12
- "dist",
13
- "src"
14
- ],
15
- "scripts": {
16
- "test": "vitest run",
17
- "test:watch": "vitest",
18
- "build": "tsup src/index.ts --format esm --dts --clean",
19
- "prepublishOnly": "pnpm build"
20
- },
21
- "dependencies": {
22
- "zod": "^3.24.0",
23
- "@nfcard/types": "workspace:*"
24
- },
25
- "devDependencies": {
26
- "tsup": "^8.0.0"
27
- },
28
- "publishConfig": {
29
- "access": "public",
30
- "registry": "https://registry.npmjs.org/",
31
- "main": "./dist/index.js",
32
- "types": "./dist/index.d.ts",
33
- "exports": {
34
- ".": {
35
- "import": "./dist/index.js",
36
- "types": "./dist/index.d.ts"
37
- }
38
- }
39
- },
40
- "repository": {
41
- "type": "git",
42
- "url": "https://bitbucket.org/fox-hole/nfcard-shared.git",
43
- "directory": "validation"
44
- }
45
- }
1
+ {
2
+ "name": "@nfcard/validation",
3
+ "version": "0.19.0",
4
+ "description": "Shared Zod validation schemas for the NFCard product family.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.js",
11
+ "types": "./dist/index.d.ts"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "src"
17
+ ],
18
+ "dependencies": {
19
+ "zod": "^3.24.0",
20
+ "@nfcard/types": "0.13.2"
21
+ },
22
+ "devDependencies": {
23
+ "tsup": "^8.0.0"
24
+ },
25
+ "publishConfig": {
26
+ "access": "public",
27
+ "registry": "https://registry.npmjs.org/"
28
+ },
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "https://bitbucket.org/fox-hole/nfcard-shared.git",
32
+ "directory": "validation"
33
+ },
34
+ "scripts": {
35
+ "test": "vitest run",
36
+ "test:watch": "vitest",
37
+ "build": "tsup src/index.ts --format esm --dts --clean"
38
+ }
39
+ }
@@ -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)
@@ -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.')