@knowvah/dot-engine 2.6.7 → 2.6.9
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-expat.d.ts +15 -4
- 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 +3 -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/common/subst.d.ts +8 -0
- package/dist/common/subst.d.ts.map +1 -1
- package/dist/gvc/anchor.d.ts.map +1 -1
- package/dist/index.js +308 -105
- package/dist/index.js.map +4 -4
- package/dist/render/xml-escape.d.ts +8 -6
- package/dist/render/xml-escape.d.ts.map +1 -1
- package/dist/render.js +308 -105
- 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-expat.ts +59 -27
- package/src/common/htmltable-lex.ts +9 -10
- package/src/common/htmltable-parse-attrs.ts +10 -13
- package/src/common/htmltable-parse.ts +16 -29
- package/src/common/htmltable-pos.ts +5 -88
- package/src/common/htmltable-types.ts +4 -1
- package/src/common/htmltable.ts +16 -16
- package/src/common/make-label.ts +8 -3
- package/src/common/poly-gencode.ts +2 -2
- package/src/common/subst.ts +12 -0
- package/src/gvc/anchor.ts +6 -5
- package/src/render/xml-escape.ts +23 -33
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@knowvah/dot-engine",
|
|
3
|
-
"version": "2.6.
|
|
3
|
+
"version": "2.6.9",
|
|
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
|
|
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
|
|
251
|
-
*
|
|
252
|
-
* next write continues it. `flush` emits a pending
|
|
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
|
-
|
|
261
|
-
this.pending
|
|
262
|
-
|
|
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 `	` 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
|
-
/**
|
|
35
|
-
|
|
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;
|
|
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,
|
|
56
|
-
|
|
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`:
|
|
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
|
|
104
|
-
if (
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
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
|
|
124
|
-
|
|
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
|
-
/**
|
|
138
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
393
|
-
|
|
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] === '#')
|
|
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
|
|
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,
|
|
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)
|
|
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
|
-
|
|
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
|
|
277
|
-
if (
|
|
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 (`&` → `&`), 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']),
|