@nfcard/validation 0.25.1 → 0.26.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 +75 -2
- package/dist/index.js +29 -3
- package/package.json +2 -2
- package/src/controlChars.test.ts +71 -71
- package/src/index.ts +66 -0
- package/src/link.test.ts +96 -0
- package/src/parity.test.ts +25 -6
- package/src/printability.test.ts +47 -0
- package/src/printability.ts +29 -2
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as _nfcard_types from '@nfcard/types';
|
|
2
|
-
import { CardAsset,
|
|
2
|
+
import { CardAsset, ElementTransform, CardElement, ProfileObjectKind, ProfileLayout } from '@nfcard/types';
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -47,6 +47,21 @@ interface Pt {
|
|
|
47
47
|
declare function isFiniteTransform(t: ElementTransform): boolean;
|
|
48
48
|
/** The 4 corners of a `wMm × hMm` box (centre-origin local) under a transform. */
|
|
49
49
|
declare function obbCorners(t: ElementTransform, wMm: number, hMm: number): Pt[];
|
|
50
|
+
/** Is a DISC of radius `rMm` centred at `t` inside the ID-1 rim minus the safety margin?
|
|
51
|
+
*
|
|
52
|
+
* NFCARD-841 — the chip is physically a DISC, but {@link footprintOf} gives it the 27.5 mm
|
|
53
|
+
* bounding SQUARE, so running it through {@link cornersOnCard} + {@link obbCorners} made its
|
|
54
|
+
* on-card verdict ROTATION-SENSITIVE. A circle covers the same area at every angle, so that is
|
|
55
|
+
* wrong in principle; it was also wrong in practice, because past scale ≈1.31 the square's
|
|
56
|
+
* half-diagonal (13.75·s·√2) exceeds the 25.5 mm rim and a 45°-rotated chip was rejected at
|
|
57
|
+
* EVERY position on the card — unorderable with no placement that could save it.
|
|
58
|
+
*
|
|
59
|
+
* ⚠ At rotation 0 this is EXACTLY the old bound (the square's half-height IS the radius), so no
|
|
60
|
+
* previously-legal design changes verdict and no previously-illegal one is let through. The only
|
|
61
|
+
* behaviour that moves is the rotation artifact.
|
|
62
|
+
*
|
|
63
|
+
* Rotation-invariant by construction — it takes no angle. Mirrors cardgen `disc_on_card`. */
|
|
64
|
+
declare function discOnCard(t: ElementTransform, rMm: number, margin?: number): boolean;
|
|
50
65
|
/** Are all corners inside the ID-1 rim minus the safety margin? */
|
|
51
66
|
declare function cornersOnCard(corners: Pt[], margin?: number): boolean;
|
|
52
67
|
type ChipAxis = 0 | 1 | 2;
|
|
@@ -22722,5 +22737,63 @@ declare function validateUserData(data: unknown): ValidationResult<z.infer<typeo
|
|
|
22722
22737
|
declare function validatePhysicalConfig(data: unknown): ValidationResult<z.infer<typeof physicalConfigSchema>>;
|
|
22723
22738
|
declare function validateDigitalConfig(data: unknown): ValidationResult<z.infer<typeof digitalConfigSchema>>;
|
|
22724
22739
|
declare function validateConfiguration(data: unknown): ValidationResult<z.infer<typeof configurationCreateSchema>>;
|
|
22740
|
+
/** What physical thing carries the code. Fulfilment dispatch, never routing. */
|
|
22741
|
+
declare const productTypeSchema: z.ZodEnum<["card", "qr_sticker", "keychain", "board"]>;
|
|
22742
|
+
/** Where a tap goes. */
|
|
22743
|
+
declare const linkDestinationTypeSchema: z.ZodEnum<["auto", "profile", "hub_chooser", "external", "board"]>;
|
|
22744
|
+
/** The lifecycle of the code itself, independent of where it points. */
|
|
22745
|
+
declare const linkStatusSchema: z.ZodEnum<["minted", "unclaimed", "claimed", "active", "void"]>;
|
|
22746
|
+
/** Which touchpoint of one physical object a row is. */
|
|
22747
|
+
declare const linkRoleSchema: z.ZodEnum<["chip", "qr"]>;
|
|
22748
|
+
/**
|
|
22749
|
+
* The three destination columns, as a request body carries them.
|
|
22750
|
+
*
|
|
22751
|
+
* SHAPE ONLY. Which types a given caller may actually SET is a tier question,
|
|
22752
|
+
* answered server-side against a tier the server derives itself — putting it
|
|
22753
|
+
* here would mean a client could read the gate off the schema, and a schema
|
|
22754
|
+
* cannot see who is asking.
|
|
22755
|
+
*
|
|
22756
|
+
* `destUrl` is capped but not URL-validated on purpose: the single gate is the
|
|
22757
|
+
* server's external-URL parser, and a second, looser check here would only
|
|
22758
|
+
* teach a client that a URL it will later refuse was accepted.
|
|
22759
|
+
*/
|
|
22760
|
+
declare const MAX_LINK_DEST_URL_LENGTH = 2048;
|
|
22761
|
+
declare const linkDestinationInputSchema: z.ZodObject<{
|
|
22762
|
+
destinationType: z.ZodEnum<["auto", "profile", "hub_chooser", "external", "board"]>;
|
|
22763
|
+
destProfileId: z.ZodOptional<z.ZodString>;
|
|
22764
|
+
destUrl: z.ZodOptional<z.ZodString>;
|
|
22765
|
+
}, "strip", z.ZodTypeAny, {
|
|
22766
|
+
destinationType: "auto" | "board" | "profile" | "hub_chooser" | "external";
|
|
22767
|
+
destProfileId?: string | undefined;
|
|
22768
|
+
destUrl?: string | undefined;
|
|
22769
|
+
}, {
|
|
22770
|
+
destinationType: "auto" | "board" | "profile" | "hub_chooser" | "external";
|
|
22771
|
+
destProfileId?: string | undefined;
|
|
22772
|
+
destUrl?: string | undefined;
|
|
22773
|
+
}>;
|
|
22774
|
+
type LinkDestinationInput = z.infer<typeof linkDestinationInputSchema>;
|
|
22775
|
+
/**
|
|
22776
|
+
* A destination the buyer chose BEFORE the object existed.
|
|
22777
|
+
*
|
|
22778
|
+
* Every field optional, including the discriminator, because "the buyer never
|
|
22779
|
+
* opened the picker" is the overwhelmingly common state and has to be storable
|
|
22780
|
+
* as silence rather than as an explicit default. Consumed once at fulfilment,
|
|
22781
|
+
* where it may only ever SOFTEN — a routing preference must not fail a paid
|
|
22782
|
+
* order.
|
|
22783
|
+
*/
|
|
22784
|
+
declare const linkDestinationIntentSchema: z.ZodObject<{
|
|
22785
|
+
destinationType: z.ZodOptional<z.ZodEnum<["auto", "profile", "hub_chooser", "external", "board"]>>;
|
|
22786
|
+
destProfileId: z.ZodOptional<z.ZodOptional<z.ZodString>>;
|
|
22787
|
+
destUrl: z.ZodOptional<z.ZodOptional<z.ZodString>>;
|
|
22788
|
+
}, "strip", z.ZodTypeAny, {
|
|
22789
|
+
destinationType?: "auto" | "board" | "profile" | "hub_chooser" | "external" | undefined;
|
|
22790
|
+
destProfileId?: string | undefined;
|
|
22791
|
+
destUrl?: string | undefined;
|
|
22792
|
+
}, {
|
|
22793
|
+
destinationType?: "auto" | "board" | "profile" | "hub_chooser" | "external" | undefined;
|
|
22794
|
+
destProfileId?: string | undefined;
|
|
22795
|
+
destUrl?: string | undefined;
|
|
22796
|
+
}>;
|
|
22797
|
+
type LinkDestinationIntent = z.infer<typeof linkDestinationIntentSchema>;
|
|
22725
22798
|
|
|
22726
|
-
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, STYLE_SCHEMA_FOR_KIND, TEXT_LINE_FACTOR_EM, TEXT_MIN_EM_MM, type ValidationResult, type Violation, adminCardCreateSchema, bookingSchema, buttonObjectStyleSchema, 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, imageObjectStyleSchema, isFiniteTransform, materialSchema, mmToNearestChipAnchor, obbCorners, patternFamilySchema, physicalConfigSchema, physicalContentOverridesSchema, profileAccessTokenCreateSchema, profileBackgroundSchema, profileCreateSchema, profileLayoutSchema, profilePinVerifySchema, profileUpdateSchema, profileUserDataSchema, profileVisibilitySchema, qrModeSchema, rowTextStyleSchema, safeProfileLayout, sanitizeHttpUrl, sanitizeText, signupWithProfileSchema, socialLinksSchema, socialOverridesSchema, textAdvanceEm, textObjectStyleSchema, tipJarSchema, userDataSchema, validateConfiguration, validateDigitalConfig, validatePhysicalConfig, validateUserData, webAddressSchema };
|
|
22799
|
+
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, type LinkDestinationInput, type LinkDestinationIntent, MAX_CTA_BUTTONS, MAX_LINK_DEST_URL_LENGTH, 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, STYLE_SCHEMA_FOR_KIND, TEXT_LINE_FACTOR_EM, TEXT_MIN_EM_MM, type ValidationResult, type Violation, adminCardCreateSchema, bookingSchema, buttonObjectStyleSchema, cardAssetSchema, cardDesignMaterialSchema, cardDesignPatternSchema, cardDesignSchema, cardElementSchema, cardElementsSchema, cardProfileUpdateSchema, cardUpdateSchema, chipAnchorToMm, chipPlacementMaxXY, comboIdSchema, configurationCreateSchema, configurationUpdateSchema, contactDetailTogglesSchema, contactExchangeSchema, cornersOnCard, createOrderSchema, ctaButtonSchema, ctaButtonsSchema, digitalConfigSchema, digitalVisibilitySchema, discOnCard, elementPrintability, elementTransformSchema, entrySourceSchema, footprintOf, hubConfigSchema, hubProfilesUpdateSchema, imageObjectStyleSchema, isFiniteTransform, linkDestinationInputSchema, linkDestinationIntentSchema, linkDestinationTypeSchema, linkRoleSchema, linkStatusSchema, materialSchema, mmToNearestChipAnchor, obbCorners, patternFamilySchema, physicalConfigSchema, physicalContentOverridesSchema, productTypeSchema, profileAccessTokenCreateSchema, profileBackgroundSchema, profileCreateSchema, profileLayoutSchema, profilePinVerifySchema, profileUpdateSchema, profileUserDataSchema, profileVisibilitySchema, qrModeSchema, rowTextStyleSchema, safeProfileLayout, sanitizeHttpUrl, sanitizeText, signupWithProfileSchema, socialLinksSchema, socialOverridesSchema, textAdvanceEm, textObjectStyleSchema, tipJarSchema, userDataSchema, validateConfiguration, validateDigitalConfig, validatePhysicalConfig, validateUserData, webAddressSchema };
|
package/dist/index.js
CHANGED
|
@@ -4,7 +4,11 @@ import {
|
|
|
4
4
|
isTipProviderId,
|
|
5
5
|
normalizeTipHandle,
|
|
6
6
|
isBookingProviderId,
|
|
7
|
-
normalizeBookingHandle
|
|
7
|
+
normalizeBookingHandle,
|
|
8
|
+
PRODUCT_TYPES,
|
|
9
|
+
LINK_DESTINATION_TYPES,
|
|
10
|
+
LINK_STATUSES,
|
|
11
|
+
LINK_ROLES
|
|
8
12
|
} from "@nfcard/types";
|
|
9
13
|
|
|
10
14
|
// src/printability.ts
|
|
@@ -41,6 +45,9 @@ function obbCorners(t, wMm, hMm) {
|
|
|
41
45
|
];
|
|
42
46
|
return local.map(([x, y]) => ({ x: t.xMm + x * c - y * s, y: t.yMm + x * s + y * c }));
|
|
43
47
|
}
|
|
48
|
+
function discOnCard(t, rMm, margin = EDGE_MARGIN_MM) {
|
|
49
|
+
return Math.abs(t.xMm) + rMm <= CARD_W_MM / 2 - margin + EPS && Math.abs(t.yMm) + rMm <= CARD_H_MM / 2 - margin + EPS;
|
|
50
|
+
}
|
|
44
51
|
function cornersOnCard(corners, margin = EDGE_MARGIN_MM) {
|
|
45
52
|
const maxX = CARD_W_MM / 2 - margin;
|
|
46
53
|
const maxY = CARD_H_MM / 2 - margin;
|
|
@@ -441,8 +448,8 @@ function elementPrintability(el, ctx) {
|
|
|
441
448
|
break;
|
|
442
449
|
}
|
|
443
450
|
}
|
|
444
|
-
const
|
|
445
|
-
if (!
|
|
451
|
+
const onCard = el.type === "chip" ? discOnCard(el.transform, CHIP_RADIUS_MM * el.transform.scale) : cornersOnCard(obbCorners(el.transform, footprintOf(el).w, footprintOf(el).h));
|
|
452
|
+
if (!onCard) {
|
|
446
453
|
push("elementOffCard", "The element extends past the printable card area.");
|
|
447
454
|
}
|
|
448
455
|
return out;
|
|
@@ -1173,6 +1180,17 @@ function validateConfiguration(data) {
|
|
|
1173
1180
|
if (result.success) return { success: true, data: result.data };
|
|
1174
1181
|
return { success: false, errors: formatZodErrors(result.error) };
|
|
1175
1182
|
}
|
|
1183
|
+
var productTypeSchema = z2.enum(PRODUCT_TYPES);
|
|
1184
|
+
var linkDestinationTypeSchema = z2.enum(LINK_DESTINATION_TYPES);
|
|
1185
|
+
var linkStatusSchema = z2.enum(LINK_STATUSES);
|
|
1186
|
+
var linkRoleSchema = z2.enum(LINK_ROLES);
|
|
1187
|
+
var MAX_LINK_DEST_URL_LENGTH = 2048;
|
|
1188
|
+
var linkDestinationInputSchema = z2.object({
|
|
1189
|
+
destinationType: linkDestinationTypeSchema,
|
|
1190
|
+
destProfileId: z2.string().trim().min(1).max(64).optional(),
|
|
1191
|
+
destUrl: z2.string().trim().max(MAX_LINK_DEST_URL_LENGTH).optional()
|
|
1192
|
+
});
|
|
1193
|
+
var linkDestinationIntentSchema = linkDestinationInputSchema.partial();
|
|
1176
1194
|
export {
|
|
1177
1195
|
ADVANCE_FALLBACK_EM,
|
|
1178
1196
|
CARD_H_MM,
|
|
@@ -1183,6 +1201,7 @@ export {
|
|
|
1183
1201
|
EMPTY_TEXT_FLOOR_EM,
|
|
1184
1202
|
ITALIC_OVERHANG_EM,
|
|
1185
1203
|
MAX_CTA_BUTTONS,
|
|
1204
|
+
MAX_LINK_DEST_URL_LENGTH,
|
|
1186
1205
|
MIN_WALL_MM,
|
|
1187
1206
|
NFC_COIL_DIAMETER_MM,
|
|
1188
1207
|
NFC_RADIAL_CLEARANCE_MM,
|
|
@@ -1216,6 +1235,7 @@ export {
|
|
|
1216
1235
|
ctaButtonsSchema,
|
|
1217
1236
|
digitalConfigSchema,
|
|
1218
1237
|
digitalVisibilitySchema,
|
|
1238
|
+
discOnCard,
|
|
1219
1239
|
elementPrintability,
|
|
1220
1240
|
elementTransformSchema,
|
|
1221
1241
|
entrySourceSchema,
|
|
@@ -1224,12 +1244,18 @@ export {
|
|
|
1224
1244
|
hubProfilesUpdateSchema,
|
|
1225
1245
|
imageObjectStyleSchema,
|
|
1226
1246
|
isFiniteTransform,
|
|
1247
|
+
linkDestinationInputSchema,
|
|
1248
|
+
linkDestinationIntentSchema,
|
|
1249
|
+
linkDestinationTypeSchema,
|
|
1250
|
+
linkRoleSchema,
|
|
1251
|
+
linkStatusSchema,
|
|
1227
1252
|
materialSchema,
|
|
1228
1253
|
mmToNearestChipAnchor,
|
|
1229
1254
|
obbCorners,
|
|
1230
1255
|
patternFamilySchema,
|
|
1231
1256
|
physicalConfigSchema,
|
|
1232
1257
|
physicalContentOverridesSchema,
|
|
1258
|
+
productTypeSchema,
|
|
1233
1259
|
profileAccessTokenCreateSchema,
|
|
1234
1260
|
profileBackgroundSchema,
|
|
1235
1261
|
profileCreateSchema,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nfcard/validation",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.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.
|
|
20
|
+
"@nfcard/types": "0.24.0"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
23
|
"tsup": "^8.0.0"
|
package/src/controlChars.test.ts
CHANGED
|
@@ -1,71 +1,71 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import fs from 'node:fs';
|
|
3
|
-
import path from 'node:path';
|
|
4
|
-
import { fileURLToPath } from 'node:url';
|
|
5
|
-
import { sanitizeHttpUrl, sanitizeText } from './index';
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* The character classes in `stripUnsafeChars` and `sanitizeText` used to be
|
|
9
|
-
* written with LITERAL 0x00 / 0x1f / 0x7f bytes in the source.
|
|
10
|
-
*
|
|
11
|
-
* That was not a style problem. It made this file read as BINARY to ripgrep
|
|
12
|
-
* ("found \0 byte around offset 2873"), so `grep`/`Grep` silently skipped it
|
|
13
|
-
* and anyone searching for `sanitizeHttpUrl` concluded it did not exist. Worse,
|
|
14
|
-
* a Prettier run, an editor "strip control characters" save, or a
|
|
15
|
-
* `.gitattributes` filter would rewrite those classes without anyone noticing —
|
|
16
|
-
* and `sanitizeHttpUrl` is the SECOND-OPINION gate that `parseExternalUrl` in
|
|
17
|
-
* nfcard-api cross-checks itself against. The two disagreeing turns into a
|
|
18
|
-
* blanket refusal of every external URL: an outage, from a formatter.
|
|
19
|
-
*
|
|
20
|
-
* The bytes are now `\x00` / `\x1f` / `\x7f` escapes — same semantics, plain
|
|
21
|
-
* ASCII, greppable, formatter-proof. These tests keep it that way.
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
25
|
-
const SOURCE = fs.readFileSync(path.join(HERE, 'index.ts'));
|
|
26
|
-
|
|
27
|
-
describe('the source file stays plain ASCII', () => {
|
|
28
|
-
it('contains NO literal control bytes — that is what made it read as binary', () => {
|
|
29
|
-
const offenders: string[] = [];
|
|
30
|
-
for (let i = 0; i < SOURCE.length; i++) {
|
|
31
|
-
const byte = SOURCE[i];
|
|
32
|
-
const isControl = byte === 0 || byte < 9 || (byte > 13 && byte < 32) || byte === 127;
|
|
33
|
-
if (isControl) offenders.push(`0x${byte.toString(16)} @ ${i}`);
|
|
34
|
-
}
|
|
35
|
-
expect(offenders).toEqual([]);
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
it('still declares the classes it always did, as escapes', () => {
|
|
39
|
-
const text = SOURCE.toString('utf8');
|
|
40
|
-
// ⚠️ Not a cosmetic assertion. `[ -<...]` — which is what these look like
|
|
41
|
-
// once the control bytes are eaten — is a RANGE over U+0020–U+003C that
|
|
42
|
-
// swallows `.` `/` `:` and every digit. That exact bug is on the record in
|
|
43
|
-
// this codebase; this pins the intended form so it cannot come back.
|
|
44
|
-
expect(text).toContain('/[\\x00-\\x1f\\x7f<>"\'`\\\\]/g');
|
|
45
|
-
expect(text).toContain('/[\\x00-\\x1f\\x7f]/g');
|
|
46
|
-
});
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
describe('the classes behave exactly as before the escape rewrite', () => {
|
|
50
|
-
const CONTROLS = ['\x00', '\x01', '\x1f', '\x7f'];
|
|
51
|
-
|
|
52
|
-
it.each(CONTROLS)('sanitizeHttpUrl strips %j out of a host', (ch) => {
|
|
53
|
-
expect(sanitizeHttpUrl(`https://exam${ch}ple.com/x`)).toBe('https://example.com/x');
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
it.each(CONTROLS)('sanitizeText strips %j', (ch) => {
|
|
57
|
-
expect(sanitizeText(`Ri${ch}csi`)).toBe('Ricsi');
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it('still strips the HTML-ish characters beside them', () => {
|
|
61
|
-
expect(sanitizeHttpUrl('https://example.com/<script>')).toBe('https://example.com/script');
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it('does NOT strip the printable characters a URL needs', () => {
|
|
65
|
-
// The guard against re-introducing the U+0020–U+003C range bug: every one
|
|
66
|
-
// of these sits inside that range and must survive.
|
|
67
|
-
expect(sanitizeHttpUrl('https://example.com/a.b/c-1?d=2&e=3')).toBe(
|
|
68
|
-
'https://example.com/a.b/c-1?d=2&e=3',
|
|
69
|
-
);
|
|
70
|
-
});
|
|
71
|
-
});
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { sanitizeHttpUrl, sanitizeText } from './index';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The character classes in `stripUnsafeChars` and `sanitizeText` used to be
|
|
9
|
+
* written with LITERAL 0x00 / 0x1f / 0x7f bytes in the source.
|
|
10
|
+
*
|
|
11
|
+
* That was not a style problem. It made this file read as BINARY to ripgrep
|
|
12
|
+
* ("found \0 byte around offset 2873"), so `grep`/`Grep` silently skipped it
|
|
13
|
+
* and anyone searching for `sanitizeHttpUrl` concluded it did not exist. Worse,
|
|
14
|
+
* a Prettier run, an editor "strip control characters" save, or a
|
|
15
|
+
* `.gitattributes` filter would rewrite those classes without anyone noticing —
|
|
16
|
+
* and `sanitizeHttpUrl` is the SECOND-OPINION gate that `parseExternalUrl` in
|
|
17
|
+
* nfcard-api cross-checks itself against. The two disagreeing turns into a
|
|
18
|
+
* blanket refusal of every external URL: an outage, from a formatter.
|
|
19
|
+
*
|
|
20
|
+
* The bytes are now `\x00` / `\x1f` / `\x7f` escapes — same semantics, plain
|
|
21
|
+
* ASCII, greppable, formatter-proof. These tests keep it that way.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
25
|
+
const SOURCE = fs.readFileSync(path.join(HERE, 'index.ts'));
|
|
26
|
+
|
|
27
|
+
describe('the source file stays plain ASCII', () => {
|
|
28
|
+
it('contains NO literal control bytes — that is what made it read as binary', () => {
|
|
29
|
+
const offenders: string[] = [];
|
|
30
|
+
for (let i = 0; i < SOURCE.length; i++) {
|
|
31
|
+
const byte = SOURCE[i];
|
|
32
|
+
const isControl = byte === 0 || byte < 9 || (byte > 13 && byte < 32) || byte === 127;
|
|
33
|
+
if (isControl) offenders.push(`0x${byte.toString(16)} @ ${i}`);
|
|
34
|
+
}
|
|
35
|
+
expect(offenders).toEqual([]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('still declares the classes it always did, as escapes', () => {
|
|
39
|
+
const text = SOURCE.toString('utf8');
|
|
40
|
+
// ⚠️ Not a cosmetic assertion. `[ -<...]` — which is what these look like
|
|
41
|
+
// once the control bytes are eaten — is a RANGE over U+0020–U+003C that
|
|
42
|
+
// swallows `.` `/` `:` and every digit. That exact bug is on the record in
|
|
43
|
+
// this codebase; this pins the intended form so it cannot come back.
|
|
44
|
+
expect(text).toContain('/[\\x00-\\x1f\\x7f<>"\'`\\\\]/g');
|
|
45
|
+
expect(text).toContain('/[\\x00-\\x1f\\x7f]/g');
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('the classes behave exactly as before the escape rewrite', () => {
|
|
50
|
+
const CONTROLS = ['\x00', '\x01', '\x1f', '\x7f'];
|
|
51
|
+
|
|
52
|
+
it.each(CONTROLS)('sanitizeHttpUrl strips %j out of a host', (ch) => {
|
|
53
|
+
expect(sanitizeHttpUrl(`https://exam${ch}ple.com/x`)).toBe('https://example.com/x');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it.each(CONTROLS)('sanitizeText strips %j', (ch) => {
|
|
57
|
+
expect(sanitizeText(`Ri${ch}csi`)).toBe('Ricsi');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('still strips the HTML-ish characters beside them', () => {
|
|
61
|
+
expect(sanitizeHttpUrl('https://example.com/<script>')).toBe('https://example.com/script');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('does NOT strip the printable characters a URL needs', () => {
|
|
65
|
+
// The guard against re-introducing the U+0020–U+003C range bug: every one
|
|
66
|
+
// of these sits inside that range and must survive.
|
|
67
|
+
expect(sanitizeHttpUrl('https://example.com/a.b/c-1?d=2&e=3')).toBe(
|
|
68
|
+
'https://example.com/a.b/c-1?d=2&e=3',
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -4,6 +4,11 @@ import {
|
|
|
4
4
|
normalizeTipHandle,
|
|
5
5
|
isBookingProviderId,
|
|
6
6
|
normalizeBookingHandle,
|
|
7
|
+
// NFCARD-330 - the Link vocabularies, derived rather than re-spelled.
|
|
8
|
+
PRODUCT_TYPES,
|
|
9
|
+
LINK_DESTINATION_TYPES,
|
|
10
|
+
LINK_STATUSES,
|
|
11
|
+
LINK_ROLES,
|
|
7
12
|
} from '@nfcard/types'
|
|
8
13
|
import { elementPrintability, mmToNearestChipAnchor } from './printability'
|
|
9
14
|
import { profileLayoutSchema } from './profileLayout'
|
|
@@ -898,3 +903,64 @@ export function validateConfiguration(
|
|
|
898
903
|
if (result.success) return { success: true, data: result.data }
|
|
899
904
|
return { success: false, errors: formatZodErrors(result.error) }
|
|
900
905
|
}
|
|
906
|
+
|
|
907
|
+
// -- Link contracts (NFCARD-330, epic 307) ---------------------------------
|
|
908
|
+
//
|
|
909
|
+
// DELIBERATE DEPARTURE FROM THE HOUSE STYLE, and worth the paragraph. Every
|
|
910
|
+
// other enum schema in this file spells its strings out literally
|
|
911
|
+
// (`materialSchema`, `qrModeSchema`, `profileVisibilitySchema`), so each
|
|
912
|
+
// vocabulary is written twice: once as a type in `@nfcard/types` and once here.
|
|
913
|
+
// These derive from the const arrays instead.
|
|
914
|
+
//
|
|
915
|
+
// The whole reason NFCARD-330 exists is that the Link vocabularies had drifted
|
|
916
|
+
// across three hand-copies. Landing them here as a fourth and fifth would be
|
|
917
|
+
// re-creating the problem inside its own fix. `z.enum` accepts the readonly
|
|
918
|
+
// tuples directly because they are declared `as const` and non-empty, so this
|
|
919
|
+
// costs nothing and makes a new destination a one-line change in one file.
|
|
920
|
+
|
|
921
|
+
/** What physical thing carries the code. Fulfilment dispatch, never routing. */
|
|
922
|
+
export const productTypeSchema = z.enum(PRODUCT_TYPES)
|
|
923
|
+
|
|
924
|
+
/** Where a tap goes. */
|
|
925
|
+
export const linkDestinationTypeSchema = z.enum(LINK_DESTINATION_TYPES)
|
|
926
|
+
|
|
927
|
+
/** The lifecycle of the code itself, independent of where it points. */
|
|
928
|
+
export const linkStatusSchema = z.enum(LINK_STATUSES)
|
|
929
|
+
|
|
930
|
+
/** Which touchpoint of one physical object a row is. */
|
|
931
|
+
export const linkRoleSchema = z.enum(LINK_ROLES)
|
|
932
|
+
|
|
933
|
+
/**
|
|
934
|
+
* The three destination columns, as a request body carries them.
|
|
935
|
+
*
|
|
936
|
+
* SHAPE ONLY. Which types a given caller may actually SET is a tier question,
|
|
937
|
+
* answered server-side against a tier the server derives itself — putting it
|
|
938
|
+
* here would mean a client could read the gate off the schema, and a schema
|
|
939
|
+
* cannot see who is asking.
|
|
940
|
+
*
|
|
941
|
+
* `destUrl` is capped but not URL-validated on purpose: the single gate is the
|
|
942
|
+
* server's external-URL parser, and a second, looser check here would only
|
|
943
|
+
* teach a client that a URL it will later refuse was accepted.
|
|
944
|
+
*/
|
|
945
|
+
export const MAX_LINK_DEST_URL_LENGTH = 2048
|
|
946
|
+
|
|
947
|
+
export const linkDestinationInputSchema = z.object({
|
|
948
|
+
destinationType: linkDestinationTypeSchema,
|
|
949
|
+
destProfileId: z.string().trim().min(1).max(64).optional(),
|
|
950
|
+
destUrl: z.string().trim().max(MAX_LINK_DEST_URL_LENGTH).optional(),
|
|
951
|
+
})
|
|
952
|
+
|
|
953
|
+
export type LinkDestinationInput = z.infer<typeof linkDestinationInputSchema>
|
|
954
|
+
|
|
955
|
+
/**
|
|
956
|
+
* A destination the buyer chose BEFORE the object existed.
|
|
957
|
+
*
|
|
958
|
+
* Every field optional, including the discriminator, because "the buyer never
|
|
959
|
+
* opened the picker" is the overwhelmingly common state and has to be storable
|
|
960
|
+
* as silence rather than as an explicit default. Consumed once at fulfilment,
|
|
961
|
+
* where it may only ever SOFTEN — a routing preference must not fail a paid
|
|
962
|
+
* order.
|
|
963
|
+
*/
|
|
964
|
+
export const linkDestinationIntentSchema = linkDestinationInputSchema.partial()
|
|
965
|
+
|
|
966
|
+
export type LinkDestinationIntent = z.infer<typeof linkDestinationIntentSchema>
|
package/src/link.test.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { LINK_DESTINATION_TYPES, PRODUCT_TYPES } from '@nfcard/types'
|
|
3
|
+
import {
|
|
4
|
+
MAX_LINK_DEST_URL_LENGTH,
|
|
5
|
+
linkDestinationIntentSchema,
|
|
6
|
+
linkDestinationTypeSchema,
|
|
7
|
+
linkRoleSchema,
|
|
8
|
+
linkStatusSchema,
|
|
9
|
+
productTypeSchema,
|
|
10
|
+
} from './index'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* NFCARD-330 — the Zod half of the Link contracts.
|
|
14
|
+
*
|
|
15
|
+
* The assertion that matters most is the DERIVATION one: these schemas are
|
|
16
|
+
* built from the `@nfcard/types` const arrays rather than re-spelling the
|
|
17
|
+
* strings, which is a deliberate departure from this package's house style and
|
|
18
|
+
* the whole reason the ticket exists.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
describe('the enum schemas', () => {
|
|
22
|
+
it('accept exactly the vocabulary and nothing else', () => {
|
|
23
|
+
for (const type of PRODUCT_TYPES) {
|
|
24
|
+
expect(productTypeSchema.safeParse(type).success).toBe(true)
|
|
25
|
+
}
|
|
26
|
+
expect(productTypeSchema.safeParse('poster').success).toBe(false)
|
|
27
|
+
|
|
28
|
+
for (const type of LINK_DESTINATION_TYPES) {
|
|
29
|
+
expect(linkDestinationTypeSchema.safeParse(type).success).toBe(true)
|
|
30
|
+
}
|
|
31
|
+
expect(linkDestinationTypeSchema.safeParse('hub').success).toBe(false)
|
|
32
|
+
|
|
33
|
+
expect(linkStatusSchema.safeParse('minted').success).toBe(true)
|
|
34
|
+
expect(linkStatusSchema.safeParse('dormant').success).toBe(false)
|
|
35
|
+
expect(linkRoleSchema.safeParse('qr').success).toBe(true)
|
|
36
|
+
expect(linkRoleSchema.safeParse('sticker').success).toBe(false)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
// ⭐ THE DERIVATION. If someone "helpfully" rewrites these as literal
|
|
40
|
+
// z.enum(['card', …]) lists, the schema and the type can drift — which is the
|
|
41
|
+
// exact failure NFCARD-330 was filed to end. This asserts the two are the
|
|
42
|
+
// same set, so a one-sided edit fails here.
|
|
43
|
+
it('stay derived from the types package, not hand-copied', () => {
|
|
44
|
+
expect(productTypeSchema.options).toEqual([...PRODUCT_TYPES])
|
|
45
|
+
expect(linkDestinationTypeSchema.options).toEqual([...LINK_DESTINATION_TYPES])
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
describe('linkDestinationInputSchema / intent', () => {
|
|
50
|
+
it('accepts a destination with its matching target', () => {
|
|
51
|
+
expect(
|
|
52
|
+
linkDestinationIntentSchema.safeParse({ destinationType: 'profile', destProfileId: 'p1' })
|
|
53
|
+
.success,
|
|
54
|
+
).toBe(true)
|
|
55
|
+
expect(
|
|
56
|
+
linkDestinationIntentSchema.safeParse({
|
|
57
|
+
destinationType: 'external',
|
|
58
|
+
destUrl: 'https://barberbros.hu/foglalas',
|
|
59
|
+
}).success,
|
|
60
|
+
).toBe(true)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
// "The buyer never opened the picker" is the overwhelmingly common state and
|
|
64
|
+
// has to be storable as silence, not as an explicit default.
|
|
65
|
+
it('accepts an entirely empty intent', () => {
|
|
66
|
+
expect(linkDestinationIntentSchema.safeParse({}).success).toBe(true)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('refuses a destination type that does not exist', () => {
|
|
70
|
+
expect(linkDestinationIntentSchema.safeParse({ destinationType: 'hub' }).success).toBe(false)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
// Capped, but NOT URL-validated: the one gate is the server's external-URL
|
|
74
|
+
// parser, and a second looser check here would teach a client that a URL the
|
|
75
|
+
// server will refuse was accepted.
|
|
76
|
+
it('caps the URL length without judging the URL', () => {
|
|
77
|
+
expect(
|
|
78
|
+
linkDestinationIntentSchema.safeParse({ destinationType: 'external', destUrl: 'not-a-url' })
|
|
79
|
+
.success,
|
|
80
|
+
).toBe(true)
|
|
81
|
+
expect(
|
|
82
|
+
linkDestinationIntentSchema.safeParse({
|
|
83
|
+
destinationType: 'external',
|
|
84
|
+
destUrl: 'h'.repeat(MAX_LINK_DEST_URL_LENGTH + 1),
|
|
85
|
+
}).success,
|
|
86
|
+
).toBe(false)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('trims the targets so a padded value is stored clean', () => {
|
|
90
|
+
const parsed = linkDestinationIntentSchema.parse({
|
|
91
|
+
destinationType: 'profile',
|
|
92
|
+
destProfileId: ' p1 ',
|
|
93
|
+
})
|
|
94
|
+
expect(parsed.destProfileId).toBe('p1')
|
|
95
|
+
})
|
|
96
|
+
})
|
package/src/parity.test.ts
CHANGED
|
@@ -26,14 +26,33 @@ import {
|
|
|
26
26
|
* Regenerate (both sides together):
|
|
27
27
|
* node ../../nfcard-cardgen/tests/parity/gen_printability_parity.mjs
|
|
28
28
|
*
|
|
29
|
-
* Skips cleanly if nfcard-cardgen isn't checked out alongside (shared-only CI)
|
|
29
|
+
* Skips cleanly if nfcard-cardgen isn't checked out alongside (shared-only CI) — but says so
|
|
30
|
+
* out loud. A silent skip is the worst outcome here: the guard reads as green while the two
|
|
31
|
+
* implementations drift. Set `NFCARD_CARDGEN_DIR` when the sibling layout does not apply (a
|
|
32
|
+
* git worktree sits two levels deeper, so the relative guess misses).
|
|
30
33
|
*/
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const
|
|
34
|
+
function resolveGolden(): string | null {
|
|
35
|
+
const rel = 'tests/parity/printability_parity.json'
|
|
36
|
+
const env = process.env.NFCARD_CARDGEN_DIR
|
|
37
|
+
const candidates = env
|
|
38
|
+
? [`${env.replace(/[\\/]+$/, '')}/${rel}`]
|
|
39
|
+
: [
|
|
40
|
+
fileURLToPath(new URL(`../../../nfcard-cardgen/${rel}`, import.meta.url)),
|
|
41
|
+
fileURLToPath(new URL(`../../../../../nfcard-cardgen/${rel}`, import.meta.url)),
|
|
42
|
+
]
|
|
43
|
+
return candidates.find((p) => existsSync(p)) ?? null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const GOLDEN = resolveGolden()
|
|
47
|
+
const present = GOLDEN !== null
|
|
48
|
+
if (!present) {
|
|
49
|
+
console.warn(
|
|
50
|
+
'[parity] SKIPPED — nfcard-cardgen/tests/parity/printability_parity.json not found. ' +
|
|
51
|
+
'Set NFCARD_CARDGEN_DIR to run the cross-language guard.',
|
|
52
|
+
)
|
|
53
|
+
}
|
|
35
54
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
36
|
-
const golden: any =
|
|
55
|
+
const golden: any = GOLDEN ? JSON.parse(readFileSync(GOLDEN, 'utf-8')) : { elementCases: [] }
|
|
37
56
|
const TOL = 1e-9
|
|
38
57
|
|
|
39
58
|
// Mirror cardgen `validate_elements`: per-element printability + the graph-level
|
package/src/printability.test.ts
CHANGED
|
@@ -5,6 +5,9 @@ import {
|
|
|
5
5
|
ITALIC_OVERHANG_EM,
|
|
6
6
|
TEXT_LINE_FACTOR_EM,
|
|
7
7
|
elementPrintability,
|
|
8
|
+
discOnCard,
|
|
9
|
+
CHIP_RADIUS_MM,
|
|
10
|
+
chipPlacementMaxXY,
|
|
8
11
|
footprintOf,
|
|
9
12
|
textAdvanceEm,
|
|
10
13
|
CARD_W_MM,
|
|
@@ -105,3 +108,47 @@ describe('NFCARD-530 — elementOffCard verdict with the accurate estimate', ()
|
|
|
105
108
|
expect(elementPrintability(el, ctx).map((v) => v.code)).toContain('elementOffCard')
|
|
106
109
|
})
|
|
107
110
|
})
|
|
111
|
+
|
|
112
|
+
// ── NFCARD-841 — the chip is a DISC, so its on-card verdict is rotation-invariant ──
|
|
113
|
+
describe('chip on-card check (NFCARD-841)', () => {
|
|
114
|
+
const chip = (xMm: number, yMm: number, rotationDeg = 0, scale = 1): CardElement =>
|
|
115
|
+
({ id: 'c1', type: 'chip', shape: 0, transform: { xMm, yMm, rotationDeg, scale, z: 0 } }) as unknown as CardElement
|
|
116
|
+
const codes = (el: CardElement) => elementPrintability(el, { assetsById: new Map() }).map((v) => v.code)
|
|
117
|
+
|
|
118
|
+
it('rotation NEVER changes a chip verdict — it used to, at every angle past scale 1.31', () => {
|
|
119
|
+
for (const scale of [1, 1.2, 1.4, 1.6]) {
|
|
120
|
+
for (const [x, y] of [[0, 0], [10, 2], [-18, -2], [26.55, 2.5]] as const) {
|
|
121
|
+
const at0 = codes(chip(x, y, 0, scale))
|
|
122
|
+
for (const rot of [15, 30, 45, 60, 90, 180, 275]) {
|
|
123
|
+
expect(codes(chip(x, y, rot, scale)), `x${x} y${y} s${scale} rot${rot}`).toEqual(at0)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('the case that had NO legal position: 45° at scale 1.6, anywhere on the card', () => {
|
|
130
|
+
// Before the fix the square's half-diagonal (13.75·1.6·√2 = 31.1 mm) exceeded the 25.5 mm
|
|
131
|
+
// rim, so elementOffCard fired at EVERY centre — a design no placement could rescue.
|
|
132
|
+
expect(codes(chip(0, 0, 45, 1.6))).toEqual([])
|
|
133
|
+
expect(codes(chip(0, 0, 0, 1.6))).toEqual([]) // …and it always agreed with the unrotated twin
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('at rotation 0 the new bound is EXACTLY the old one — no design changes verdict', () => {
|
|
137
|
+
// The square's half-height IS the disc radius, so this is a pure no-op where it used to be
|
|
138
|
+
// correct. Walk the y axis across the boundary and check the flip lands on the same value.
|
|
139
|
+
const rimMaxY = 54 / 2 - 1.5 - CHIP_RADIUS_MM // the old square bound at scale 1 = 11.75
|
|
140
|
+
expect(discOnCard(chip(0, rimMaxY).transform, CHIP_RADIUS_MM)).toBe(true)
|
|
141
|
+
expect(discOnCard(chip(0, rimMaxY + 0.01).transform, CHIP_RADIUS_MM)).toBe(false)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('the ENVELOPE still binds first, so this did not loosen the pocket rule', () => {
|
|
145
|
+
// chipPlacementMaxXY is the disc bound minus the 1 mm pocket buffer, at every scale — so a
|
|
146
|
+
// chip that clears the envelope can never trip elementOffCard, and NFCARD-421 stays fixed.
|
|
147
|
+
for (const scale of [1, 1.3, 1.6]) {
|
|
148
|
+
const env = chipPlacementMaxXY(scale)
|
|
149
|
+
expect(env.maxY).toBeCloseTo(54 / 2 - 1.5 - CHIP_RADIUS_MM * scale - 1, 6)
|
|
150
|
+
expect(codes(chip(0, env.maxY, 45, scale))).toEqual([]) // on the envelope edge: legal
|
|
151
|
+
expect(codes(chip(0, env.maxY + 0.02, 45, scale))).toEqual(['chipOutsideEnvelope']) // just past: ONLY the envelope
|
|
152
|
+
}
|
|
153
|
+
})
|
|
154
|
+
})
|
package/src/printability.ts
CHANGED
|
@@ -86,6 +86,27 @@ export function obbCorners(t: ElementTransform, wMm: number, hMm: number): Pt[]
|
|
|
86
86
|
return local.map(([x, y]) => ({ x: t.xMm + x * c - y * s, y: t.yMm + x * s + y * c }))
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
/** Is a DISC of radius `rMm` centred at `t` inside the ID-1 rim minus the safety margin?
|
|
90
|
+
*
|
|
91
|
+
* NFCARD-841 — the chip is physically a DISC, but {@link footprintOf} gives it the 27.5 mm
|
|
92
|
+
* bounding SQUARE, so running it through {@link cornersOnCard} + {@link obbCorners} made its
|
|
93
|
+
* on-card verdict ROTATION-SENSITIVE. A circle covers the same area at every angle, so that is
|
|
94
|
+
* wrong in principle; it was also wrong in practice, because past scale ≈1.31 the square's
|
|
95
|
+
* half-diagonal (13.75·s·√2) exceeds the 25.5 mm rim and a 45°-rotated chip was rejected at
|
|
96
|
+
* EVERY position on the card — unorderable with no placement that could save it.
|
|
97
|
+
*
|
|
98
|
+
* ⚠ At rotation 0 this is EXACTLY the old bound (the square's half-height IS the radius), so no
|
|
99
|
+
* previously-legal design changes verdict and no previously-illegal one is let through. The only
|
|
100
|
+
* behaviour that moves is the rotation artifact.
|
|
101
|
+
*
|
|
102
|
+
* Rotation-invariant by construction — it takes no angle. Mirrors cardgen `disc_on_card`. */
|
|
103
|
+
export function discOnCard(t: ElementTransform, rMm: number, margin = EDGE_MARGIN_MM): boolean {
|
|
104
|
+
return (
|
|
105
|
+
Math.abs(t.xMm) + rMm <= CARD_W_MM / 2 - margin + EPS &&
|
|
106
|
+
Math.abs(t.yMm) + rMm <= CARD_H_MM / 2 - margin + EPS
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
|
|
89
110
|
/** Are all corners inside the ID-1 rim minus the safety margin? */
|
|
90
111
|
export function cornersOnCard(corners: Pt[], margin = EDGE_MARGIN_MM): boolean {
|
|
91
112
|
const maxX = CARD_W_MM / 2 - margin
|
|
@@ -323,8 +344,14 @@ export function elementPrintability(el: CardElement, ctx: PrintabilityCtx): Viol
|
|
|
323
344
|
}
|
|
324
345
|
}
|
|
325
346
|
|
|
326
|
-
|
|
327
|
-
|
|
347
|
+
// NFCARD-841 — the CHIP is judged as the disc it is, not as its bounding square, so the
|
|
348
|
+
// verdict cannot depend on an angle a circle does not have. Every other element keeps the OBB
|
|
349
|
+
// check: their footprints really are rectangles and rotation really does move their corners.
|
|
350
|
+
const onCard =
|
|
351
|
+
el.type === 'chip'
|
|
352
|
+
? discOnCard(el.transform, CHIP_RADIUS_MM * el.transform.scale)
|
|
353
|
+
: cornersOnCard(obbCorners(el.transform, footprintOf(el).w, footprintOf(el).h))
|
|
354
|
+
if (!onCard) {
|
|
328
355
|
push('elementOffCard', 'The element extends past the printable card area.')
|
|
329
356
|
}
|
|
330
357
|
return out
|