tsdraft 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/layers.js ADDED
@@ -0,0 +1,178 @@
1
+ // AutoCAD discards a drawing whose layer table has a name with one of the
2
+ // characters below, as it does for the ASCII punctuation `rules.ts` lists. The
3
+ // exception is the private-use block, which it replaces with `\M+00000`. They
4
+ // are string literals, with every character written as an escape, so that
5
+ // `rules.ts` can also reject them in a layer name written as a literal.
6
+ /** C1 controls and invisible formatting characters, such as the zero-width space. */
7
+ export const REJECTED_FORMAT = "\
8
+ \u0080\u0081\u0082\u0083\u0084\u0085\u0086\u0087\u0088\u0089\u008a\u008b\u008c\u008d\u008e\
9
+ \u008f\u0090\u0091\u0092\u0093\u0094\u0095\u0096\u0097\u0098\u0099\u009a\u009b\u009c\u009d\
10
+ \u009e\u009f\u0600\u0601\u0602\u0603\u06dd\u070f\u17b4\u17b5\u200b\u200c\u200d\u200e\u200f\
11
+ \u202a\u202b\u202c\u202d\u202e\u2060\u2061\u2062\u2063\u2064\u206a\u206b\u206c\u206d\u206e\
12
+ \u206f\ufeff\ufff9\ufffa\ufffb";
13
+ /** Superscript and subscript letters, Roman numeral characters and letterlike symbols such as the ohm sign. */
14
+ export const REJECTED_LETTERLIKE = "\
15
+ \u2071\u207f\u2090\u2091\u2092\u2093\u2094\u2102\u2107\u210a\u210b\u210c\u210d\u210e\u210f\
16
+ \u2110\u2111\u2112\u2113\u2115\u2119\u211a\u211b\u211c\u211d\u2124\u2126\u2128\u212a\u212b\
17
+ \u212c\u212d\u212f\u2130\u2131\u2132\u2133\u2134\u2135\u2136\u2137\u2138\u2139\u213c\u213d\
18
+ \u213e\u213f\u2145\u2146\u2147\u2148\u2149\u214e\u2180\u2181\u2182\u2183\u2184\u2185\u2186\
19
+ \u2187\u2188";
20
+ /** Glagolitic letters and the Latin Extended-C block. */
21
+ export const REJECTED_GLAGOLITIC = "\
22
+ \u2c00\u2c01\u2c02\u2c03\u2c04\u2c05\u2c06\u2c07\u2c08\u2c09\u2c0a\u2c0b\u2c0c\u2c0d\u2c0e\
23
+ \u2c0f\u2c10\u2c11\u2c12\u2c13\u2c14\u2c15\u2c16\u2c17\u2c18\u2c19\u2c1a\u2c1b\u2c1c\u2c1d\
24
+ \u2c1e\u2c1f\u2c20\u2c21\u2c22\u2c23\u2c24\u2c25\u2c26\u2c27\u2c28\u2c29\u2c2a\u2c2b\u2c2c\
25
+ \u2c2d\u2c2e\u2c30\u2c31\u2c32\u2c33\u2c34\u2c35\u2c36\u2c37\u2c38\u2c39\u2c3a\u2c3b\u2c3c\
26
+ \u2c3d\u2c3e\u2c3f\u2c40\u2c41\u2c42\u2c43\u2c44\u2c45\u2c46\u2c47\u2c48\u2c49\u2c4a\u2c4b\
27
+ \u2c4c\u2c4d\u2c4e\u2c4f\u2c50\u2c51\u2c52\u2c53\u2c54\u2c55\u2c56\u2c57\u2c58\u2c59\u2c5a\
28
+ \u2c5b\u2c5c\u2c5d\u2c5e\u2c60\u2c61\u2c62\u2c63\u2c64\u2c65\u2c66\u2c67\u2c68\u2c69\u2c6a\
29
+ \u2c6b\u2c6c\u2c6d\u2c6e\u2c6f\u2c71\u2c72\u2c73\u2c74\u2c75\u2c76\u2c77\u2c78\u2c79\u2c7a\
30
+ \u2c7b\u2c7c\u2c7d";
31
+ /** Coptic letters. */
32
+ export const REJECTED_COPTIC = "\
33
+ \u2c80\u2c81\u2c82\u2c83\u2c84\u2c85\u2c86\u2c87\u2c88\u2c89\u2c8a\u2c8b\u2c8c\u2c8d\u2c8e\
34
+ \u2c8f\u2c90\u2c91\u2c92\u2c93\u2c94\u2c95\u2c96\u2c97\u2c98\u2c99\u2c9a\u2c9b\u2c9c\u2c9d\
35
+ \u2c9e\u2c9f\u2ca0\u2ca1\u2ca2\u2ca3\u2ca4\u2ca5\u2ca6\u2ca7\u2ca8\u2ca9\u2caa\u2cab\u2cac\
36
+ \u2cad\u2cae\u2caf\u2cb0\u2cb1\u2cb2\u2cb3\u2cb4\u2cb5\u2cb6\u2cb7\u2cb8\u2cb9\u2cba\u2cbb\
37
+ \u2cbc\u2cbd\u2cbe\u2cbf\u2cc0\u2cc1\u2cc2\u2cc3\u2cc4\u2cc5\u2cc6\u2cc7\u2cc8\u2cc9\u2cca\
38
+ \u2ccb\u2ccc\u2ccd\u2cce\u2ccf\u2cd0\u2cd1\u2cd2\u2cd3\u2cd4\u2cd5\u2cd6\u2cd7\u2cd8\u2cd9\
39
+ \u2cda\u2cdb\u2cdc\u2cdd\u2cde\u2cdf\u2ce0\u2ce1\u2ce2\u2ce3\u2ce4";
40
+ /** Georgian Supplement, Tifinagh and Ethiopic Extended letters. */
41
+ export const REJECTED_GEORGIAN = "\
42
+ \u2d00\u2d01\u2d02\u2d03\u2d04\u2d05\u2d06\u2d07\u2d08\u2d09\u2d0a\u2d0b\u2d0c\u2d0d\u2d0e\
43
+ \u2d0f\u2d10\u2d11\u2d12\u2d13\u2d14\u2d15\u2d16\u2d17\u2d18\u2d19\u2d1a\u2d1b\u2d1c\u2d1d\
44
+ \u2d1e\u2d1f\u2d20\u2d21\u2d22\u2d23\u2d24\u2d25\u2d30\u2d31\u2d32\u2d33\u2d34\u2d35\u2d36\
45
+ \u2d37\u2d38\u2d39\u2d3a\u2d3b\u2d3c\u2d3d\u2d3e\u2d3f\u2d40\u2d41\u2d42\u2d43\u2d44\u2d45\
46
+ \u2d46\u2d47\u2d48\u2d49\u2d4a\u2d4b\u2d4c\u2d4d\u2d4e\u2d4f\u2d50\u2d51\u2d52\u2d53\u2d54\
47
+ \u2d55\u2d56\u2d57\u2d58\u2d59\u2d5a\u2d5b\u2d5c\u2d5d\u2d5e\u2d5f\u2d60\u2d61\u2d62\u2d63\
48
+ \u2d64\u2d65\u2d6f\u2d80\u2d81\u2d82\u2d83\u2d84\u2d85\u2d86\u2d87\u2d88\u2d89\u2d8a\u2d8b\
49
+ \u2d8c\u2d8d\u2d8e\u2d8f\u2d90\u2d91\u2d92\u2d93\u2d94\u2d95\u2d96\u2da0\u2da1\u2da2\u2da3\
50
+ \u2da4\u2da5\u2da6\u2da8\u2da9\u2daa\u2dab\u2dac\u2dad\u2dae\u2db0\u2db1\u2db2\u2db3\u2db4\
51
+ \u2db5\u2db6\u2db8\u2db9\u2dba\u2dbb\u2dbc\u2dbd\u2dbe\u2dc0\u2dc1\u2dc2\u2dc3\u2dc4\u2dc5\
52
+ \u2dc6\u2dc8\u2dc9\u2dca\u2dcb\u2dcc\u2dcd\u2dce\u2dd0\u2dd1\u2dd2\u2dd3\u2dd4\u2dd5\u2dd6\
53
+ \u2dd8\u2dd9\u2dda\u2ddb\u2ddc\u2ddd\u2dde";
54
+ /** The private-use characters U+E800 to U+E8FF. */
55
+ export const REJECTED_PRIVATE = "\
56
+ \ue800\ue801\ue802\ue803\ue804\ue805\ue806\ue807\ue808\ue809\ue80a\ue80b\ue80c\ue80d\ue80e\
57
+ \ue80f\ue810\ue811\ue812\ue813\ue814\ue815\ue816\ue817\ue818\ue819\ue81a\ue81b\ue81c\ue81d\
58
+ \ue81e\ue81f\ue820\ue821\ue822\ue823\ue824\ue825\ue826\ue827\ue828\ue829\ue82a\ue82b\ue82c\
59
+ \ue82d\ue82e\ue82f\ue830\ue831\ue832\ue833\ue834\ue835\ue836\ue837\ue838\ue839\ue83a\ue83b\
60
+ \ue83c\ue83d\ue83e\ue83f\ue840\ue841\ue842\ue843\ue844\ue845\ue846\ue847\ue848\ue849\ue84a\
61
+ \ue84b\ue84c\ue84d\ue84e\ue84f\ue850\ue851\ue852\ue853\ue854\ue855\ue856\ue857\ue858\ue859\
62
+ \ue85a\ue85b\ue85c\ue85d\ue85e\ue85f\ue860\ue861\ue862\ue863\ue864\ue865\ue866\ue867\ue868\
63
+ \ue869\ue86a\ue86b\ue86c\ue86d\ue86e\ue86f\ue870\ue871\ue872\ue873\ue874\ue875\ue876\ue877\
64
+ \ue878\ue879\ue87a\ue87b\ue87c\ue87d\ue87e\ue87f\ue880\ue881\ue882\ue883\ue884\ue885\ue886\
65
+ \ue887\ue888\ue889\ue88a\ue88b\ue88c\ue88d\ue88e\ue88f\ue890\ue891\ue892\ue893\ue894\ue895\
66
+ \ue896\ue897\ue898\ue899\ue89a\ue89b\ue89c\ue89d\ue89e\ue89f\ue8a0\ue8a1\ue8a2\ue8a3\ue8a4\
67
+ \ue8a5\ue8a6\ue8a7\ue8a8\ue8a9\ue8aa\ue8ab\ue8ac\ue8ad\ue8ae\ue8af\ue8b0\ue8b1\ue8b2\ue8b3\
68
+ \ue8b4\ue8b5\ue8b6\ue8b7\ue8b8\ue8b9\ue8ba\ue8bb\ue8bc\ue8bd\ue8be\ue8bf\ue8c0\ue8c1\ue8c2\
69
+ \ue8c3\ue8c4\ue8c5\ue8c6\ue8c7\ue8c8\ue8c9\ue8ca\ue8cb\ue8cc\ue8cd\ue8ce\ue8cf\ue8d0\ue8d1\
70
+ \ue8d2\ue8d3\ue8d4\ue8d5\ue8d6\ue8d7\ue8d8\ue8d9\ue8da\ue8db\ue8dc\ue8dd\ue8de\ue8df\ue8e0\
71
+ \ue8e1\ue8e2\ue8e3\ue8e4\ue8e5\ue8e6\ue8e7\ue8e8\ue8e9\ue8ea\ue8eb\ue8ec\ue8ed\ue8ee\ue8ef\
72
+ \ue8f0\ue8f1\ue8f2\ue8f3\ue8f4\ue8f5\ue8f6\ue8f7\ue8f8\ue8f9\ue8fa\ue8fb\ue8fc\ue8fd\ue8fe\
73
+ \ue8ff";
74
+ const REJECTED = new Set(REJECTED_FORMAT +
75
+ REJECTED_LETTERLIKE +
76
+ REJECTED_GLAGOLITIC +
77
+ REJECTED_COPTIC +
78
+ REJECTED_GEORGIAN +
79
+ REJECTED_PRIVATE);
80
+ /**
81
+ * Finds the first character of a layer name that AutoCAD rejects, beyond the
82
+ * ASCII punctuation and control characters that `layerNameProblem` checks.
83
+ *
84
+ * @param name - The layer name.
85
+ * @returns The character, or undefined when the name has none.
86
+ */
87
+ export function rejectedNameCharacter(name) {
88
+ for (const character of name)
89
+ if (REJECTED.has(character))
90
+ return character;
91
+ return undefined;
92
+ }
93
+ /**
94
+ * The capital letters AutoCAD lower-cases when it looks a layer name up, as
95
+ * hexadecimal code point ranges. `/2` marks a range that takes every second
96
+ * code point. AutoCAD's table is older and smaller than Unicode's: it leaves
97
+ * out Ÿ (U+0178), İ (U+0130) and ϴ (U+03F4), the title-case digraphs such as
98
+ * Dž (U+01C5), and most letters added since Unicode 5.1.
99
+ */
100
+ const FOLDED_RANGES = "41-5a c0-d6 d8-de 100-12e/2 132-136/2 139-147/2 14a-176/2 179-17d/2 181-182 184-186/2 " +
101
+ "187-189/2 18a-18b 18e-191 193-194 196-198 19c-19d 19f-1a0 1a2-1a6/2 1a7-1a9/2 1ac-1ae/2 " +
102
+ "1af-1b1/2 1b2-1b3 1b5-1b7/2 1b8 1bc 1c4 1c7 1ca 1cd-1db/2 1de-1ee/2 1f1 1f4-1f6/2 " +
103
+ "1f7-1f8 1fa-232/2 23b-23d/2 241-243/2 244-246 248-24e/2 370-372/2 376 386-388/2 389-38a " +
104
+ "38c-38e/2 38f-391/2 392-3a1 3a3-3ab 3cf 3d8-3ee/2 3f7-3f9/2 3fa 3fd-42f 460-480/2 " +
105
+ "48a-4c0/2 4c1-4cd/2 4d0-522/2 531-556 1e00-1e94/2 1ea0-1efe/2 1f08-1f0f 1f18-1f1d " +
106
+ "1f28-1f2f 1f38-1f3f 1f48-1f4d 1f59-1f5f/2 1f68-1f6f 1f88-1f8f 1f98-1f9f 1fa8-1faf " +
107
+ "1fb8-1fbc 1fc8-1fcc 1fd8-1fdb 1fe8-1fec 1ff8-1ffc 2160-216f 24b6-24cf a640-a65e/2 " +
108
+ "a662-a66c/2 a680-a696/2 a722-a72e/2 a732-a76e/2 a779-a77d/2 a77e-a786/2 a78b ff21-ff3a";
109
+ const FOLDED = new Set(FOLDED_RANGES.split(" ").flatMap((range) => {
110
+ const [span = "", step = "1"] = range.split("/");
111
+ const [first = "", last = first] = span.split("-");
112
+ const codes = [];
113
+ for (let code = parseInt(first, 16); code <= parseInt(last, 16); code += Number(step)) {
114
+ codes.push(code);
115
+ }
116
+ return codes;
117
+ }));
118
+ /**
119
+ * Pairs of letters that AutoCAD keeps as two layers but its AUDIT reports as a
120
+ * duplicate name, written `from>to` in hexadecimal after `layerKey` has
121
+ * lower-cased the name. They include ÿ and Ÿ, the two lower-case sigmas σ and
122
+ * ς, and ß and ẞ.
123
+ */
124
+ const AUDIT_SAME_PAIRS = "178>ff 1c6>1c5 1c9>1c8 1cc>1cb 1f3>1f2 3c3>3c2 3f5>3b5 527>526 1e9e>df 1fbe>3b9 " +
125
+ "2c7e>23f 2c7f>240 2cec>2ceb 2cee>2ced a791>a790 a7a1>a7a0 a7a3>a7a2 a7a5>a7a4 " +
126
+ "a7a7>a7a6 a7a9>a7a8";
127
+ const AUDIT_SAME = new Map(AUDIT_SAME_PAIRS.split(" ").map((pair) => {
128
+ const [from = "", to = ""] = pair.split(">");
129
+ return [String.fromCharCode(parseInt(from, 16)), String.fromCharCode(parseInt(to, 16))];
130
+ }));
131
+ /**
132
+ * The form of a layer name that AutoCAD looks layers up by. Two names with the
133
+ * same key are one layer to AutoCAD: an entity on `wall` goes on a layer
134
+ * declared as `WALL`, and AutoCAD keeps only the first of two such layers.
135
+ *
136
+ * AutoCAD lower-cases one character at a time from its own table, so this is
137
+ * not `toLowerCase()`: a final sigma ς, a dotless ı and ß stay as they are.
138
+ *
139
+ * @param name - The layer name.
140
+ * @returns The key to compare layer names by.
141
+ */
142
+ export function layerKey(name) {
143
+ let key = "";
144
+ for (let i = 0; i < name.length; i++) {
145
+ const character = name.charAt(i);
146
+ key += FOLDED.has(name.charCodeAt(i)) ? character.toLowerCase() : character;
147
+ }
148
+ return key;
149
+ }
150
+ /**
151
+ * The form of a layer name that AutoCAD's AUDIT compares when it looks for
152
+ * duplicate names. AUDIT takes a few more letters for the same than the layer
153
+ * table does, so two layers can have different `layerKey`s and still be an
154
+ * AUDIT error.
155
+ *
156
+ * This covers the single letters AUDIT adds, and not everything it reports:
157
+ * it also calls `straße` and `strasse`, `æ` and `ae`, and a composed é and its
158
+ * decomposed form duplicates, which this key keeps apart.
159
+ *
160
+ * @param name - The layer name.
161
+ * @returns The key to find AUDIT's duplicate names by.
162
+ */
163
+ export function layerAuditKey(name) {
164
+ let key = "";
165
+ for (const character of layerKey(name))
166
+ key += AUDIT_SAME.get(character) ?? character;
167
+ return key;
168
+ }
169
+ /**
170
+ * Orders layers for a DXF layer table. AutoCAD's AUDIT reports "Layer zero name
171
+ * found at incorrect index" when layer `0` is not the first record.
172
+ *
173
+ * @param layers - The drawing's layers, in any order.
174
+ * @returns The layers with `0` first and the others in their given order.
175
+ */
176
+ export function layerZeroFirst(layers) {
177
+ return [...layers.filter((l) => l.name === "0"), ...layers.filter((l) => l.name !== "0")];
178
+ }
package/dist/lint.d.ts CHANGED
@@ -23,8 +23,17 @@ export interface LintOptions {
23
23
  * exported as a different version than it was built for, and for warnings.
24
24
  */
25
25
  export declare function lint(drawing: Drawing, options?: LintOptions): Diagnostic[];
26
- /** Thrown by `toDXF` when a drawing has lint errors for the version being written. */
26
+ /**
27
+ * An error that refuses a drawing because of what `lint` found in it. `toDXF`
28
+ * throws it when a drawing has lint errors for the version being written, and
29
+ * other code can throw it to refuse a drawing for its own purpose. Its message
30
+ * lists the first 10 problems and names no file format.
31
+ */
27
32
  export declare class DrawingError extends Error {
33
+ /** Every problem the drawing was refused for, including those the message leaves out. */
28
34
  readonly diagnostics: readonly Diagnostic[];
35
+ /**
36
+ * @param diagnostics - The problems to refuse the drawing for, as `lint` returns them.
37
+ */
29
38
  constructor(diagnostics: readonly Diagnostic[]);
30
39
  }
package/dist/lint.js CHANGED
@@ -1,4 +1,6 @@
1
- import { layerColorProblem, layerNameProblem, textProblem } from "./rules.js";
1
+ import { written } from "./format.js";
2
+ import { layerAuditKey, layerKey } from "./layers.js";
3
+ import { arcIsWrittenAsPoint, arcProblem, layerColorProblem, layerNameProblem, layerTwinProblem, magnitudeProblem, sizeProblem, textProblem, textWarning, } from "./rules.js";
2
4
  /**
3
5
  * Checks a drawing against the rules AutoCAD enforces when it opens DXF.
4
6
  *
@@ -17,7 +19,9 @@ export function lint(drawing, options = {}) {
17
19
  message: `Unknown units: ${JSON.stringify(drawing.units)}`,
18
20
  });
19
21
  }
22
+ // AutoCAD looks layers up by one form of their names, and AUDIT compares another.
20
23
  const seen = new Set();
24
+ const audited = new Map();
21
25
  for (const layer of drawing.layers) {
22
26
  const error = (rule, message) => out.push({ rule, severity: "error", message, layer: layer.name });
23
27
  const nameProblem = layerNameProblem(layer.name, version);
@@ -26,40 +30,62 @@ export function lint(drawing, options = {}) {
26
30
  const colorProblem = layerColorProblem(layer.color);
27
31
  if (colorProblem)
28
32
  error("layer-color", `Layer ${JSON.stringify(layer.name)}: ${colorProblem}`);
29
- const key = layer.name.toLowerCase();
33
+ const key = layerKey(layer.name);
34
+ const auditKey = layerAuditKey(layer.name);
35
+ const twinProblem = layerTwinProblem(layer.name, audited.get(auditKey) ?? layer.name);
30
36
  if (seen.has(key)) {
31
37
  error("duplicate-layer", `Layer ${JSON.stringify(layer.name)} is defined twice. CAD ignores case in layer names`);
32
38
  }
39
+ else if (twinProblem) {
40
+ error("duplicate-layer", `Layer ${JSON.stringify(layer.name)}: ${twinProblem}`);
41
+ }
33
42
  seen.add(key);
43
+ if (!audited.has(auditKey))
44
+ audited.set(auditKey, layer.name);
34
45
  }
35
46
  if (!seen.has("0")) {
36
47
  out.push({ rule: "undefined-layer", severity: "error", message: 'Layer "0" is missing' });
37
48
  }
38
49
  drawing.entities.forEach((entity, index) => {
39
50
  const add = (rule, severity, problem) => out.push({ rule, severity, message: `${entity.kind} #${index}: ${problem}`, entity: index });
40
- if (!seen.has(entity.layer.toLowerCase())) {
51
+ if (!seen.has(layerKey(entity.layer))) {
41
52
  add("undefined-layer", "error", `layer ${JSON.stringify(entity.layer)} is not defined`);
42
53
  }
43
- for (const problem of geometryProblems(entity))
54
+ const problems = geometryProblems(entity);
55
+ for (const problem of problems)
44
56
  add("geometry", "error", problem);
45
57
  if (entity.kind === "text") {
46
58
  const problem = textProblem(entity.value);
47
59
  if (problem)
48
60
  add("text", "error", problem);
61
+ const warning = textWarning(entity.value);
62
+ if (warning)
63
+ add("text", "warning", warning);
49
64
  }
50
- if (entity.kind === "line" && same(entity.from, entity.to)) {
51
- add("zero-length", "warning", "the line has no length");
52
- }
65
+ // A shape with a geometry error has no length worth a second message.
66
+ const collapsed = problems.length === 0 ? zeroLength(entity) : undefined;
67
+ if (collapsed)
68
+ add("zero-length", "warning", collapsed);
53
69
  });
54
70
  return out;
55
71
  }
56
- /** Thrown by `toDXF` when a drawing has lint errors for the version being written. */
72
+ /**
73
+ * An error that refuses a drawing because of what `lint` found in it. `toDXF`
74
+ * throws it when a drawing has lint errors for the version being written, and
75
+ * other code can throw it to refuse a drawing for its own purpose. Its message
76
+ * lists the first 10 problems and names no file format.
77
+ */
57
78
  export class DrawingError extends Error {
79
+ /** Every problem the drawing was refused for, including those the message leaves out. */
58
80
  diagnostics;
81
+ /**
82
+ * @param diagnostics - The problems to refuse the drawing for, as `lint` returns them.
83
+ */
59
84
  constructor(diagnostics) {
85
+ const count = diagnostics.length === 1 ? "1 problem" : `${diagnostics.length} problems`;
60
86
  const shown = diagnostics.slice(0, 10).map((d) => `- ${d.message}`);
61
87
  const more = diagnostics.length > 10 ? [`- and ${diagnostics.length - 10} more`] : [];
62
- super(["The drawing cannot be written as DXF:", ...shown, ...more].join("\n"));
88
+ super([`The drawing has ${count}:`, ...shown, ...more].join("\n"));
63
89
  this.name = "DrawingError";
64
90
  this.diagnostics = diagnostics;
65
91
  }
@@ -67,12 +93,18 @@ export class DrawingError extends Error {
67
93
  function geometryProblems(e) {
68
94
  const out = [];
69
95
  const finite = (label, ...values) => {
70
- if (!values.every(Number.isFinite))
96
+ if (!values.every(Number.isFinite)) {
71
97
  out.push(`${label} must be finite numbers`);
98
+ return;
99
+ }
100
+ const problem = values.map(magnitudeProblem).find((p) => p !== undefined);
101
+ if (problem)
102
+ out.push(`${label}: ${problem}`);
72
103
  };
73
- const positive = (label, value) => {
74
- if (!(Number.isFinite(value) && value > 0))
75
- out.push(`${label} must be greater than 0`);
104
+ const size = (label, value) => {
105
+ const problem = Number.isFinite(value) ? sizeProblem(value) : "This value must be finite";
106
+ if (problem)
107
+ out.push(`${label}: ${problem}`);
76
108
  };
77
109
  switch (e.kind) {
78
110
  case "line":
@@ -81,29 +113,76 @@ function geometryProblems(e) {
81
113
  case "polyline":
82
114
  if (e.points.length < 2)
83
115
  out.push("a polyline needs at least 2 points");
84
- finite("its points", ...e.points.flat());
116
+ // A loop, because a polyline can have more points than a call can take arguments.
117
+ if (!e.points.every((p) => Number.isFinite(p[0]) && Number.isFinite(p[1]))) {
118
+ out.push("its points must be finite numbers");
119
+ }
120
+ else {
121
+ for (const [x, y] of e.points) {
122
+ const problem = magnitudeProblem(x) ?? magnitudeProblem(y);
123
+ if (!problem)
124
+ continue;
125
+ out.push(`its points: ${problem}`);
126
+ break;
127
+ }
128
+ }
85
129
  break;
86
130
  case "circle":
87
131
  finite("its center", ...e.center);
88
- positive("its radius", e.radius);
132
+ size("radius", e.radius);
89
133
  break;
90
134
  case "arc":
91
135
  finite("its center", ...e.center);
92
- positive("its radius", e.radius);
136
+ size("radius", e.radius);
93
137
  if (![e.startAngle, e.endAngle].every((a) => Number.isFinite(a) && a >= 0 && a < 360)) {
94
138
  out.push("its angles must be from 0 up to 360");
95
139
  }
96
140
  else if (e.startAngle === e.endAngle) {
97
141
  out.push("its start and end angles must differ");
98
142
  }
143
+ else {
144
+ const problem = arcProblem(e.startAngle, e.endAngle);
145
+ if (problem)
146
+ out.push(problem);
147
+ }
99
148
  break;
100
149
  case "text":
101
150
  finite("its position and rotation", ...e.at, e.rotation);
102
- positive("its height", e.height);
151
+ size("height", e.height);
103
152
  break;
104
153
  }
105
154
  return out;
106
155
  }
156
+ /** Says how a valid entity ends up with no length in the file, or returns undefined when it has one. */
157
+ function zeroLength(e) {
158
+ const rounded = "once it is rounded to the 6 decimals tsdraft writes";
159
+ switch (e.kind) {
160
+ case "line":
161
+ if (same(e.from, e.to))
162
+ return "the line has no length";
163
+ return sameWritten(e.from, e.to) ? `the line has no length ${rounded}` : undefined;
164
+ case "polyline": {
165
+ const [first] = e.points;
166
+ if (!first)
167
+ return undefined;
168
+ if (e.points.every((p) => same(p, first)))
169
+ return "the polyline has no length";
170
+ return e.points.every((p) => sameWritten(p, first))
171
+ ? `the polyline has no length ${rounded}`
172
+ : undefined;
173
+ }
174
+ case "arc":
175
+ return arcIsWrittenAsPoint(e.startAngle, e.endAngle)
176
+ ? `the arc has no length ${rounded}`
177
+ : undefined;
178
+ case "circle":
179
+ case "text":
180
+ return undefined;
181
+ }
182
+ }
107
183
  function same(a, b) {
108
184
  return a[0] === b[0] && a[1] === b[1];
109
185
  }
186
+ function sameWritten(a, b) {
187
+ return written(a[0]) === written(b[0]) && written(a[1]) === written(b[1]);
188
+ }
package/dist/rules.d.ts CHANGED
@@ -1,14 +1,19 @@
1
1
  import type { DxfVersion } from "./drawing.ts";
2
+ import { type REJECTED_COPTIC, type REJECTED_FORMAT, type REJECTED_GEORGIAN, type REJECTED_GLAGOLITIC, type REJECTED_LETTERLIKE, type REJECTED_PRIVATE } from "./layers.ts";
2
3
  declare const NAME_EMPTY = "Layer names cannot be empty";
3
4
  declare const NAME_CHARS = "Layer names cannot contain < > / \\ \" : ; ? * | = ` ^ , or line breaks";
5
+ declare const NAME_REJECTED = "Layer names cannot contain a character AutoCAD rejects or replaces in names, such as a zero-width space or the ohm sign U+2126. The README lists them";
4
6
  declare const NAME_EDGE = "Layer names cannot start or end with a space";
5
7
  declare const NAME_R12_CHARS = "R12 layer names can only use letters, digits, $, - and _. Use DXF version \"R2018\" for other characters";
6
8
  declare const NAME_R12_LENGTH = "R12 layer names cannot be longer than 31 characters. Use DXF version \"R2018\" for longer names";
7
9
  declare const COLOR = "A layer color must be an AutoCAD Color Index integer from 1 to 255";
8
10
  declare const POSITIVE = "This value must be greater than 0";
11
+ declare const WRITTEN_AS_ZERO = "This value is written as 0, because tsdraft writes 6 decimals. Use 0.000001 or more";
12
+ declare const TOO_LARGE = "This value must be under 1e100, because AutoCAD reads only two exponent digits from DXF and would take 1e+100 for 1e+10";
9
13
  type ForbiddenChar = "<" | ">" | "/" | "\\" | '"' | ":" | ";" | "?" | "*" | "|" | "=" | "`" | "^" | "," | "\n" | "\r" | "\t";
10
14
  type Chars<S extends string, Found = never> = S extends `${infer C}${infer Rest}` ? Chars<Rest, Found | C> : Found;
11
15
  type R12Char = Chars<"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789$_-">;
16
+ type RejectedChar = Chars<typeof REJECTED_FORMAT> | Chars<typeof REJECTED_LETTERLIKE> | Chars<typeof REJECTED_GLAGOLITIC> | Chars<typeof REJECTED_COPTIC> | Chars<typeof REJECTED_GEORGIAN> | Chars<typeof REJECTED_PRIVATE>;
12
17
  /** Walks the name one character at a time, counting, so it can check both the characters and the length. */
13
18
  type R12Name<N extends string, Rest extends string = N, Count extends 0[] = []> = Count["length"] extends 32 ? typeof NAME_R12_LENGTH : Rest extends `${infer C}${infer Tail}` ? C extends R12Char ? R12Name<N, Tail, [...Count, 0]> : typeof NAME_R12_CHARS : N;
14
19
  /**
@@ -16,18 +21,96 @@ type R12Name<N extends string, Rest extends string = N, Count extends 0[] = []>
16
21
  * error message otherwise, so the compiler reports the reason. Names that are
17
22
  * not literals pass the compiler and are checked when the code runs.
18
23
  */
19
- export type LayerName<N extends string, V extends DxfVersion> = string extends N ? N : N extends "" ? typeof NAME_EMPTY : N extends `${string}${ForbiddenChar}${string}` ? typeof NAME_CHARS : N extends ` ${string}` | `${string} ` ? typeof NAME_EDGE : V extends "R12" ? R12Name<N> : N;
24
+ export type LayerName<N extends string, V extends DxfVersion> = string extends N ? N : N extends "" ? typeof NAME_EMPTY : N extends `${string}${ForbiddenChar}${string}` ? typeof NAME_CHARS : N extends `${string}${RejectedChar}${string}` ? typeof NAME_REJECTED : N extends ` ${string}` | `${string} ` ? typeof NAME_EDGE : V extends "R12" ? R12Name<N> : N;
20
25
  type Enumerate<N extends number, A extends number[] = []> = A["length"] extends N ? A[number] : Enumerate<N, [...A, A["length"]]>;
21
26
  /** AutoCAD Color Index: an integer from 1 to 255. */
22
27
  export type Aci = Exclude<Enumerate<256>, 0>;
23
28
  /** Resolves to `C` when it is a valid layer color, and to the error message otherwise. */
24
29
  export type LayerColor<C extends number> = number extends C ? C : C extends Aci ? C : typeof COLOR;
25
- /** Resolves to `N` when it is greater than 0, and to the error message otherwise. */
26
- export type Positive<N extends number> = number extends N ? N : `${N}` extends `-${string}` | "0" ? typeof POSITIVE : N;
30
+ /**
31
+ * True for a number the writers round to 0, given as the compiler prints it.
32
+ * A number under 0.000001 prints in exponent form, such as `4e-7`, and those
33
+ * under 0.0000005 round to 0. `5e-7` itself is stored a little below a half,
34
+ * so it rounds down too.
35
+ */
36
+ type WrittenAsZero<S extends string> = S extends `${infer Mantissa}e-${infer Exponent}` ? Exponent extends "7" ? Mantissa extends "5" ? true : Mantissa extends `${5 | 6 | 7 | 8 | 9}${string}` ? false : true : true : false;
37
+ type Digit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9";
38
+ /**
39
+ * Resolves to `N` when it is a size the writers can keep, and to the error
40
+ * message otherwise: `N` must be greater than 0, must not round to 0 at the
41
+ * 6 decimals tsdraft writes, and must be under 1e100, where the compiler
42
+ * prints a third exponent digit as the writers do.
43
+ */
44
+ export type Positive<N extends number> = number extends N ? N : `${N}` extends `-${string}` | "0" ? typeof POSITIVE : WrittenAsZero<`${N}`> extends true ? typeof WRITTEN_AS_ZERO : `${N}` extends `${string}e+${Digit}${Digit}${Digit}` ? typeof TOO_LARGE : N;
27
45
  /** Returns what is wrong with a layer name for a DXF version, or undefined when it is valid. */
28
46
  export declare function layerNameProblem(name: string, version: DxfVersion): string | undefined;
47
+ /**
48
+ * Returns what is wrong with a drawing that has both names as layers, or
49
+ * undefined when the two can stand together. AutoCAD looks layers up one way
50
+ * and its AUDIT compares their names another, so a few pairs of names, such
51
+ * as `ΟΔΟΣ` and `οδος`, are two layers to AutoCAD and one duplicate name to
52
+ * AUDIT. Names that are one layer to AutoCAD are not this function's concern.
53
+ * The rule compares two names, so it has no type.
54
+ *
55
+ * @param name - The name of the layer being checked.
56
+ * @param other - The name of another layer in the drawing.
57
+ * @returns The problem, or undefined.
58
+ */
59
+ export declare function layerTwinProblem(name: string, other: string): string | undefined;
29
60
  /** Returns what is wrong with a layer color, or undefined when it is valid. */
30
61
  export declare function layerColorProblem(color: number): string | undefined;
62
+ /**
63
+ * Returns what is wrong with a radius, a text height or a rectangle size, or
64
+ * undefined when it is valid. A size that is written as 0 is refused like 0
65
+ * itself: AutoCAD gives text of height 0 the drawing's default text height,
66
+ * and a circle of radius 0 is a point. A size of 1e100 or more is refused
67
+ * too, as `magnitudeProblem` explains.
68
+ *
69
+ * @param value - The size, which must be a finite number.
70
+ * @returns The problem, or undefined.
71
+ */
72
+ export declare function sizeProblem(value: number): string | undefined;
73
+ /**
74
+ * Returns what is wrong with the size of a number a drawing holds, or
75
+ * undefined when AutoCAD can read it. This goes for every number that is
76
+ * written: coordinates, sizes and text rotation. Only sizes can be checked as
77
+ * literals, through `Positive`, because points and rotations have no literal
78
+ * types.
79
+ *
80
+ * @param value - The number, which must be finite.
81
+ * @returns The problem, or undefined.
82
+ */
83
+ export declare function magnitudeProblem(value: number): string | undefined;
84
+ /**
85
+ * Returns what is wrong with an arc's angles once they are rounded for
86
+ * writing, or undefined when the arc can be written. AutoCAD holds an arc
87
+ * whose start and end angles are equal as an arc of no length, so an arc that
88
+ * is all but a full circle would vanish. The compiler cannot subtract two
89
+ * literals, so this rule has no type.
90
+ *
91
+ * @param startAngle - The start angle in degrees, from 0 up to 360.
92
+ * @param endAngle - The end angle in degrees, from 0 up to 360.
93
+ * @returns The problem, or undefined.
94
+ */
95
+ export declare function arcProblem(startAngle: number, endAngle: number): string | undefined;
96
+ /**
97
+ * True for an arc so short that its angles are the same once rounded, which
98
+ * is written as an arc of no length. That is within rounding of what was
99
+ * drawn, so it is worth a warning and not an error.
100
+ *
101
+ * @param startAngle - The start angle in degrees, from 0 up to 360.
102
+ * @param endAngle - The end angle in degrees, from 0 up to 360.
103
+ * @returns Whether the arc is written with no length.
104
+ */
105
+ export declare function arcIsWrittenAsPoint(startAngle: number, endAngle: number): boolean;
31
106
  /** Returns what is wrong with a single-line text value, or undefined when it is valid. */
32
107
  export declare function textProblem(value: string): string | undefined;
108
+ /**
109
+ * Returns a warning for text that AutoCAD stores as written but draws
110
+ * differently, or undefined when it draws what it stores.
111
+ *
112
+ * @param value - The text value.
113
+ * @returns The warning, or undefined.
114
+ */
115
+ export declare function textWarning(value: string): string | undefined;
33
116
  export {};
package/dist/rules.js CHANGED
@@ -1,18 +1,30 @@
1
+ import { isTooLarge, written, writtenAngle } from "./format.js";
2
+ import { layerAuditKey, layerKey, rejectedNameCharacter, } from "./layers.js";
1
3
  const NAME_EMPTY = "Layer names cannot be empty";
2
4
  const NAME_CHARS = 'Layer names cannot contain < > / \\ " : ; ? * | = ` ^ , or line breaks';
5
+ const NAME_REJECTED = "Layer names cannot contain a character AutoCAD rejects or replaces in names, such as a zero-width space or the ohm sign U+2126. The README lists them";
3
6
  const NAME_EDGE = "Layer names cannot start or end with a space";
4
7
  const NAME_LENGTH = "Layer names cannot be longer than 255 characters";
5
8
  const NAME_R12_CHARS = 'R12 layer names can only use letters, digits, $, - and _. Use DXF version "R2018" for other characters';
6
9
  const NAME_R12_LENGTH = 'R12 layer names cannot be longer than 31 characters. Use DXF version "R2018" for longer names';
7
10
  const COLOR = "A layer color must be an AutoCAD Color Index integer from 1 to 255";
8
11
  const POSITIVE = "This value must be greater than 0";
12
+ const WRITTEN_AS_ZERO = "This value is written as 0, because tsdraft writes 6 decimals. Use 0.000001 or more";
13
+ const TOO_LARGE = "This value must be under 1e100, because AutoCAD reads only two exponent digits from DXF and would take 1e+100 for 1e+10";
14
+ const ARC_LOST = "An arc of nearly a full turn cannot be written: its angles are the same once rounded to 6 decimals, so AutoCAD would hold an arc of no length. Draw a circle instead";
15
+ const TEXT_PRIVATE_USE = "Text cannot contain the private-use characters U+E800 to U+E8FF, because AutoCAD replaces them when it reads DXF";
16
+ const TEXT_PERCENT = 'AutoCAD reads "%%" in text as the start of a control code: it draws "%%d" as a degree sign and "%%%" as one percent sign. The text is stored as written';
9
17
  /** Returns what is wrong with a layer name for a DXF version, or undefined when it is valid. */
10
18
  export function layerNameProblem(name, version) {
11
19
  if (name.length === 0)
12
20
  return NAME_EMPTY;
13
21
  if (/[<>/\\":;?*|=`^,]/.test(name) || hasControl(name) || hasAstral(name))
14
22
  return NAME_CHARS;
15
- if (name !== name.trim())
23
+ const rejected = rejectedNameCharacter(name);
24
+ if (rejected !== undefined)
25
+ return `${NAME_REJECTED}. This name has ${codePoint(rejected)}`;
26
+ // Only the ASCII space: AutoCAD takes a no-break space or any other at either end.
27
+ if (name.startsWith(" ") || name.endsWith(" "))
16
28
  return NAME_EDGE;
17
29
  if (name.length > 255)
18
30
  return NAME_LENGTH;
@@ -24,10 +36,84 @@ export function layerNameProblem(name, version) {
24
36
  }
25
37
  return undefined;
26
38
  }
39
+ /**
40
+ * Returns what is wrong with a drawing that has both names as layers, or
41
+ * undefined when the two can stand together. AutoCAD looks layers up one way
42
+ * and its AUDIT compares their names another, so a few pairs of names, such
43
+ * as `ΟΔΟΣ` and `οδος`, are two layers to AutoCAD and one duplicate name to
44
+ * AUDIT. Names that are one layer to AutoCAD are not this function's concern.
45
+ * The rule compares two names, so it has no type.
46
+ *
47
+ * @param name - The name of the layer being checked.
48
+ * @param other - The name of another layer in the drawing.
49
+ * @returns The problem, or undefined.
50
+ */
51
+ export function layerTwinProblem(name, other) {
52
+ if (layerKey(name) === layerKey(other) || layerAuditKey(name) !== layerAuditKey(other)) {
53
+ return undefined;
54
+ }
55
+ return `AutoCAD keeps this layer and ${JSON.stringify(other)} as two layers, but its AUDIT reports them as one duplicate name`;
56
+ }
27
57
  /** Returns what is wrong with a layer color, or undefined when it is valid. */
28
58
  export function layerColorProblem(color) {
29
59
  return Number.isInteger(color) && color >= 1 && color <= 255 ? undefined : COLOR;
30
60
  }
61
+ /**
62
+ * Returns what is wrong with a radius, a text height or a rectangle size, or
63
+ * undefined when it is valid. A size that is written as 0 is refused like 0
64
+ * itself: AutoCAD gives text of height 0 the drawing's default text height,
65
+ * and a circle of radius 0 is a point. A size of 1e100 or more is refused
66
+ * too, as `magnitudeProblem` explains.
67
+ *
68
+ * @param value - The size, which must be a finite number.
69
+ * @returns The problem, or undefined.
70
+ */
71
+ export function sizeProblem(value) {
72
+ if (!(value > 0))
73
+ return POSITIVE;
74
+ return written(value) === 0 ? WRITTEN_AS_ZERO : magnitudeProblem(value);
75
+ }
76
+ /**
77
+ * Returns what is wrong with the size of a number a drawing holds, or
78
+ * undefined when AutoCAD can read it. This goes for every number that is
79
+ * written: coordinates, sizes and text rotation. Only sizes can be checked as
80
+ * literals, through `Positive`, because points and rotations have no literal
81
+ * types.
82
+ *
83
+ * @param value - The number, which must be finite.
84
+ * @returns The problem, or undefined.
85
+ */
86
+ export function magnitudeProblem(value) {
87
+ return isTooLarge(value) ? TOO_LARGE : undefined;
88
+ }
89
+ /**
90
+ * Returns what is wrong with an arc's angles once they are rounded for
91
+ * writing, or undefined when the arc can be written. AutoCAD holds an arc
92
+ * whose start and end angles are equal as an arc of no length, so an arc that
93
+ * is all but a full circle would vanish. The compiler cannot subtract two
94
+ * literals, so this rule has no type.
95
+ *
96
+ * @param startAngle - The start angle in degrees, from 0 up to 360.
97
+ * @param endAngle - The end angle in degrees, from 0 up to 360.
98
+ * @returns The problem, or undefined.
99
+ */
100
+ export function arcProblem(startAngle, endAngle) {
101
+ return roundsToOneAngle(startAngle, endAngle) && !isShortArc(startAngle, endAngle)
102
+ ? ARC_LOST
103
+ : undefined;
104
+ }
105
+ /**
106
+ * True for an arc so short that its angles are the same once rounded, which
107
+ * is written as an arc of no length. That is within rounding of what was
108
+ * drawn, so it is worth a warning and not an error.
109
+ *
110
+ * @param startAngle - The start angle in degrees, from 0 up to 360.
111
+ * @param endAngle - The end angle in degrees, from 0 up to 360.
112
+ * @returns Whether the arc is written with no length.
113
+ */
114
+ export function arcIsWrittenAsPoint(startAngle, endAngle) {
115
+ return roundsToOneAngle(startAngle, endAngle) && isShortArc(startAngle, endAngle);
116
+ }
31
117
  /** Returns what is wrong with a single-line text value, or undefined when it is valid. */
32
118
  export function textProblem(value) {
33
119
  if (hasControl(value))
@@ -35,8 +121,30 @@ export function textProblem(value) {
35
121
  if (hasAstral(value)) {
36
122
  return "Text cannot contain emoji or other characters above U+FFFF, because AutoCAD cannot read them from DXF";
37
123
  }
124
+ if (/[\ue800-\ue8ff]/.test(value))
125
+ return TEXT_PRIVATE_USE;
38
126
  return undefined;
39
127
  }
128
+ /**
129
+ * Returns a warning for text that AutoCAD stores as written but draws
130
+ * differently, or undefined when it draws what it stores.
131
+ *
132
+ * @param value - The text value.
133
+ * @returns The warning, or undefined.
134
+ */
135
+ export function textWarning(value) {
136
+ return value.includes("%%") ? TEXT_PERCENT : undefined;
137
+ }
138
+ function roundsToOneAngle(startAngle, endAngle) {
139
+ return writtenAngle(startAngle) === writtenAngle(endAngle);
140
+ }
141
+ /** True when the counterclockwise sweep from start to end is half a turn or less. */
142
+ function isShortArc(startAngle, endAngle) {
143
+ return (((endAngle - startAngle) % 360) + 360) % 360 <= 180;
144
+ }
145
+ function codePoint(character) {
146
+ return `U+${character.charCodeAt(0).toString(16).toUpperCase().padStart(4, "0")}`;
147
+ }
40
148
  function hasControl(value) {
41
149
  for (let i = 0; i < value.length; i++) {
42
150
  const code = value.charCodeAt(i);