@knowvah/dot-engine 2.6.7 → 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 (36) 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-expat.d.ts +15 -4
  8. package/dist/common/htmltable-lex-expat.d.ts.map +1 -1
  9. package/dist/common/htmltable-lex.d.ts.map +1 -1
  10. package/dist/common/htmltable-parse-attrs.d.ts +3 -1
  11. package/dist/common/htmltable-parse-attrs.d.ts.map +1 -1
  12. package/dist/common/htmltable-parse.d.ts +6 -5
  13. package/dist/common/htmltable-parse.d.ts.map +1 -1
  14. package/dist/common/htmltable-pos.d.ts +3 -26
  15. package/dist/common/htmltable-pos.d.ts.map +1 -1
  16. package/dist/common/htmltable-types.d.ts +3 -1
  17. package/dist/common/htmltable-types.d.ts.map +1 -1
  18. package/dist/common/htmltable.d.ts +3 -2
  19. package/dist/common/htmltable.d.ts.map +1 -1
  20. package/dist/common/make-label.d.ts.map +1 -1
  21. package/dist/index.js +174 -84
  22. package/dist/index.js.map +4 -4
  23. package/dist/render.js +174 -84
  24. package/dist/render.js.map +4 -4
  25. package/package.json +1 -1
  26. package/src/common/htmltable-attr-warn.ts +24 -7
  27. package/src/common/htmltable-label.ts +109 -0
  28. package/src/common/htmltable-lex-attvalue.ts +141 -0
  29. package/src/common/htmltable-lex-expat.ts +59 -27
  30. package/src/common/htmltable-lex.ts +9 -10
  31. package/src/common/htmltable-parse-attrs.ts +10 -13
  32. package/src/common/htmltable-parse.ts +16 -29
  33. package/src/common/htmltable-pos.ts +5 -88
  34. package/src/common/htmltable-types.ts +4 -1
  35. package/src/common/htmltable.ts +16 -16
  36. package/src/common/make-label.ts +8 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@knowvah/dot-engine",
3
- "version": "2.6.7",
3
+ "version": "2.6.8",
4
4
  "description": "Faithful TypeScript port of Graphviz (dot, neato, fdp, sfdp, circo, twopi, osage, patchwork) — no C: no native binary, no WASM. Oracle-verified against the C implementation, runs in the browser.",
5
5
  "keywords": [
6
6
  "graphviz",
@@ -11,7 +11,7 @@
11
11
  * @see lib/common/htmllex.c:doAttrs
12
12
  */
13
13
 
14
- /** An attribute as written: name in source case, value verbatim. */
14
+ /** An attribute as startElement gets it: name in source case, value decoded by expat. */
15
15
  export type AttrEntry = readonly [name: string, value: string];
16
16
 
17
17
  export interface CheckedAttrs {
@@ -247,9 +247,9 @@ export function checkAttrs(tag: string, pairs: readonly AttrEntry[]): CheckedAtt
247
247
  // ---------------------------------------------------------------------------
248
248
 
249
249
  /**
250
- * C's stderr as lines: text is appended, each completed line goes to
251
- * `writeLine`, and a message without a trailing newline stays pending so the
252
- * next write continues it. `flush` emits a pending partial line.
250
+ * C's stderr as messages: a write ending in a newline goes to `writeLine`
251
+ * (newline dropped, embedded newlines kept), while a message without one stays
252
+ * pending so the next write continues it. `flush` emits a pending message.
253
253
  */
254
254
  export class StderrLines {
255
255
  private pending = '';
@@ -257,9 +257,11 @@ export class StderrLines {
257
257
  constructor(private readonly writeLine: (line: string) => void) {}
258
258
 
259
259
  write(text: string): void {
260
- const parts = (this.pending + text).split('\n');
261
- this.pending = parts.pop() ?? '';
262
- for (const line of parts) this.writeLine(line);
260
+ this.pending += text;
261
+ if (!this.pending.endsWith('\n')) return;
262
+ const line = this.pending.slice(0, -1);
263
+ this.pending = '';
264
+ this.writeLine(line);
263
265
  }
264
266
 
265
267
  flush(): void {
@@ -268,3 +270,18 @@ export class StderrLines {
268
270
  this.pending = '';
269
271
  }
270
272
  }
273
+
274
+ /** A finished stderr line on the console channel the port uses for it. */
275
+ export const consoleLine = (line: string): void => {
276
+ if (line.startsWith('Warning')) console.warn(line);
277
+ else console.error(line);
278
+ };
279
+
280
+ /** One label's stderr: what make_html_label and make_label print, in order. */
281
+ export const labelStderr = (): StderrLines => new StderrLines(consoleLine);
282
+
283
+ /** agerr of one newline-terminated message: to `out`, or the console when there is none. */
284
+ export const agerrLine = (out: StderrLines | undefined, msg: string): void => {
285
+ if (out === undefined) consoleLine(msg);
286
+ else out.write(`${msg}\n`);
287
+ };
@@ -0,0 +1,109 @@
1
+ // SPDX-License-Identifier: EPL-2.0
2
+
3
+ /**
4
+ * make_html_label: parse, size and position an HTML-like label, writing what
5
+ * C writes to stderr along the way through one {@link StderrLines}, so a
6
+ * message without a trailing newline (htmllex.c's doInt) runs into the next
7
+ * write: a later warning, the parse error, or make_label's `in label of ...`.
8
+ *
9
+ * @see lib/common/htmltable.c:make_html_label
10
+ */
11
+
12
+ import type { TextlabelT } from './types.js';
13
+ import type { TextMeasurer } from './textmeasure.js';
14
+ import type { HtmlLabel, ImageSizer } from './htmltable-types.js';
15
+ import { sizeHtmlLabel, parseHtmlLabelRv, inheritTablePenColor } from './htmltable.js';
16
+ import { makeLabel } from './make-label.js';
17
+ import { HtmlLexError } from './htmltable-lex.js';
18
+ import { labelStderr, type StderrLines } from './htmltable-attr-warn.js';
19
+ import { posHtmlLabel, type HtmlFontInfo, type PlacedHtml } from './htmltable-pos.js';
20
+
21
+ /** parseHTML's rv for a label that failed to parse (`ctx->error`). */
22
+ const HTML_PARSE_FATAL = 3;
23
+
24
+ /** A built HTML label plus make_html_label's "problem" return value. */
25
+ export interface HtmlLabelResult {
26
+ label: TextlabelT;
27
+ rv: number;
28
+ }
29
+
30
+ /** The label's font, the object's pen color, and the `<IMG>` sizer. */
31
+ export type HtmlLabelInfo = HtmlFontInfo & { imageSizer?: ImageSizer; pencolor?: string };
32
+
33
+ /**
34
+ * make_html_label: parse, size and position an HTML-like label. Returns C's
35
+ * rv too (nonzero: the caller appends `in label of ...`, labels.c:make_label).
36
+ * Everything C prints goes to `stderr`, which the caller owns and flushes once
37
+ * its own continuation is written. The optional `imageSizer` resolves
38
+ * `<IMG SRC>`; absent means C's missing image.
39
+ * @see lib/common/htmltable.c:make_html_label
40
+ */
41
+ export function makeHtmlLabelTo(
42
+ content: string,
43
+ finfo: HtmlLabelInfo,
44
+ measurer: TextMeasurer,
45
+ stderr: StderrLines,
46
+ ): HtmlLabelResult {
47
+ const { fontname, fontsize, fontcolor, imageSizer, pencolor } = finfo;
48
+ let lbl: HtmlLabel, lexWarn: boolean; // lexWarn: htmllex.c's ctx->warn, rv bit 1
49
+ try {
50
+ ({ label: lbl, warn: lexWarn } = parseHtmlLabelRv(content, stderr));
51
+ } catch (e) {
52
+ // C aborts the HTML parse on a syntax error (YYABORT): parseHTML returns
53
+ // rv 3 (ctx->error) with no label, and make_html_label returns it with
54
+ // lp->html cleared and no spans, so the label renders empty.
55
+ // @see lib/common/htmltable.c:1880 (rv == 3), htmlparse.y (YYABORT)
56
+ if (e instanceof HtmlLexError) for (const line of e.diagnostics) stderr.write(`${line}\n`);
57
+ return { label: makeLabel('', fontname, fontsize, fontcolor, measurer), rv: HTML_PARSE_FATAL };
58
+ }
59
+ // A top-level table with no explicit COLOR inherits the object's pen color,
60
+ // which then flows table->cell->nested-table.
61
+ // @see lib/common/htmltable.c:1911 (getPenColor), :1406/:1556 (inheritance)
62
+ if (lbl.kind === 'table') inheritTablePenColor(lbl.table, pencolor);
63
+ const rv = Number(lexWarn) | sizeHtmlLabel(lbl, measurer, {
64
+ fontsize, fontname, imageSizer, stderr,
65
+ });
66
+ const placed = posHtmlLabel(lbl, { fontname, fontsize, fontcolor }, measurer);
67
+ const dim = lbl.dimen ?? { w: 0, h: 0 };
68
+ // C replaces table-label text: "this may be used for the title and
69
+ // alt fields in image maps" — anchors inherit it as default tooltip.
70
+ // @see lib/common/htmltable.c:make_html_label (line 1935)
71
+ const text = lbl.kind === 'table' ? '<TABLE>' : content;
72
+ return { label: htmlTextLabel(text, { fontname, fontsize, fontcolor }, placed, dim), rv };
73
+ }
74
+
75
+ /** {@link makeHtmlLabelTo} on a stderr of its own, flushed before it returns. */
76
+ export function makeHtmlLabelRv(content: string, finfo: HtmlLabelInfo, measurer: TextMeasurer): HtmlLabelResult {
77
+ const stderr = labelStderr();
78
+ try {
79
+ return makeHtmlLabelTo(content, finfo, measurer, stderr);
80
+ } finally {
81
+ stderr.flush();
82
+ }
83
+ }
84
+
85
+ /** {@link makeHtmlLabelRv} without the problem flag. */
86
+ export function makeHtmlLabel(content: string, finfo: HtmlLabelInfo, measurer: TextMeasurer): TextlabelT {
87
+ return makeHtmlLabelRv(content, finfo, measurer).label;
88
+ }
89
+
90
+ /** Build the TextlabelT wrapper around a positioned HTML label. */
91
+ function htmlTextLabel(
92
+ text: string,
93
+ finfo: HtmlFontInfo,
94
+ placed: PlacedHtml,
95
+ dim: { w: number; h: number },
96
+ ): TextlabelT {
97
+ return {
98
+ text,
99
+ fontname: finfo.fontname, fontcolor: finfo.fontcolor, charset: 0,
100
+ fontsize: finfo.fontsize,
101
+ dimen: { x: dim.w, y: dim.h },
102
+ space: { x: dim.w, y: dim.h },
103
+ pos: { x: 0, y: 0 },
104
+ u: { kind: 'html', html: placed },
105
+ valign: 'c'.charCodeAt(0),
106
+ set: false,
107
+ html: true,
108
+ };
109
+ }
@@ -0,0 +1,141 @@
1
+ // SPDX-License-Identifier: EPL-2.0
2
+ /**
3
+ * expat's handling of an attribute value, which `doAttrs` only ever sees
4
+ * decoded: scanAtts tokenizes the quoted value (a `<`, or an `&` that does not
5
+ * start a well-formed reference, is an invalid token), storeAtts then
6
+ * normalizes it (references decoded, white space to spaces) and reports an
7
+ * undefined entity or a reference to a character XML does not allow.
8
+ *
9
+ * Not vendored with graphviz; read from libexpat R_2_6_4.
10
+ * @see expat lib/xmltok_impl.c:PREFIX(scanAtts)
11
+ * @see expat lib/xmltok_impl.c:PREFIX(scanRef)
12
+ * @see expat lib/xmlparse.c:storeAtts
13
+ * @see expat lib/xmlparse.c:appendAttributeValue
14
+ */
15
+
16
+ /** expat `XML_ErrorString(XML_ERROR_UNDEFINED_ENTITY)`. */
17
+ export const XML_ERROR_UNDEFINED_ENTITY = 'undefined entity';
18
+ /** expat `XML_ErrorString(XML_ERROR_BAD_CHAR_REF)`. */
19
+ export const XML_ERROR_BAD_CHAR_REF = 'reference to invalid character number';
20
+
21
+ /** How scanAtts ends a quoted value: at its closing quote, short of data, or on an invalid token. */
22
+ export type AttValueScan = { close: number } | { partial: true } | { invalid: number };
23
+
24
+ /** A storeAtts error: its message and where expat's event pointer is (undefined: the tag's start). */
25
+ export interface AttValueError {
26
+ message: string;
27
+ offset?: number;
28
+ }
29
+
30
+ const NAME_START = /[A-Za-z_:\u0080-￿]/;
31
+ const NAME_CHAR = /[\w.:\u0080-￿-]/;
32
+ const DEC_DIGIT = /[0-9]/;
33
+ const HEX_DIGIT = /[0-9A-Fa-f]/;
34
+ /** Bytes expat's tokenizer rejects anywhere (BT_NONXML): controls other than tab, LF and CR. */
35
+ const NON_XML = /[\x00-\x08\x0b\x0c\x0e-\x1f]/;
36
+
37
+ /** A reference's digits from `i` up to `;`: the index after it, or where scanning stops. */
38
+ function scanDigits(text: string, i: number, digit: RegExp): number | AttValueScan {
39
+ if (i >= text.length) return { partial: true };
40
+ if (!digit.test(text[i]!)) return { invalid: i };
41
+ for (let k = i + 1; k < text.length; k++) {
42
+ if (text[k] === ';') return k + 1;
43
+ if (!digit.test(text[k]!)) return { invalid: k };
44
+ }
45
+ return { partial: true };
46
+ }
47
+
48
+ /**
49
+ * The reference after the `&` at `i - 1`: the index after its `;`, or where
50
+ * scanning stops.
51
+ * @see expat lib/xmltok_impl.c:PREFIX(scanRef)
52
+ * @see expat lib/xmltok_impl.c:PREFIX(scanCharRef)
53
+ */
54
+ function scanRef(text: string, i: number): number | AttValueScan {
55
+ const c = text[i];
56
+ if (c === undefined) return { partial: true };
57
+ if (c === '#') return text[i + 1] === 'x' ? scanDigits(text, i + 2, HEX_DIGIT) : scanDigits(text, i + 1, DEC_DIGIT);
58
+ if (!NAME_START.test(c)) return { invalid: i };
59
+ for (let k = i + 1; k < text.length; k++) {
60
+ if (text[k] === ';') return k + 1;
61
+ if (!NAME_CHAR.test(text[k]!)) return { invalid: k };
62
+ }
63
+ return { partial: true };
64
+ }
65
+
66
+ /**
67
+ * scanAtts over the value whose opening quote is at `j`.
68
+ * @see expat lib/xmltok_impl.c:PREFIX(scanAtts) ("in attribute value")
69
+ */
70
+ export function scanAttValue(text: string, j: number): AttValueScan {
71
+ const q = text[j];
72
+ let k = j + 1;
73
+ for (;;) {
74
+ const c = text[k];
75
+ if (c === undefined) return { partial: true };
76
+ if (c === q) return { close: k };
77
+ if (c === '<' || NON_XML.test(c)) return { invalid: k };
78
+ if (c !== '&') {
79
+ k++;
80
+ continue;
81
+ }
82
+ const r = scanRef(text, k + 1);
83
+ if (typeof r !== 'number') return r;
84
+ k = r;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * XmlCharRefNumber: the code point of `&#...;`, or -1 for one XML does not
90
+ * allow (controls other than tab, LF and CR; surrogates; U+FFFE and U+FFFF;
91
+ * beyond U+10FFFF).
92
+ * @see expat lib/xmltok_impl.c:PREFIX(charRefNumber)
93
+ * @see expat lib/xmltok.c:checkCharRefNumber
94
+ */
95
+ export function charRefNumber(ref: string): number {
96
+ const hex = ref.startsWith('&#x');
97
+ const n = parseInt(ref.slice(hex ? 3 : 2, -1), hex ? 16 : 10);
98
+ if (n >= 0x110000) return -1;
99
+ if (n >= 0xd800 && n <= 0xdfff) return -1;
100
+ if (n < 0x20 && NON_XML.test(String.fromCharCode(n))) return -1;
101
+ return n === 0xfffe || n === 0xffff ? -1 : n;
102
+ }
103
+
104
+ const PREDEFINED: Readonly<Record<string, string>> = { lt: '<', gt: '>', amp: '&', quot: '"', apos: "'" };
105
+
106
+ /** The references of a well-formed value, and its CR LF pairs. */
107
+ const VALUE_PIECE = /&#x?[0-9A-Fa-f]+;|&[^;]+;|\r\n|[\t\n\r]/g;
108
+
109
+ /**
110
+ * storeAtts' check of a well-formed value (`raw`, between the quotes, at
111
+ * `base`): the first undefined entity or disallowed character reference. No
112
+ * DTD, so only the five predefined entities exist.
113
+ * @see expat lib/xmlparse.c:appendAttributeValue
114
+ */
115
+ export function attValueError(raw: string, base: number): AttValueError | undefined {
116
+ for (const m of raw.matchAll(VALUE_PIECE)) {
117
+ const ref = m[0];
118
+ if (!ref.startsWith('&')) continue;
119
+ if (ref.startsWith('&#')) {
120
+ if (charRefNumber(ref) < 0) return { message: XML_ERROR_BAD_CHAR_REF, offset: base + m.index };
121
+ } else if (!Object.hasOwn(PREDEFINED, ref.slice(1, -1))) {
122
+ return { message: XML_ERROR_UNDEFINED_ENTITY };
123
+ }
124
+ }
125
+ return undefined;
126
+ }
127
+
128
+ /**
129
+ * appendAttributeValue for a CDATA attribute (no DTD declares one otherwise)
130
+ * on a value storeAtts accepted: references become their characters, which
131
+ * stay as they are (a `&#9;` is a tab); a tab, LF, CR or CR LF in the source
132
+ * becomes one space.
133
+ * @see expat lib/xmlparse.c:appendAttributeValue
134
+ */
135
+ export function decodeAttValue(raw: string): string {
136
+ return raw.replace(VALUE_PIECE, (m) => {
137
+ if (!m.startsWith('&')) return ' ';
138
+ if (m.startsWith('&#')) return String.fromCodePoint(charRefNumber(m));
139
+ return PREDEFINED[m.slice(1, -1)]!;
140
+ });
141
+ }
@@ -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)';
@@ -31,8 +34,8 @@ export interface ExpatToken {
31
34
  end: number;
32
35
  /** A `cdata` token's character data, as expat reports it (no entity decoding). */
33
36
  data?: string;
34
- /** Offset of the second occurrence of an attribute name in a start tag. */
35
- dup?: number;
37
+ /** A start tag's first storeAtts error: a duplicate name or a bad value. */
38
+ attError?: AttValueError & { offset: number };
36
39
  }
37
40
 
38
41
  /** The first well-formedness error of a parse call. */
@@ -47,13 +50,18 @@ export interface ExpatResult {
47
50
  invalid?: ExpatInvalid;
48
51
  }
49
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
+
50
58
  type Scan =
51
- | { kind: 'complete'; end: number; dup?: number }
59
+ | { kind: 'complete'; end: number; attError?: AttError }
52
60
  | { kind: 'partial' }
53
61
  | { kind: 'invalid'; offset: number };
54
62
 
55
- const COMPLETE_END = (end: number, dup?: number): Scan =>
56
- 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 };
57
65
  const PARTIAL: Scan = { kind: 'partial' };
58
66
  const INVALID = (offset: number): Scan => ({ kind: 'invalid', offset });
59
67
 
@@ -94,21 +102,22 @@ export function fedBytes(unit: string, isText: boolean): number {
94
102
  interface Pair {
95
103
  name: string;
96
104
  next: number;
105
+ /** The value between its quotes. */
106
+ value: { start: number; end: number };
97
107
  }
98
108
 
99
- /** 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. */
100
110
  function scanValue(text: string, j: number): number | Scan {
101
111
  const q = text[j];
102
112
  if (q !== '"' && q !== "'") return INVALID(j);
103
- const close = text.indexOf(q, j + 1);
104
- if (close < 0) {
105
- const lt = text.indexOf('<', j + 1);
106
- return lt >= 0 ? INVALID(lt) : PARTIAL;
107
- }
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;
108
117
  const next = skipSpace(text, close + 1);
109
118
  // Attributes are separated by white space.
110
119
  if (next === close + 1 && !'>/'.includes(text[next] ?? '>')) return INVALID(next);
111
- return next;
120
+ return close;
112
121
  }
113
122
 
114
123
  /** The `name = "value"` pair at `i`, or how expat's tokenizer stops there. */
@@ -120,8 +129,9 @@ function scanPair(text: string, i: number): Pair | Scan {
120
129
  if (text[eq] !== '=') return INVALID(eq);
121
130
  const j = skipSpace(text, eq + 1);
122
131
  if (j >= text.length) return PARTIAL;
123
- const next = scanValue(text, j);
124
- 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 } };
125
135
  }
126
136
 
127
137
  /** `>` or `/>` at `i` ends the tag; end of data leaves it partial; otherwise more attributes. */
@@ -134,19 +144,30 @@ function tagEdge(text: string, i: number): Scan | undefined {
134
144
  return text[i + 1] === '>' ? COMPLETE_END(i + 2) : undefined;
135
145
  }
136
146
 
137
- /** The pairs of a start tag from `from`; a duplicate only counts once the tag tokenizes whole. */
138
- 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 {
139
161
  const seen = new Set<string>();
140
- let dup: number | undefined;
162
+ let attError: AttError | undefined;
141
163
  let i = skipSpace(text, from);
142
164
  for (;;) {
143
165
  const edge = tagEdge(text, i);
144
- if (edge?.kind === 'complete') return COMPLETE_END(edge.end, dup);
166
+ if (edge?.kind === 'complete') return COMPLETE_END(edge.end, attError);
145
167
  if (edge !== undefined) return edge;
146
168
  const pair = scanPair(text, i);
147
169
  if ('kind' in pair) return pair;
148
- if (seen.has(pair.name)) dup ??= i;
149
- seen.add(pair.name);
170
+ attError ??= storeAttError(text, p, i, pair, seen);
150
171
  i = pair.next;
151
172
  }
152
173
  }
@@ -165,7 +186,7 @@ function scanStartTag(text: string, p: number): Scan {
165
186
  const c = text[name.next];
166
187
  if (c === undefined) return PARTIAL;
167
188
  if (!isSpace(c) && c !== '>' && c !== '/') return INVALID(name.next);
168
- return scanAttributes(text, name.next);
189
+ return scanAttributes(text, p, name.next);
169
190
  }
170
191
 
171
192
  /** An end tag at `p`: a name, optional white space, `>`. */
@@ -281,7 +302,7 @@ function scanContent(text: string, base: number, inCdata: boolean, dataOf: Cdata
281
302
  if (scan.kind === 'invalid') return { tokens, consumed: p, invalid: base + scan.offset, inCdata: false };
282
303
  if (scan.kind === 'partial') return { tokens, consumed: p, inCdata: false };
283
304
  const tok: ExpatToken = { kind, start: base + p, end: base + scan.end };
284
- if (scan.dup !== undefined) tok.dup = base + scan.dup;
305
+ if (scan.attError !== undefined) tok.attError = { ...scan.attError, offset: base + scan.attError.offset };
285
306
  tokens.push(tok);
286
307
  p = scan.end;
287
308
  }
@@ -389,18 +410,29 @@ const XML_PREDEFINED_ENTITIES = new Set(['lt', 'gt', 'amp', 'quot', 'apos']);
389
410
 
390
411
  const ENTITY_NAME = /^[A-Za-z_:][\w.:-]*$/;
391
412
 
392
- /** Offset of the first `&name;` that scanEntity leaves for expat to reject. */
393
- 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 {
394
422
  for (let t = text.indexOf('&'); t >= 0; t = text.indexOf('&', t + 1)) {
395
- 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
+ }
396
428
  const semi = text.indexOf(';', t + 1);
397
429
  if (semi < 0) continue;
398
430
  const key = text.slice(t + 1, semi);
399
431
  if (ENTITY_NAME.test(key) && !Object.hasOwn(HTML_ENTITIES, key) && !XML_PREDEFINED_ENTITIES.has(key)) {
400
- return t;
432
+ return { message: XML_ERROR_UNDEFINED_ENTITY, offset: t };
401
433
  }
402
434
  }
403
- return -1;
435
+ return undefined;
404
436
  }
405
437
 
406
438
  export interface TagChunk {
@@ -15,11 +15,12 @@ import { HtmlParseError } from './htmltable-types.js';
15
15
  import type { LexerOutput, OpenToken, TextToken, Token } from './htmltable-types.js';
16
16
  import { checkAttrs } from './htmltable-attr-warn.js';
17
17
  import type { AttrEntry } from './htmltable-attr-warn.js';
18
- import { ExpatFeed, findUndefinedEntity, parseTagChunk } from './htmltable-lex-expat.js';
18
+ import { ExpatFeed, parseTagChunk, textRefError } from './htmltable-lex-expat.js';
19
19
  import { charDataTokens, initialCharData, keepLostText, releaseXb, tagInCell, textTokens } from './htmltable-lex-chardata.js';
20
20
  import type { CharDataState } from './htmltable-lex-chardata.js';
21
21
  import type { ExpatResult, ExpatToken } from './htmltable-lex-expat.js';
22
22
  import { findNext } from './htmltable-lex-units.js';
23
+ import { decodeAttValue } from './htmltable-lex-attvalue.js';
23
24
  import type { Unit } from './htmltable-lex-units.js';
24
25
 
25
26
  export type { Token } from './htmltable-types.js';
@@ -42,7 +43,8 @@ const isWs = (c: string | undefined): boolean =>
42
43
  /**
43
44
  * Parse key="value" attribute pairs. Values are consumed, never
44
45
  * re-scanned (the old scanner re-matched words inside quoted values as
45
- * 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
46
48
  * XML — expat rejects the document and the label falls back to plain
47
49
  * text; throwing here reproduces that.
48
50
  * Names keep their source case (doAttrs prints them as written) and pairs
@@ -67,7 +69,7 @@ function parseAttrPairs(raw: string): AttrEntry[] {
67
69
  if (q !== '"' && q !== "'") throw new HtmlParseError(name);
68
70
  const end = raw.indexOf(q, i + 1);
69
71
  if (end < 0) throw new HtmlParseError(name);
70
- out.push([name, raw.slice(i + 1, end)]);
72
+ out.push([name, decodeAttValue(raw.slice(i + 1, end))]);
71
73
  i = end + 1;
72
74
  }
73
75
  return out;
@@ -145,9 +147,7 @@ function parseOpenToken(inner: string) {
145
147
  * with the graphviz sources.
146
148
  * @see lib/common/htmllex.c:htmllex (XML_ErrorString at the XML_Parse failure)
147
149
  */
148
- const XML_ERROR_UNDEFINED_ENTITY = 'undefined entity';
149
150
  const XML_ERROR_TAG_MISMATCH = 'mismatched tag';
150
- const XML_ERROR_DUPLICATE_ATTRIBUTE = 'duplicate attribute';
151
151
  /** yacc's message when the token stream ends inside an unfinished construct. */
152
152
  const SYNTAX_ERROR = 'syntax error';
153
153
 
@@ -254,7 +254,8 @@ function checkTagToken(site: TokenSite): void {
254
254
  throw htmlErrorInLine(XML_ERROR_TAG_MISMATCH, lineAt(src, tok.start), context);
255
255
  }
256
256
  } else {
257
- 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);
258
259
  if (!tag.selfClose) st.stack.push(tag.name);
259
260
  }
260
261
  if (KNOWN_TAGS.has(tag.name.toUpperCase())) return;
@@ -273,10 +274,8 @@ function eventTokens(site: TokenSite): Token[] {
273
274
  checkTagToken(site);
274
275
  return tagTokens(text);
275
276
  }
276
- const off = findUndefinedEntity(text);
277
- if (off >= 0) {
278
- throw htmlErrorInLine(XML_ERROR_UNDEFINED_ENTITY, lineAt(src, tok.start + off), st.prev + st.curr);
279
- }
277
+ const err = textRefError(text);
278
+ if (err !== undefined) throw htmlErrorInLine(err.message, lineAt(src, tok.start + err.offset), st.prev + st.curr);
280
279
  return textTokens(text);
281
280
  }
282
281
 
@@ -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']),