@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.
Files changed (39) hide show
  1. package/dist/common/htmltable-attr-warn.d.ts +10 -4
  2. package/dist/common/htmltable-attr-warn.d.ts.map +1 -1
  3. package/dist/common/htmltable-label.d.ts +37 -0
  4. package/dist/common/htmltable-label.d.ts.map +1 -0
  5. package/dist/common/htmltable-lex-attvalue.d.ts +59 -0
  6. package/dist/common/htmltable-lex-attvalue.d.ts.map +1 -0
  7. package/dist/common/htmltable-lex-chardata.d.ts +37 -0
  8. package/dist/common/htmltable-lex-chardata.d.ts.map +1 -0
  9. package/dist/common/htmltable-lex-expat.d.ts +26 -13
  10. package/dist/common/htmltable-lex-expat.d.ts.map +1 -1
  11. package/dist/common/htmltable-lex.d.ts.map +1 -1
  12. package/dist/common/htmltable-parse-attrs.d.ts +3 -1
  13. package/dist/common/htmltable-parse-attrs.d.ts.map +1 -1
  14. package/dist/common/htmltable-parse.d.ts +6 -5
  15. package/dist/common/htmltable-parse.d.ts.map +1 -1
  16. package/dist/common/htmltable-pos.d.ts +3 -26
  17. package/dist/common/htmltable-pos.d.ts.map +1 -1
  18. package/dist/common/htmltable-types.d.ts +5 -1
  19. package/dist/common/htmltable-types.d.ts.map +1 -1
  20. package/dist/common/htmltable.d.ts +3 -2
  21. package/dist/common/htmltable.d.ts.map +1 -1
  22. package/dist/common/make-label.d.ts.map +1 -1
  23. package/dist/index.js +310 -144
  24. package/dist/index.js.map +4 -4
  25. package/dist/render.js +310 -144
  26. package/dist/render.js.map +4 -4
  27. package/package.json +1 -1
  28. package/src/common/htmltable-attr-warn.ts +24 -7
  29. package/src/common/htmltable-label.ts +109 -0
  30. package/src/common/htmltable-lex-attvalue.ts +141 -0
  31. package/src/common/htmltable-lex-chardata.ts +95 -0
  32. package/src/common/htmltable-lex-expat.ts +143 -48
  33. package/src/common/htmltable-lex.ts +41 -36
  34. package/src/common/htmltable-parse-attrs.ts +10 -13
  35. package/src/common/htmltable-parse.ts +46 -61
  36. package/src/common/htmltable-pos.ts +5 -88
  37. package/src/common/htmltable-types.ts +6 -1
  38. package/src/common/htmltable.ts +16 -16
  39. 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
- /** Offset of the second occurrence of an attribute name in a start tag. */
33
- dup?: number;
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; dup?: 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, dup?: number): Scan =>
54
- dup === undefined ? { kind: 'complete', end } : { kind: 'complete', end, dup };
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
- * The number of bytes C passes to `XML_Parse` for a text unit: the buffer
73
- * `findNext` filled (named entities rewritten as `&#N;`, see `scanEntity`)
74
- * with a trailing `]` escaped (`protect_rsqb`). Other units go as written.
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
- export function fedBytes(unit: string, isText: boolean): number {
78
- if (!isText) return byteLength(unit);
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
- if (text.endsWith(']')) text = `${text.slice(0, -1)}&#93;`;
83
- return byteLength(text);
94
+ return endsUnit && text.endsWith(']') ? `${text.slice(0, -1)}&#93;` : 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`: the index after it and any white space, or how scanning stops. */
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 close = text.indexOf(q, j + 1);
96
- if (close < 0) {
97
- const lt = text.indexOf('<', j + 1);
98
- return lt >= 0 ? INVALID(lt) : PARTIAL;
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 next;
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 next = scanValue(text, j);
116
- return typeof next === 'number' ? { name, next } : next;
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
- /** The pairs of a start tag from `from`; a duplicate only counts once the tag tokenizes whole. */
130
- function scanAttributes(text: string, from: number): Scan {
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 dup: number | undefined;
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, dup);
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
- if (seen.has(pair.name)) dup ??= i;
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
- const e = text.indexOf('<', p);
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.dup !== undefined) tok.dup = base + scan.dup;
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, nbytes: number): ExpatResult {
310
- this.buffer.append(nbytes);
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
- /** Offset of the first `&name;` that scanEntity leaves for expat to reject. */
330
- export function findUndefinedEntity(text: string): number {
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] === '#') continue;
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 -1;
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, fedBytes, findUndefinedEntity, parseTagChunk } from './htmltable-lex-expat.js';
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). Bare or unquoted attributes are not well-formed
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:characterData */
97
- function cleanText(raw: string) {
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 "&#91;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
- // @see lib/common/htmllex.c (XML_SetElementHandler callbacks)
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
- if (tok.dup !== undefined) throw htmlErrorInLine(XML_ERROR_DUPLICATE_ATTRIBUTE, lineAt(src, tok.dup), context);
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 off = findUndefinedEntity(text);
282
- if (off >= 0) {
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
- const tokens = eventTokens({ src: run.src, tok, st: run.st });
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) absorb(run.carry, g.tokens);
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
- const nbytes = fedBytes(run.st.curr, !run.st.curr.startsWith('<'));
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 (`&amp;` → `&`), 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: parseAlign(a['align'] !== undefined ? a['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: anchorAttr(a['href']),
128
+ href: a['href'],
132
129
  title: parseTitle(a),
133
- target: anchorAttr(a['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: anchorAttr(a['href']),
149
+ href: a['href'],
153
150
  title: parseTitle(a),
154
- target: anchorAttr(a['target']),
151
+ target: a['target'],
155
152
  id: a['id'],
156
153
  port: a['port'],
157
154
  sides: parseSides(a['sides']),