@knowvah/dot-engine 2.6.6 → 2.6.8
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/common/htmltable-attr-warn.d.ts +10 -4
- package/dist/common/htmltable-attr-warn.d.ts.map +1 -1
- package/dist/common/htmltable-label.d.ts +37 -0
- package/dist/common/htmltable-label.d.ts.map +1 -0
- package/dist/common/htmltable-lex-attvalue.d.ts +59 -0
- package/dist/common/htmltable-lex-attvalue.d.ts.map +1 -0
- package/dist/common/htmltable-lex-chardata.d.ts +37 -0
- package/dist/common/htmltable-lex-chardata.d.ts.map +1 -0
- package/dist/common/htmltable-lex-expat.d.ts +26 -13
- package/dist/common/htmltable-lex-expat.d.ts.map +1 -1
- package/dist/common/htmltable-lex.d.ts.map +1 -1
- package/dist/common/htmltable-parse-attrs.d.ts +3 -1
- package/dist/common/htmltable-parse-attrs.d.ts.map +1 -1
- package/dist/common/htmltable-parse.d.ts +6 -5
- package/dist/common/htmltable-parse.d.ts.map +1 -1
- package/dist/common/htmltable-pos.d.ts +3 -26
- package/dist/common/htmltable-pos.d.ts.map +1 -1
- package/dist/common/htmltable-types.d.ts +5 -1
- package/dist/common/htmltable-types.d.ts.map +1 -1
- package/dist/common/htmltable.d.ts +3 -2
- package/dist/common/htmltable.d.ts.map +1 -1
- package/dist/common/make-label.d.ts.map +1 -1
- package/dist/index.js +310 -144
- package/dist/index.js.map +4 -4
- package/dist/render.js +310 -144
- package/dist/render.js.map +4 -4
- package/package.json +1 -1
- package/src/common/htmltable-attr-warn.ts +24 -7
- package/src/common/htmltable-label.ts +109 -0
- package/src/common/htmltable-lex-attvalue.ts +141 -0
- package/src/common/htmltable-lex-chardata.ts +95 -0
- package/src/common/htmltable-lex-expat.ts +143 -48
- package/src/common/htmltable-lex.ts +41 -36
- package/src/common/htmltable-parse-attrs.ts +10 -13
- package/src/common/htmltable-parse.ts +46 -61
- package/src/common/htmltable-pos.ts +5 -88
- package/src/common/htmltable-types.ts +6 -1
- package/src/common/htmltable.ts +16 -16
- package/src/common/make-label.ts +8 -3
|
@@ -15,6 +15,9 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { HTML_ENTITIES } from './html-entities.js';
|
|
18
|
+
import {
|
|
19
|
+
attValueError, charRefNumber, scanAttValue, XML_ERROR_BAD_CHAR_REF, XML_ERROR_UNDEFINED_ENTITY, type AttValueError,
|
|
20
|
+
} from './htmltable-lex-attvalue.js';
|
|
18
21
|
|
|
19
22
|
/** expat `XML_ErrorString(XML_ERROR_INVALID_TOKEN)`. */
|
|
20
23
|
export const XML_ERROR_INVALID_TOKEN = 'not well-formed (invalid token)';
|
|
@@ -26,11 +29,13 @@ const INIT_BUFFER_SIZE = 1024;
|
|
|
26
29
|
|
|
27
30
|
/** One complete expat token of the content: its extent in the label source. */
|
|
28
31
|
export interface ExpatToken {
|
|
29
|
-
kind: 'text' | 'tag' | 'comment' | 'pi';
|
|
32
|
+
kind: 'text' | 'tag' | 'comment' | 'pi' | 'cdata';
|
|
30
33
|
start: number;
|
|
31
34
|
end: number;
|
|
32
|
-
/**
|
|
33
|
-
|
|
35
|
+
/** A `cdata` token's character data, as expat reports it (no entity decoding). */
|
|
36
|
+
data?: string;
|
|
37
|
+
/** A start tag's first storeAtts error: a duplicate name or a bad value. */
|
|
38
|
+
attError?: AttValueError & { offset: number };
|
|
34
39
|
}
|
|
35
40
|
|
|
36
41
|
/** The first well-formedness error of a parse call. */
|
|
@@ -45,13 +50,18 @@ export interface ExpatResult {
|
|
|
45
50
|
invalid?: ExpatInvalid;
|
|
46
51
|
}
|
|
47
52
|
|
|
53
|
+
/** expat `XML_ErrorString(XML_ERROR_DUPLICATE_ATTRIBUTE)`. */
|
|
54
|
+
const XML_ERROR_DUPLICATE_ATTRIBUTE = 'duplicate attribute';
|
|
55
|
+
|
|
56
|
+
type AttError = AttValueError & { offset: number };
|
|
57
|
+
|
|
48
58
|
type Scan =
|
|
49
|
-
| { kind: 'complete'; end: number;
|
|
59
|
+
| { kind: 'complete'; end: number; attError?: AttError }
|
|
50
60
|
| { kind: 'partial' }
|
|
51
61
|
| { kind: 'invalid'; offset: number };
|
|
52
62
|
|
|
53
|
-
const COMPLETE_END = (end: number,
|
|
54
|
-
|
|
63
|
+
const COMPLETE_END = (end: number, attError?: AttError): Scan =>
|
|
64
|
+
attError === undefined ? { kind: 'complete', end } : { kind: 'complete', end, attError };
|
|
55
65
|
const PARTIAL: Scan = { kind: 'partial' };
|
|
56
66
|
const INVALID = (offset: number): Scan => ({ kind: 'invalid', offset });
|
|
57
67
|
|
|
@@ -69,38 +79,45 @@ function skipSpace(text: string, i: number): number {
|
|
|
69
79
|
const byteLength = (s: string): number => new TextEncoder().encode(s).length;
|
|
70
80
|
|
|
71
81
|
/**
|
|
72
|
-
*
|
|
73
|
-
* `findNext` filled (named entities rewritten as `&#N;`, see `scanEntity`)
|
|
74
|
-
* with a
|
|
82
|
+
* What C passes to `XML_Parse` for (a piece of) a text unit: the buffer
|
|
83
|
+
* `findNext` filled (named entities rewritten as `&#N;`, see `scanEntity`),
|
|
84
|
+
* with a `]` that ends the unit escaped (`protect_rsqb`). Outside a CDATA
|
|
85
|
+
* section expat decodes both back to the characters written; inside one they
|
|
86
|
+
* stay as rewritten.
|
|
87
|
+
* @see lib/common/htmllex.c:findNext
|
|
75
88
|
* @see lib/common/htmllex.c:protect_rsqb
|
|
76
89
|
*/
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
let text = unit.replace(/&([^;&<]*);/g, (m, name: string) => {
|
|
90
|
+
function fedText(piece: string, endsUnit: boolean): string {
|
|
91
|
+
const text = piece.replace(/&([^;&<]*);/g, (m, name: string) => {
|
|
80
92
|
return name.length >= 2 && Object.hasOwn(HTML_ENTITIES, name) ? `&#${HTML_ENTITIES[name]};` : m;
|
|
81
93
|
});
|
|
82
|
-
|
|
83
|
-
|
|
94
|
+
return endsUnit && text.endsWith(']') ? `${text.slice(0, -1)}]` : text;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The number of bytes C passes to `XML_Parse` for a unit; other than text units go as written. */
|
|
98
|
+
export function fedBytes(unit: string, isText: boolean): number {
|
|
99
|
+
return byteLength(isText ? fedText(unit, true) : unit);
|
|
84
100
|
}
|
|
85
101
|
|
|
86
102
|
interface Pair {
|
|
87
103
|
name: string;
|
|
88
104
|
next: number;
|
|
105
|
+
/** The value between its quotes. */
|
|
106
|
+
value: { start: number; end: number };
|
|
89
107
|
}
|
|
90
108
|
|
|
91
|
-
/** The quoted value opening at `j`:
|
|
109
|
+
/** The quoted value opening at `j`: its closing quote, or how scanning stops. */
|
|
92
110
|
function scanValue(text: string, j: number): number | Scan {
|
|
93
111
|
const q = text[j];
|
|
94
112
|
if (q !== '"' && q !== "'") return INVALID(j);
|
|
95
|
-
const
|
|
96
|
-
if (
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
}
|
|
113
|
+
const v = scanAttValue(text, j);
|
|
114
|
+
if ('partial' in v) return PARTIAL;
|
|
115
|
+
if ('invalid' in v) return INVALID(v.invalid);
|
|
116
|
+
const close = v.close;
|
|
100
117
|
const next = skipSpace(text, close + 1);
|
|
101
118
|
// Attributes are separated by white space.
|
|
102
119
|
if (next === close + 1 && !'>/'.includes(text[next] ?? '>')) return INVALID(next);
|
|
103
|
-
return
|
|
120
|
+
return close;
|
|
104
121
|
}
|
|
105
122
|
|
|
106
123
|
/** The `name = "value"` pair at `i`, or how expat's tokenizer stops there. */
|
|
@@ -112,8 +129,9 @@ function scanPair(text: string, i: number): Pair | Scan {
|
|
|
112
129
|
if (text[eq] !== '=') return INVALID(eq);
|
|
113
130
|
const j = skipSpace(text, eq + 1);
|
|
114
131
|
if (j >= text.length) return PARTIAL;
|
|
115
|
-
const
|
|
116
|
-
|
|
132
|
+
const close = scanValue(text, j);
|
|
133
|
+
if (typeof close !== 'number') return close;
|
|
134
|
+
return { name, next: skipSpace(text, close + 1), value: { start: j + 1, end: close } };
|
|
117
135
|
}
|
|
118
136
|
|
|
119
137
|
/** `>` or `/>` at `i` ends the tag; end of data leaves it partial; otherwise more attributes. */
|
|
@@ -126,19 +144,30 @@ function tagEdge(text: string, i: number): Scan | undefined {
|
|
|
126
144
|
return text[i + 1] === '>' ? COMPLETE_END(i + 2) : undefined;
|
|
127
145
|
}
|
|
128
146
|
|
|
129
|
-
/**
|
|
130
|
-
|
|
147
|
+
/**
|
|
148
|
+
* storeAtts on one attribute: a repeated name, else its value's error. Its
|
|
149
|
+
* undefined-entity error leaves the event pointer at the tag (`p`).
|
|
150
|
+
* @see expat lib/xmlparse.c:storeAtts
|
|
151
|
+
*/
|
|
152
|
+
function storeAttError(text: string, p: number, at: number, pair: Pair, seen: Set<string>): AttError | undefined {
|
|
153
|
+
if (seen.has(pair.name)) return { message: XML_ERROR_DUPLICATE_ATTRIBUTE, offset: at };
|
|
154
|
+
seen.add(pair.name);
|
|
155
|
+
const err = attValueError(text.slice(pair.value.start, pair.value.end), pair.value.start);
|
|
156
|
+
return err === undefined ? undefined : { message: err.message, offset: err.offset ?? p };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The pairs of the start tag at `p` from `from`; storeAtts' errors only count once the tag tokenizes whole. */
|
|
160
|
+
function scanAttributes(text: string, p: number, from: number): Scan {
|
|
131
161
|
const seen = new Set<string>();
|
|
132
|
-
let
|
|
162
|
+
let attError: AttError | undefined;
|
|
133
163
|
let i = skipSpace(text, from);
|
|
134
164
|
for (;;) {
|
|
135
165
|
const edge = tagEdge(text, i);
|
|
136
|
-
if (edge?.kind === 'complete') return COMPLETE_END(edge.end,
|
|
166
|
+
if (edge?.kind === 'complete') return COMPLETE_END(edge.end, attError);
|
|
137
167
|
if (edge !== undefined) return edge;
|
|
138
168
|
const pair = scanPair(text, i);
|
|
139
169
|
if ('kind' in pair) return pair;
|
|
140
|
-
|
|
141
|
-
seen.add(pair.name);
|
|
170
|
+
attError ??= storeAttError(text, p, i, pair, seen);
|
|
142
171
|
i = pair.next;
|
|
143
172
|
}
|
|
144
173
|
}
|
|
@@ -157,7 +186,7 @@ function scanStartTag(text: string, p: number): Scan {
|
|
|
157
186
|
const c = text[name.next];
|
|
158
187
|
if (c === undefined) return PARTIAL;
|
|
159
188
|
if (!isSpace(c) && c !== '>' && c !== '/') return INVALID(name.next);
|
|
160
|
-
return scanAttributes(text, name.next);
|
|
189
|
+
return scanAttributes(text, p, name.next);
|
|
161
190
|
}
|
|
162
191
|
|
|
163
192
|
/** An end tag at `p`: a name, optional white space, `>`. */
|
|
@@ -219,29 +248,65 @@ interface ContentScan {
|
|
|
219
248
|
/** Offset where the unconsumed (partial) token starts. */
|
|
220
249
|
consumed: number;
|
|
221
250
|
invalid?: number;
|
|
251
|
+
/** Whether the scan ended inside a CDATA section. */
|
|
252
|
+
inCdata: boolean;
|
|
222
253
|
}
|
|
223
254
|
|
|
255
|
+
const CDATA_OPEN = '<![CDATA[';
|
|
256
|
+
const CDATA_CLOSE = ']]>';
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Character data of a CDATA section from `p` up to its `]]>` (or the end of
|
|
260
|
+
* what was fed): one `cdata` token, then the index after the section, or -1
|
|
261
|
+
* while it is still open. Markup and `&` are data here. The fed bytes never
|
|
262
|
+
* end in a `]` (text units go through protect_rsqb, other units end in `>`),
|
|
263
|
+
* so expat holds no partial `]]>` back.
|
|
264
|
+
* @see expat xmltok_impl.c:PREFIX(cdataSectionTok)
|
|
265
|
+
*/
|
|
266
|
+
function scanCdata(text: string, p: number, base: number, dataOf: CdataText, tokens: ExpatToken[]): number {
|
|
267
|
+
const close = text.indexOf(CDATA_CLOSE, p);
|
|
268
|
+
const end = close < 0 ? text.length : close;
|
|
269
|
+
if (end > p) tokens.push({ kind: 'cdata', start: base + p, end: base + end, data: dataOf(base + p, base + end) });
|
|
270
|
+
return close < 0 ? -1 : close + CDATA_CLOSE.length;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Character data at `p` up to the next `<`: one `text` token, then the index it ends at. */
|
|
274
|
+
function scanText(text: string, p: number, base: number, tokens: ExpatToken[]): number {
|
|
275
|
+
const e = text.indexOf('<', p);
|
|
276
|
+
const end = e < 0 ? text.length : e;
|
|
277
|
+
tokens.push({ kind: 'text', start: base + p, end: base + end });
|
|
278
|
+
return end;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** The fed text of the label source between two offsets. */
|
|
282
|
+
type CdataText = (start: number, end: number) => string;
|
|
283
|
+
|
|
224
284
|
/** Tokenize `text` (a label slice at `base`) as far as expat can: tokens, then partial or invalid. */
|
|
225
|
-
function scanContent(text: string, base: number): ContentScan {
|
|
285
|
+
function scanContent(text: string, base: number, inCdata: boolean, dataOf: CdataText): ContentScan {
|
|
226
286
|
const tokens: ExpatToken[] = [];
|
|
227
287
|
let p = 0;
|
|
288
|
+
let inSection = inCdata;
|
|
228
289
|
while (p < text.length) {
|
|
290
|
+
if (inSection || text.startsWith(CDATA_OPEN, p)) {
|
|
291
|
+
const after = scanCdata(text, inSection ? p : p + CDATA_OPEN.length, base, dataOf, tokens);
|
|
292
|
+
if (after < 0) return { tokens, consumed: text.length, inCdata: true };
|
|
293
|
+
inSection = false;
|
|
294
|
+
p = after;
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
229
297
|
if (text[p] !== '<') {
|
|
230
|
-
|
|
231
|
-
const end = e < 0 ? text.length : e;
|
|
232
|
-
tokens.push({ kind: 'text', start: base + p, end: base + end });
|
|
233
|
-
p = end;
|
|
298
|
+
p = scanText(text, p, base, tokens);
|
|
234
299
|
continue;
|
|
235
300
|
}
|
|
236
301
|
const { scan, kind } = scanMarkup(text, p);
|
|
237
|
-
if (scan.kind === 'invalid') return { tokens, consumed: p, invalid: base + scan.offset };
|
|
238
|
-
if (scan.kind === 'partial') return { tokens, consumed: p };
|
|
302
|
+
if (scan.kind === 'invalid') return { tokens, consumed: p, invalid: base + scan.offset, inCdata: false };
|
|
303
|
+
if (scan.kind === 'partial') return { tokens, consumed: p, inCdata: false };
|
|
239
304
|
const tok: ExpatToken = { kind, start: base + p, end: base + scan.end };
|
|
240
|
-
if (scan.
|
|
305
|
+
if (scan.attError !== undefined) tok.attError = { ...scan.attError, offset: base + scan.attError.offset };
|
|
241
306
|
tokens.push(tok);
|
|
242
307
|
p = scan.end;
|
|
243
308
|
}
|
|
244
|
-
return { tokens, consumed: p };
|
|
309
|
+
return { tokens, consumed: p, inCdata: inSection };
|
|
245
310
|
}
|
|
246
311
|
|
|
247
312
|
/**
|
|
@@ -297,6 +362,9 @@ export class ExpatFeed {
|
|
|
297
362
|
private readonly buffer = new InputBuffer();
|
|
298
363
|
private heldStart = 0;
|
|
299
364
|
private fedEnd = 0;
|
|
365
|
+
private inCdata = false;
|
|
366
|
+
/** Source ranges of the text units fed so far: their bytes went through findNext's rewrite. */
|
|
367
|
+
private readonly textUnits: Array<readonly [number, number]> = [];
|
|
300
368
|
|
|
301
369
|
constructor(private readonly src: string) {}
|
|
302
370
|
|
|
@@ -305,20 +373,36 @@ export class ExpatFeed {
|
|
|
305
373
|
return this.heldStart < this.fedEnd ? this.heldStart : undefined;
|
|
306
374
|
}
|
|
307
375
|
|
|
308
|
-
/** One `XML_Parse(parser, src[start, end), nbytes, 0)
|
|
309
|
-
feed(start: number, end: number,
|
|
310
|
-
this.
|
|
376
|
+
/** One `XML_Parse(parser, src[start, end), nbytes, 0)`; `isText` for a text unit. */
|
|
377
|
+
feed(start: number, end: number, isText: boolean): ExpatResult {
|
|
378
|
+
if (isText) this.textUnits.push([start, end]);
|
|
379
|
+
this.buffer.append(fedBytes(this.src.slice(start, end), isText));
|
|
311
380
|
this.fedEnd = end;
|
|
312
381
|
if (!this.buffer.enough) return { tokens: [] };
|
|
313
382
|
const text = this.src.slice(this.heldStart, end);
|
|
314
|
-
const scan = scanContent(text, this.heldStart);
|
|
383
|
+
const scan = scanContent(text, this.heldStart, this.inCdata, (a, b) => this.fedBetween(a, b));
|
|
315
384
|
if (scan.invalid !== undefined) {
|
|
316
385
|
return { tokens: scan.tokens, invalid: { message: XML_ERROR_INVALID_TOKEN, offset: scan.invalid } };
|
|
317
386
|
}
|
|
387
|
+
this.inCdata = scan.inCdata;
|
|
318
388
|
this.heldStart += scan.consumed;
|
|
319
389
|
this.buffer.consumedTo(byteLength(this.src.slice(this.heldStart, end)), scan.consumed > 0);
|
|
320
390
|
return { tokens: scan.tokens };
|
|
321
391
|
}
|
|
392
|
+
|
|
393
|
+
/** What expat read for source offsets [start, end): text-unit pieces as findNext rewrote them. */
|
|
394
|
+
private fedBetween(start: number, end: number): string {
|
|
395
|
+
let out = '';
|
|
396
|
+
let p = start;
|
|
397
|
+
for (const [us, ue] of this.textUnits) {
|
|
398
|
+
if (ue <= p || us >= end) continue;
|
|
399
|
+
if (us > p) out += this.src.slice(p, us);
|
|
400
|
+
const e = Math.min(ue, end);
|
|
401
|
+
out += fedText(this.src.slice(Math.max(us, p), e), e === ue);
|
|
402
|
+
p = e;
|
|
403
|
+
}
|
|
404
|
+
return out + this.src.slice(p, end);
|
|
405
|
+
}
|
|
322
406
|
}
|
|
323
407
|
|
|
324
408
|
/** Entities expat knows without a DTD; `scanEntity` leaves them as written. */
|
|
@@ -326,18 +410,29 @@ const XML_PREDEFINED_ENTITIES = new Set(['lt', 'gt', 'amp', 'quot', 'apos']);
|
|
|
326
410
|
|
|
327
411
|
const ENTITY_NAME = /^[A-Za-z_:][\w.:-]*$/;
|
|
328
412
|
|
|
329
|
-
|
|
330
|
-
|
|
413
|
+
const CHAR_REF = /^&#(?:x[0-9A-Fa-f]+|[0-9]+);/;
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* The first reference of character data that expat rejects, at its offset: a
|
|
417
|
+
* `&name;` scanEntity leaves alone (neither an HTML entity nor predefined), or
|
|
418
|
+
* a character reference XML does not allow.
|
|
419
|
+
* @see expat lib/xmlparse.c:doContent (XML_TOK_ENTITY_REF, XML_TOK_CHAR_REF)
|
|
420
|
+
*/
|
|
421
|
+
export function textRefError(text: string): { message: string; offset: number } | undefined {
|
|
331
422
|
for (let t = text.indexOf('&'); t >= 0; t = text.indexOf('&', t + 1)) {
|
|
332
|
-
if (text[t + 1] === '#')
|
|
423
|
+
if (text[t + 1] === '#') {
|
|
424
|
+
const ref = CHAR_REF.exec(text.slice(t))?.[0];
|
|
425
|
+
if (ref !== undefined && charRefNumber(ref) < 0) return { message: XML_ERROR_BAD_CHAR_REF, offset: t };
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
333
428
|
const semi = text.indexOf(';', t + 1);
|
|
334
429
|
if (semi < 0) continue;
|
|
335
430
|
const key = text.slice(t + 1, semi);
|
|
336
431
|
if (ENTITY_NAME.test(key) && !Object.hasOwn(HTML_ENTITIES, key) && !XML_PREDEFINED_ENTITIES.has(key)) {
|
|
337
|
-
return t;
|
|
432
|
+
return { message: XML_ERROR_UNDEFINED_ENTITY, offset: t };
|
|
338
433
|
}
|
|
339
434
|
}
|
|
340
|
-
return
|
|
435
|
+
return undefined;
|
|
341
436
|
}
|
|
342
437
|
|
|
343
438
|
export interface TagChunk {
|
|
@@ -13,12 +13,14 @@
|
|
|
13
13
|
|
|
14
14
|
import { HtmlParseError } from './htmltable-types.js';
|
|
15
15
|
import type { LexerOutput, OpenToken, TextToken, Token } from './htmltable-types.js';
|
|
16
|
-
import { htmlEntityUTF8 } from './html-entities.js';
|
|
17
16
|
import { checkAttrs } from './htmltable-attr-warn.js';
|
|
18
17
|
import type { AttrEntry } from './htmltable-attr-warn.js';
|
|
19
|
-
import { ExpatFeed,
|
|
18
|
+
import { ExpatFeed, parseTagChunk, textRefError } from './htmltable-lex-expat.js';
|
|
19
|
+
import { charDataTokens, initialCharData, keepLostText, releaseXb, tagInCell, textTokens } from './htmltable-lex-chardata.js';
|
|
20
|
+
import type { CharDataState } from './htmltable-lex-chardata.js';
|
|
20
21
|
import type { ExpatResult, ExpatToken } from './htmltable-lex-expat.js';
|
|
21
22
|
import { findNext } from './htmltable-lex-units.js';
|
|
23
|
+
import { decodeAttValue } from './htmltable-lex-attvalue.js';
|
|
22
24
|
import type { Unit } from './htmltable-lex-units.js';
|
|
23
25
|
|
|
24
26
|
export type { Token } from './htmltable-types.js';
|
|
@@ -41,7 +43,8 @@ const isWs = (c: string | undefined): boolean =>
|
|
|
41
43
|
/**
|
|
42
44
|
* Parse key="value" attribute pairs. Values are consumed, never
|
|
43
45
|
* re-scanned (the old scanner re-matched words inside quoted values as
|
|
44
|
-
* phantom attributes)
|
|
46
|
+
* phantom attributes), and decoded as expat hands them to startElement
|
|
47
|
+
* (`decodeAttValue`; storeAtts' errors were raised before this runs). Bare or unquoted attributes are not well-formed
|
|
45
48
|
* XML — expat rejects the document and the label falls back to plain
|
|
46
49
|
* text; throwing here reproduces that.
|
|
47
50
|
* Names keep their source case (doAttrs prints them as written) and pairs
|
|
@@ -66,7 +69,7 @@ function parseAttrPairs(raw: string): AttrEntry[] {
|
|
|
66
69
|
if (q !== '"' && q !== "'") throw new HtmlParseError(name);
|
|
67
70
|
const end = raw.indexOf(q, i + 1);
|
|
68
71
|
if (end < 0) throw new HtmlParseError(name);
|
|
69
|
-
out.push([name, raw.slice(i + 1, end)]);
|
|
72
|
+
out.push([name, decodeAttValue(raw.slice(i + 1, end))]);
|
|
70
73
|
i = end + 1;
|
|
71
74
|
}
|
|
72
75
|
return out;
|
|
@@ -93,33 +96,25 @@ function parseCloseToken(inner: string) {
|
|
|
93
96
|
return { type: 'close' as const, tag: normalizeTag(tag) };
|
|
94
97
|
}
|
|
95
98
|
|
|
96
|
-
/** @see lib/common/htmllex.c:
|
|
97
|
-
|
|
98
|
-
const isKeep = (c: string) => c === ' ' || c === '\t' || c === '\n' || c === '\r';
|
|
99
|
-
return raw.replace(/[\x00-\x1f\x7f]/g, (c) => isKeep(c) ? c : '');
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/** The text token of a text run, if anything is left after cleaning. */
|
|
103
|
-
function textTokens(raw: string): Token[] {
|
|
104
|
-
// Decode XML/HTML entities in the text run. C's HTML lexer resolves entities
|
|
105
|
-
// via expat during tokenization (htmllex.c), so both sizing and emit see the
|
|
106
|
-
// decoded UTF-8 text. Skipping this measured the raw "[el..." (9-10 chars)
|
|
107
|
-
// instead of "[el..." (6 chars), inflating edge-label vnodes and shifting
|
|
108
|
-
// layout (corpus 1949: +18.7px). Plain-string labels already decode via the
|
|
109
|
-
// make_label path, so only HTML-delimited labels diverged.
|
|
110
|
-
const clean = htmlEntityUTF8(cleanText(raw));
|
|
111
|
-
return clean.length > 0 ? [{ type: 'text', value: clean }] : [];
|
|
112
|
-
}
|
|
99
|
+
/** Elements whose `<X/>` endElement folds into one token. @see lib/common/htmllex.c:endElement */
|
|
100
|
+
const EMPTY_ELEMENTS: ReadonlySet<string> = new Set(['BR', 'HR', 'VR', 'IMG']);
|
|
113
101
|
|
|
114
102
|
/** The tokens of one complete `<..>` tag. */
|
|
115
103
|
function tagTokens(text: string): Token[] {
|
|
116
104
|
const inner = text.slice(1, -1).trim();
|
|
117
105
|
if (inner.startsWith('/')) return [parseCloseToken(inner)];
|
|
118
|
-
// Self-closing <X/>: expat reports startElement then endElement
|
|
119
|
-
//
|
|
106
|
+
// Self-closing <X/>: expat reports startElement then endElement in one call,
|
|
107
|
+
// and endElement's `ctx->tok` overwrites startElement's. Only BR, HR, VR and
|
|
108
|
+
// IMG turn the pair into one token (T_BR etc.); any other element reaches the
|
|
109
|
+
// parser as its close tag alone, with what startElement printed.
|
|
110
|
+
// @see lib/common/htmllex.c:endElement
|
|
120
111
|
if (inner.endsWith('/')) {
|
|
121
112
|
const open = parseOpenToken(inner.slice(0, -1).trim());
|
|
122
|
-
return [open, { type: 'close', tag: open.tag }];
|
|
113
|
+
if (EMPTY_ELEMENTS.has(open.tag)) return [open, { type: 'close', tag: open.tag }];
|
|
114
|
+
const close: Token = { type: 'close', tag: open.tag };
|
|
115
|
+
if (open.warnings !== undefined) close.warnings = open.warnings;
|
|
116
|
+
if (open.warn === true) close.warn = true;
|
|
117
|
+
return [close];
|
|
123
118
|
}
|
|
124
119
|
return [parseOpenToken(inner)];
|
|
125
120
|
}
|
|
@@ -152,9 +147,7 @@ function parseOpenToken(inner: string) {
|
|
|
152
147
|
* with the graphviz sources.
|
|
153
148
|
* @see lib/common/htmllex.c:htmllex (XML_ErrorString at the XML_Parse failure)
|
|
154
149
|
*/
|
|
155
|
-
const XML_ERROR_UNDEFINED_ENTITY = 'undefined entity';
|
|
156
150
|
const XML_ERROR_TAG_MISMATCH = 'mismatched tag';
|
|
157
|
-
const XML_ERROR_DUPLICATE_ATTRIBUTE = 'duplicate attribute';
|
|
158
151
|
/** yacc's message when the token stream ends inside an unfinished construct. */
|
|
159
152
|
const SYNTAX_ERROR = 'syntax error';
|
|
160
153
|
|
|
@@ -261,7 +254,8 @@ function checkTagToken(site: TokenSite): void {
|
|
|
261
254
|
throw htmlErrorInLine(XML_ERROR_TAG_MISMATCH, lineAt(src, tok.start), context);
|
|
262
255
|
}
|
|
263
256
|
} else {
|
|
264
|
-
|
|
257
|
+
const err = tok.attError;
|
|
258
|
+
if (err !== undefined) throw htmlErrorInLine(err.message, lineAt(src, err.offset), context);
|
|
265
259
|
if (!tag.selfClose) st.stack.push(tag.name);
|
|
266
260
|
}
|
|
267
261
|
if (KNOWN_TAGS.has(tag.name.toUpperCase())) return;
|
|
@@ -274,14 +268,14 @@ function eventTokens(site: TokenSite): Token[] {
|
|
|
274
268
|
const { src, tok, st } = site;
|
|
275
269
|
const text = src.slice(tok.start, tok.end);
|
|
276
270
|
if (tok.kind === 'comment' || tok.kind === 'pi') return [];
|
|
271
|
+
// CDATA content is character data as is: no markup, no entity decoding.
|
|
272
|
+
if (tok.kind === 'cdata') return charDataTokens(tok.data ?? '');
|
|
277
273
|
if (tok.kind === 'tag') {
|
|
278
274
|
checkTagToken(site);
|
|
279
275
|
return tagTokens(text);
|
|
280
276
|
}
|
|
281
|
-
const
|
|
282
|
-
if (
|
|
283
|
-
throw htmlErrorInLine(XML_ERROR_UNDEFINED_ENTITY, lineAt(src, tok.start + off), st.prev + st.curr);
|
|
284
|
-
}
|
|
277
|
+
const err = textRefError(text);
|
|
278
|
+
if (err !== undefined) throw htmlErrorInLine(err.message, lineAt(src, tok.start + err.offset), st.prev + st.curr);
|
|
285
279
|
return textTokens(text);
|
|
286
280
|
}
|
|
287
281
|
|
|
@@ -298,6 +292,8 @@ interface LexRun {
|
|
|
298
292
|
st: ChunkState;
|
|
299
293
|
expat: ExpatFeed;
|
|
300
294
|
carry: Carry;
|
|
295
|
+
/** `ctx->inCell` and `ctx->xb`. */
|
|
296
|
+
cd: CharDataState;
|
|
301
297
|
}
|
|
302
298
|
|
|
303
299
|
/** Move what `carry` holds onto `first`, the token the parser is about to get. */
|
|
@@ -326,7 +322,13 @@ interface EventGroup {
|
|
|
326
322
|
function eventGroups(run: LexRun, res: ExpatResult): EventGroup[] {
|
|
327
323
|
const groups: EventGroup[] = [];
|
|
328
324
|
for (const tok of res.tokens) {
|
|
329
|
-
|
|
325
|
+
let tokens = eventTokens({ src: run.src, tok, st: run.st });
|
|
326
|
+
if (tok.kind === 'tag') {
|
|
327
|
+
const tag = parseTagChunk(run.src.slice(tok.start, tok.end));
|
|
328
|
+
if (tag !== undefined) tagInCell(run.cd, tag);
|
|
329
|
+
} else if (!run.cd.inCell) {
|
|
330
|
+
tokens = []; // characterData appends only inside a cell
|
|
331
|
+
}
|
|
330
332
|
if (tokens.length > 0) groups.push({ tokens, tok });
|
|
331
333
|
}
|
|
332
334
|
if (res.invalid !== undefined) {
|
|
@@ -343,6 +345,7 @@ function eventGroups(run: LexRun, res: ExpatResult): EventGroup[] {
|
|
|
343
345
|
*/
|
|
344
346
|
function deliver(run: LexRun, last: EventGroup): void {
|
|
345
347
|
const first = last.tokens[0]!;
|
|
348
|
+
releaseXb(run.cd, first);
|
|
346
349
|
attachCarry(run.carry, first);
|
|
347
350
|
const info: ChunkInfo = { src: run.src, pos: last.tok.start, end: last.tok.end, context: run.st.prev + run.st.curr };
|
|
348
351
|
const prev = run.tokens.at(-1);
|
|
@@ -364,7 +367,10 @@ function deliver(run: LexRun, last: EventGroup): void {
|
|
|
364
367
|
function runEvents(run: LexRun, res: ExpatResult): void {
|
|
365
368
|
const groups = eventGroups(run, res);
|
|
366
369
|
const last = groups.pop();
|
|
367
|
-
for (const g of groups)
|
|
370
|
+
for (const g of groups) {
|
|
371
|
+
absorb(run.carry, g.tokens);
|
|
372
|
+
keepLostText(run.cd, g.tokens);
|
|
373
|
+
}
|
|
368
374
|
if (last !== undefined) deliver(run, last);
|
|
369
375
|
}
|
|
370
376
|
|
|
@@ -439,7 +445,7 @@ const eofOutput = (carry: Carry): LexerOutput | undefined => {
|
|
|
439
445
|
export function tokenizeDeferred(src: string, label: LabelRange = { start: 0, end: src.length }): TokenizeResult {
|
|
440
446
|
const run: LexRun = {
|
|
441
447
|
src, tokens: [], st: { stack: [], prev: '', curr: '' }, expat: new ExpatFeed(src),
|
|
442
|
-
carry: { warnings: [], warn: false },
|
|
448
|
+
carry: { warnings: [], warn: false }, cd: initialCharData(),
|
|
443
449
|
};
|
|
444
450
|
let error: HtmlParseError | undefined;
|
|
445
451
|
try {
|
|
@@ -448,8 +454,7 @@ export function tokenizeDeferred(src: string, label: LabelRange = { start: 0, en
|
|
|
448
454
|
run.st.curr = src.slice(u.start, u.end);
|
|
449
455
|
run.carry.warnings.push(...u.warnings);
|
|
450
456
|
if (u.warn) run.carry.warn = true;
|
|
451
|
-
|
|
452
|
-
runEvents(run, run.expat.feed(u.start, u.end, nbytes));
|
|
457
|
+
runEvents(run, run.expat.feed(u.start, u.end, !run.st.curr.startsWith('<')));
|
|
453
458
|
}
|
|
454
459
|
const held = run.expat.held;
|
|
455
460
|
if (held !== undefined) error = unfinishedError(run, held);
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import type {
|
|
12
12
|
HtmlAlign,
|
|
13
13
|
HtmlCell,
|
|
14
|
+
HtmlCellAlign,
|
|
14
15
|
HtmlCellContent,
|
|
15
16
|
HtmlRow,
|
|
16
17
|
HtmlTable,
|
|
@@ -23,14 +24,6 @@ import {
|
|
|
23
24
|
BORDER_BOTTOM,
|
|
24
25
|
BORDER_MASK,
|
|
25
26
|
} from './htmltable-types.js';
|
|
26
|
-
import { htmlEntityUTF8 } from './html-entities.js';
|
|
27
|
-
|
|
28
|
-
/** Decode XML/HTML entities in an anchor attribute value (href/target), as C's
|
|
29
|
-
* HTML parser does. The stored value is the real character (`&` → `&`), so
|
|
30
|
-
* imap emits it raw and cmapx re-escapes it. Undefined passes through. */
|
|
31
|
-
function anchorAttr(v: string | undefined): string | undefined {
|
|
32
|
-
return v === undefined ? undefined : htmlEntityUTF8(v);
|
|
33
|
-
}
|
|
34
27
|
|
|
35
28
|
// ---------------------------------------------------------------------------
|
|
36
29
|
// Attribute helpers
|
|
@@ -44,6 +37,10 @@ export const parseAlign = (v: string): HtmlAlign | undefined => {
|
|
|
44
37
|
return undefined;
|
|
45
38
|
};
|
|
46
39
|
|
|
40
|
+
/** @see lib/common/htmllex.c:cell_halignfn */
|
|
41
|
+
export const parseCellAlign = (v: string): HtmlCellAlign | undefined =>
|
|
42
|
+
v.toUpperCase() === 'TEXT' ? 'text' : parseAlign(v);
|
|
43
|
+
|
|
47
44
|
export const parseVAlign = (v: string): HtmlVAlign | undefined => {
|
|
48
45
|
const u = v.toUpperCase();
|
|
49
46
|
if (u === 'TOP') return 'top';
|
|
@@ -112,7 +109,7 @@ export const buildCell = (content: HtmlCellContent[], a: Record<string, string>)
|
|
|
112
109
|
kind: 'cell',
|
|
113
110
|
content,
|
|
114
111
|
port: a['port'],
|
|
115
|
-
align:
|
|
112
|
+
align: parseCellAlign(a['align'] !== undefined ? a['align'] : ''),
|
|
116
113
|
valign: parseVAlign(a['valign'] !== undefined ? a['valign'] : ''),
|
|
117
114
|
balign: parseAlign(a['balign'] !== undefined ? a['balign'] : ''),
|
|
118
115
|
bgcolor: a['bgcolor'],
|
|
@@ -128,9 +125,9 @@ export const buildCell = (content: HtmlCellContent[], a: Record<string, string>)
|
|
|
128
125
|
fixedsize: parseFixedSize(a),
|
|
129
126
|
colspan: num(a['colspan']),
|
|
130
127
|
rowspan: num(a['rowspan']),
|
|
131
|
-
href:
|
|
128
|
+
href: a['href'],
|
|
132
129
|
title: parseTitle(a),
|
|
133
|
-
target:
|
|
130
|
+
target: a['target'],
|
|
134
131
|
id: a['id'],
|
|
135
132
|
});
|
|
136
133
|
|
|
@@ -149,9 +146,9 @@ export const buildTable = (rows: HtmlRow[], a: Record<string, string>): HtmlTabl
|
|
|
149
146
|
fixedsize: parseFixedSize(a),
|
|
150
147
|
width: num(a['width']),
|
|
151
148
|
height: num(a['height']),
|
|
152
|
-
href:
|
|
149
|
+
href: a['href'],
|
|
153
150
|
title: parseTitle(a),
|
|
154
|
-
target:
|
|
151
|
+
target: a['target'],
|
|
155
152
|
id: a['id'],
|
|
156
153
|
port: a['port'],
|
|
157
154
|
sides: parseSides(a['sides']),
|