@knowvah/dot-engine 2.6.6 → 2.6.7
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-lex-chardata.d.ts +37 -0
- package/dist/common/htmltable-lex-chardata.d.ts.map +1 -0
- package/dist/common/htmltable-lex-expat.d.ts +11 -9
- 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.d.ts.map +1 -1
- package/dist/common/htmltable-types.d.ts +2 -0
- package/dist/common/htmltable-types.d.ts.map +1 -1
- package/dist/index.js +136 -60
- package/dist/index.js.map +4 -4
- package/dist/render.js +136 -60
- package/dist/render.js.map +4 -4
- package/package.json +1 -1
- package/src/common/htmltable-lex-chardata.ts +95 -0
- package/src/common/htmltable-lex-expat.ts +84 -21
- package/src/common/htmltable-lex.ts +33 -27
- package/src/common/htmltable-parse.ts +30 -32
- package/src/common/htmltable-types.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@knowvah/dot-engine",
|
|
3
|
-
"version": "2.6.
|
|
3
|
+
"version": "2.6.7",
|
|
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",
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EPL-2.0
|
|
2
|
+
/**
|
|
3
|
+
* The htmllex state characterData reads: `ctx->inCell`, which the element
|
|
4
|
+
* handlers set, and `ctx->xb`, the buffer it appends to. Every handler assigns
|
|
5
|
+
* `ctx->tok`, so when one `XML_Parse` call fires several events only the last
|
|
6
|
+
* token reaches the parser, but characters an earlier event appended stay in
|
|
7
|
+
* xb: they lead the next string the parser reduces, or count in the table
|
|
8
|
+
* rule's nonSpace check.
|
|
9
|
+
*
|
|
10
|
+
* @see lib/common/htmllex.c:startElement
|
|
11
|
+
* @see lib/common/htmllex.c:endElement
|
|
12
|
+
* @see lib/common/htmllex.c:characterData
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { htmlEntityUTF8 } from './html-entities.js';
|
|
16
|
+
import type { TagChunk } from './htmltable-lex-expat.js';
|
|
17
|
+
import type { Token } from './htmltable-types.js';
|
|
18
|
+
|
|
19
|
+
/** `ctx->inCell`, and the text in `ctx->xb` that no token carried to the parser. */
|
|
20
|
+
export interface CharDataState {
|
|
21
|
+
inCell: boolean;
|
|
22
|
+
xb: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** initHTMLlexer: text is kept until the first TABLE or TR. @see lib/common/htmllex.c:initHTMLlexer */
|
|
26
|
+
export const initialCharData = (): CharDataState => ({ inCell: true, xb: '' });
|
|
27
|
+
|
|
28
|
+
/** startElement's assignment to inCell for element `name` (upper case). */
|
|
29
|
+
function startInCell(name: string, inCell: boolean): boolean {
|
|
30
|
+
if (name === 'TABLE' || name === 'TR' || name === 'TH') return false;
|
|
31
|
+
return name === 'TD' ? true : inCell;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** endElement's assignment to inCell for element `name` (upper case). */
|
|
35
|
+
function endInCell(name: string, inCell: boolean): boolean {
|
|
36
|
+
if (name === 'TABLE') return true;
|
|
37
|
+
return name === 'TD' ? false : inCell;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Run a tag's element handlers on inCell; `<X/>` runs startElement, then endElement. */
|
|
41
|
+
export function tagInCell(st: CharDataState, tag: TagChunk): void {
|
|
42
|
+
const name = tag.name.toUpperCase();
|
|
43
|
+
if (tag.close) {
|
|
44
|
+
st.inCell = endInCell(name, st.inCell);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
st.inCell = startInCell(name, st.inCell);
|
|
48
|
+
if (tag.selfClose) st.inCell = endInCell(name, st.inCell);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Keep the text of tokens a later event in the same call overwrote. */
|
|
52
|
+
export function keepLostText(st: CharDataState, lost: readonly Token[]): void {
|
|
53
|
+
for (const t of lost) if (t.type === 'text') st.xb += t.value;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Hand xb's kept text to the token the parser is about to get: a string
|
|
58
|
+
* starts with it, and a TABLE carries it for the table rule's nonSpace check.
|
|
59
|
+
* Any other token leaves it in xb for the next of those.
|
|
60
|
+
* @see lib/common/htmlparse.y:table
|
|
61
|
+
*/
|
|
62
|
+
export function releaseXb(st: CharDataState, first: Token): void {
|
|
63
|
+
if (st.xb === '') return;
|
|
64
|
+
if (first.type === 'text') first.value = st.xb + first.value;
|
|
65
|
+
else if (first.type === 'open' && first.tag === 'TABLE') first.heldText = st.xb;
|
|
66
|
+
else return;
|
|
67
|
+
st.xb = '';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* characterData keeps each byte `>= ' '` of the text expat decoded: anything
|
|
72
|
+
* below 0x20 goes (tab, LF and CR too, from the source or a character
|
|
73
|
+
* reference); DEL and non-ASCII stay.
|
|
74
|
+
* @see lib/common/htmllex.c:characterData
|
|
75
|
+
*/
|
|
76
|
+
function cleanText(decoded: string) {
|
|
77
|
+
return decoded.replace(/[\x00-\x1f]/g, '');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The text token of a text run, if anything is left after cleaning. */
|
|
81
|
+
export function textTokens(raw: string): Token[] {
|
|
82
|
+
// Decode XML/HTML entities in the text run. C's HTML lexer resolves entities
|
|
83
|
+
// via expat during tokenization (htmllex.c), so both sizing and emit see the
|
|
84
|
+
// decoded UTF-8 text. Skipping this measured the raw "[el..." (9-10 chars)
|
|
85
|
+
// instead of "[el..." (6 chars), inflating edge-label vnodes and shifting
|
|
86
|
+
// layout (corpus 1949: +18.7px). Plain-string labels already decode via the
|
|
87
|
+
// make_label path, so only HTML-delimited labels diverged.
|
|
88
|
+
return charDataTokens(htmlEntityUTF8(raw));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** characterData on what expat decoded: a T_string when any character survives. */
|
|
92
|
+
export function charDataTokens(decoded: string): Token[] {
|
|
93
|
+
const clean = cleanText(decoded);
|
|
94
|
+
return clean.length > 0 ? [{ type: 'text', value: clean }] : [];
|
|
95
|
+
}
|
|
@@ -26,9 +26,11 @@ const INIT_BUFFER_SIZE = 1024;
|
|
|
26
26
|
|
|
27
27
|
/** One complete expat token of the content: its extent in the label source. */
|
|
28
28
|
export interface ExpatToken {
|
|
29
|
-
kind: 'text' | 'tag' | 'comment' | 'pi';
|
|
29
|
+
kind: 'text' | 'tag' | 'comment' | 'pi' | 'cdata';
|
|
30
30
|
start: number;
|
|
31
31
|
end: number;
|
|
32
|
+
/** A `cdata` token's character data, as expat reports it (no entity decoding). */
|
|
33
|
+
data?: string;
|
|
32
34
|
/** Offset of the second occurrence of an attribute name in a start tag. */
|
|
33
35
|
dup?: number;
|
|
34
36
|
}
|
|
@@ -69,18 +71,24 @@ function skipSpace(text: string, i: number): number {
|
|
|
69
71
|
const byteLength = (s: string): number => new TextEncoder().encode(s).length;
|
|
70
72
|
|
|
71
73
|
/**
|
|
72
|
-
*
|
|
73
|
-
* `findNext` filled (named entities rewritten as `&#N;`, see `scanEntity`)
|
|
74
|
-
* with a
|
|
74
|
+
* What C passes to `XML_Parse` for (a piece of) a text unit: the buffer
|
|
75
|
+
* `findNext` filled (named entities rewritten as `&#N;`, see `scanEntity`),
|
|
76
|
+
* with a `]` that ends the unit escaped (`protect_rsqb`). Outside a CDATA
|
|
77
|
+
* section expat decodes both back to the characters written; inside one they
|
|
78
|
+
* stay as rewritten.
|
|
79
|
+
* @see lib/common/htmllex.c:findNext
|
|
75
80
|
* @see lib/common/htmllex.c:protect_rsqb
|
|
76
81
|
*/
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
let text = unit.replace(/&([^;&<]*);/g, (m, name: string) => {
|
|
82
|
+
function fedText(piece: string, endsUnit: boolean): string {
|
|
83
|
+
const text = piece.replace(/&([^;&<]*);/g, (m, name: string) => {
|
|
80
84
|
return name.length >= 2 && Object.hasOwn(HTML_ENTITIES, name) ? `&#${HTML_ENTITIES[name]};` : m;
|
|
81
85
|
});
|
|
82
|
-
|
|
83
|
-
|
|
86
|
+
return endsUnit && text.endsWith(']') ? `${text.slice(0, -1)}]` : text;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The number of bytes C passes to `XML_Parse` for a unit; other than text units go as written. */
|
|
90
|
+
export function fedBytes(unit: string, isText: boolean): number {
|
|
91
|
+
return byteLength(isText ? fedText(unit, true) : unit);
|
|
84
92
|
}
|
|
85
93
|
|
|
86
94
|
interface Pair {
|
|
@@ -219,29 +227,65 @@ interface ContentScan {
|
|
|
219
227
|
/** Offset where the unconsumed (partial) token starts. */
|
|
220
228
|
consumed: number;
|
|
221
229
|
invalid?: number;
|
|
230
|
+
/** Whether the scan ended inside a CDATA section. */
|
|
231
|
+
inCdata: boolean;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const CDATA_OPEN = '<![CDATA[';
|
|
235
|
+
const CDATA_CLOSE = ']]>';
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Character data of a CDATA section from `p` up to its `]]>` (or the end of
|
|
239
|
+
* what was fed): one `cdata` token, then the index after the section, or -1
|
|
240
|
+
* while it is still open. Markup and `&` are data here. The fed bytes never
|
|
241
|
+
* end in a `]` (text units go through protect_rsqb, other units end in `>`),
|
|
242
|
+
* so expat holds no partial `]]>` back.
|
|
243
|
+
* @see expat xmltok_impl.c:PREFIX(cdataSectionTok)
|
|
244
|
+
*/
|
|
245
|
+
function scanCdata(text: string, p: number, base: number, dataOf: CdataText, tokens: ExpatToken[]): number {
|
|
246
|
+
const close = text.indexOf(CDATA_CLOSE, p);
|
|
247
|
+
const end = close < 0 ? text.length : close;
|
|
248
|
+
if (end > p) tokens.push({ kind: 'cdata', start: base + p, end: base + end, data: dataOf(base + p, base + end) });
|
|
249
|
+
return close < 0 ? -1 : close + CDATA_CLOSE.length;
|
|
222
250
|
}
|
|
223
251
|
|
|
252
|
+
/** Character data at `p` up to the next `<`: one `text` token, then the index it ends at. */
|
|
253
|
+
function scanText(text: string, p: number, base: number, tokens: ExpatToken[]): number {
|
|
254
|
+
const e = text.indexOf('<', p);
|
|
255
|
+
const end = e < 0 ? text.length : e;
|
|
256
|
+
tokens.push({ kind: 'text', start: base + p, end: base + end });
|
|
257
|
+
return end;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** The fed text of the label source between two offsets. */
|
|
261
|
+
type CdataText = (start: number, end: number) => string;
|
|
262
|
+
|
|
224
263
|
/** 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 {
|
|
264
|
+
function scanContent(text: string, base: number, inCdata: boolean, dataOf: CdataText): ContentScan {
|
|
226
265
|
const tokens: ExpatToken[] = [];
|
|
227
266
|
let p = 0;
|
|
267
|
+
let inSection = inCdata;
|
|
228
268
|
while (p < text.length) {
|
|
269
|
+
if (inSection || text.startsWith(CDATA_OPEN, p)) {
|
|
270
|
+
const after = scanCdata(text, inSection ? p : p + CDATA_OPEN.length, base, dataOf, tokens);
|
|
271
|
+
if (after < 0) return { tokens, consumed: text.length, inCdata: true };
|
|
272
|
+
inSection = false;
|
|
273
|
+
p = after;
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
229
276
|
if (text[p] !== '<') {
|
|
230
|
-
|
|
231
|
-
const end = e < 0 ? text.length : e;
|
|
232
|
-
tokens.push({ kind: 'text', start: base + p, end: base + end });
|
|
233
|
-
p = end;
|
|
277
|
+
p = scanText(text, p, base, tokens);
|
|
234
278
|
continue;
|
|
235
279
|
}
|
|
236
280
|
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 };
|
|
281
|
+
if (scan.kind === 'invalid') return { tokens, consumed: p, invalid: base + scan.offset, inCdata: false };
|
|
282
|
+
if (scan.kind === 'partial') return { tokens, consumed: p, inCdata: false };
|
|
239
283
|
const tok: ExpatToken = { kind, start: base + p, end: base + scan.end };
|
|
240
284
|
if (scan.dup !== undefined) tok.dup = base + scan.dup;
|
|
241
285
|
tokens.push(tok);
|
|
242
286
|
p = scan.end;
|
|
243
287
|
}
|
|
244
|
-
return { tokens, consumed: p };
|
|
288
|
+
return { tokens, consumed: p, inCdata: inSection };
|
|
245
289
|
}
|
|
246
290
|
|
|
247
291
|
/**
|
|
@@ -297,6 +341,9 @@ export class ExpatFeed {
|
|
|
297
341
|
private readonly buffer = new InputBuffer();
|
|
298
342
|
private heldStart = 0;
|
|
299
343
|
private fedEnd = 0;
|
|
344
|
+
private inCdata = false;
|
|
345
|
+
/** Source ranges of the text units fed so far: their bytes went through findNext's rewrite. */
|
|
346
|
+
private readonly textUnits: Array<readonly [number, number]> = [];
|
|
300
347
|
|
|
301
348
|
constructor(private readonly src: string) {}
|
|
302
349
|
|
|
@@ -305,20 +352,36 @@ export class ExpatFeed {
|
|
|
305
352
|
return this.heldStart < this.fedEnd ? this.heldStart : undefined;
|
|
306
353
|
}
|
|
307
354
|
|
|
308
|
-
/** One `XML_Parse(parser, src[start, end), nbytes, 0)
|
|
309
|
-
feed(start: number, end: number,
|
|
310
|
-
this.
|
|
355
|
+
/** One `XML_Parse(parser, src[start, end), nbytes, 0)`; `isText` for a text unit. */
|
|
356
|
+
feed(start: number, end: number, isText: boolean): ExpatResult {
|
|
357
|
+
if (isText) this.textUnits.push([start, end]);
|
|
358
|
+
this.buffer.append(fedBytes(this.src.slice(start, end), isText));
|
|
311
359
|
this.fedEnd = end;
|
|
312
360
|
if (!this.buffer.enough) return { tokens: [] };
|
|
313
361
|
const text = this.src.slice(this.heldStart, end);
|
|
314
|
-
const scan = scanContent(text, this.heldStart);
|
|
362
|
+
const scan = scanContent(text, this.heldStart, this.inCdata, (a, b) => this.fedBetween(a, b));
|
|
315
363
|
if (scan.invalid !== undefined) {
|
|
316
364
|
return { tokens: scan.tokens, invalid: { message: XML_ERROR_INVALID_TOKEN, offset: scan.invalid } };
|
|
317
365
|
}
|
|
366
|
+
this.inCdata = scan.inCdata;
|
|
318
367
|
this.heldStart += scan.consumed;
|
|
319
368
|
this.buffer.consumedTo(byteLength(this.src.slice(this.heldStart, end)), scan.consumed > 0);
|
|
320
369
|
return { tokens: scan.tokens };
|
|
321
370
|
}
|
|
371
|
+
|
|
372
|
+
/** What expat read for source offsets [start, end): text-unit pieces as findNext rewrote them. */
|
|
373
|
+
private fedBetween(start: number, end: number): string {
|
|
374
|
+
let out = '';
|
|
375
|
+
let p = start;
|
|
376
|
+
for (const [us, ue] of this.textUnits) {
|
|
377
|
+
if (ue <= p || us >= end) continue;
|
|
378
|
+
if (us > p) out += this.src.slice(p, us);
|
|
379
|
+
const e = Math.min(ue, end);
|
|
380
|
+
out += fedText(this.src.slice(Math.max(us, p), e), e === ue);
|
|
381
|
+
p = e;
|
|
382
|
+
}
|
|
383
|
+
return out + this.src.slice(p, end);
|
|
384
|
+
}
|
|
322
385
|
}
|
|
323
386
|
|
|
324
387
|
/** Entities expat knows without a DTD; `scanEntity` leaves them as written. */
|
|
@@ -13,10 +13,11 @@
|
|
|
13
13
|
|
|
14
14
|
import { HtmlParseError } from './htmltable-types.js';
|
|
15
15
|
import type { LexerOutput, OpenToken, TextToken, Token } from './htmltable-types.js';
|
|
16
|
-
import { htmlEntityUTF8 } from './html-entities.js';
|
|
17
16
|
import { checkAttrs } from './htmltable-attr-warn.js';
|
|
18
17
|
import type { AttrEntry } from './htmltable-attr-warn.js';
|
|
19
|
-
import { ExpatFeed,
|
|
18
|
+
import { ExpatFeed, findUndefinedEntity, parseTagChunk } 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';
|
|
22
23
|
import type { Unit } from './htmltable-lex-units.js';
|
|
@@ -93,33 +94,25 @@ function parseCloseToken(inner: string) {
|
|
|
93
94
|
return { type: 'close' as const, tag: normalizeTag(tag) };
|
|
94
95
|
}
|
|
95
96
|
|
|
96
|
-
/** @see lib/common/htmllex.c:
|
|
97
|
-
|
|
98
|
-
const isKeep = (c: string) => c === ' ' || c === '\t' || c === '\n' || c === '\r';
|
|
99
|
-
return raw.replace(/[\x00-\x1f\x7f]/g, (c) => isKeep(c) ? c : '');
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/** The text token of a text run, if anything is left after cleaning. */
|
|
103
|
-
function textTokens(raw: string): Token[] {
|
|
104
|
-
// Decode XML/HTML entities in the text run. C's HTML lexer resolves entities
|
|
105
|
-
// via expat during tokenization (htmllex.c), so both sizing and emit see the
|
|
106
|
-
// decoded UTF-8 text. Skipping this measured the raw "[el..." (9-10 chars)
|
|
107
|
-
// instead of "[el..." (6 chars), inflating edge-label vnodes and shifting
|
|
108
|
-
// layout (corpus 1949: +18.7px). Plain-string labels already decode via the
|
|
109
|
-
// make_label path, so only HTML-delimited labels diverged.
|
|
110
|
-
const clean = htmlEntityUTF8(cleanText(raw));
|
|
111
|
-
return clean.length > 0 ? [{ type: 'text', value: clean }] : [];
|
|
112
|
-
}
|
|
97
|
+
/** Elements whose `<X/>` endElement folds into one token. @see lib/common/htmllex.c:endElement */
|
|
98
|
+
const EMPTY_ELEMENTS: ReadonlySet<string> = new Set(['BR', 'HR', 'VR', 'IMG']);
|
|
113
99
|
|
|
114
100
|
/** The tokens of one complete `<..>` tag. */
|
|
115
101
|
function tagTokens(text: string): Token[] {
|
|
116
102
|
const inner = text.slice(1, -1).trim();
|
|
117
103
|
if (inner.startsWith('/')) return [parseCloseToken(inner)];
|
|
118
|
-
// Self-closing <X/>: expat reports startElement then endElement
|
|
119
|
-
//
|
|
104
|
+
// Self-closing <X/>: expat reports startElement then endElement in one call,
|
|
105
|
+
// and endElement's `ctx->tok` overwrites startElement's. Only BR, HR, VR and
|
|
106
|
+
// IMG turn the pair into one token (T_BR etc.); any other element reaches the
|
|
107
|
+
// parser as its close tag alone, with what startElement printed.
|
|
108
|
+
// @see lib/common/htmllex.c:endElement
|
|
120
109
|
if (inner.endsWith('/')) {
|
|
121
110
|
const open = parseOpenToken(inner.slice(0, -1).trim());
|
|
122
|
-
return [open, { type: 'close', tag: open.tag }];
|
|
111
|
+
if (EMPTY_ELEMENTS.has(open.tag)) return [open, { type: 'close', tag: open.tag }];
|
|
112
|
+
const close: Token = { type: 'close', tag: open.tag };
|
|
113
|
+
if (open.warnings !== undefined) close.warnings = open.warnings;
|
|
114
|
+
if (open.warn === true) close.warn = true;
|
|
115
|
+
return [close];
|
|
123
116
|
}
|
|
124
117
|
return [parseOpenToken(inner)];
|
|
125
118
|
}
|
|
@@ -274,6 +267,8 @@ function eventTokens(site: TokenSite): Token[] {
|
|
|
274
267
|
const { src, tok, st } = site;
|
|
275
268
|
const text = src.slice(tok.start, tok.end);
|
|
276
269
|
if (tok.kind === 'comment' || tok.kind === 'pi') return [];
|
|
270
|
+
// CDATA content is character data as is: no markup, no entity decoding.
|
|
271
|
+
if (tok.kind === 'cdata') return charDataTokens(tok.data ?? '');
|
|
277
272
|
if (tok.kind === 'tag') {
|
|
278
273
|
checkTagToken(site);
|
|
279
274
|
return tagTokens(text);
|
|
@@ -298,6 +293,8 @@ interface LexRun {
|
|
|
298
293
|
st: ChunkState;
|
|
299
294
|
expat: ExpatFeed;
|
|
300
295
|
carry: Carry;
|
|
296
|
+
/** `ctx->inCell` and `ctx->xb`. */
|
|
297
|
+
cd: CharDataState;
|
|
301
298
|
}
|
|
302
299
|
|
|
303
300
|
/** Move what `carry` holds onto `first`, the token the parser is about to get. */
|
|
@@ -326,7 +323,13 @@ interface EventGroup {
|
|
|
326
323
|
function eventGroups(run: LexRun, res: ExpatResult): EventGroup[] {
|
|
327
324
|
const groups: EventGroup[] = [];
|
|
328
325
|
for (const tok of res.tokens) {
|
|
329
|
-
|
|
326
|
+
let tokens = eventTokens({ src: run.src, tok, st: run.st });
|
|
327
|
+
if (tok.kind === 'tag') {
|
|
328
|
+
const tag = parseTagChunk(run.src.slice(tok.start, tok.end));
|
|
329
|
+
if (tag !== undefined) tagInCell(run.cd, tag);
|
|
330
|
+
} else if (!run.cd.inCell) {
|
|
331
|
+
tokens = []; // characterData appends only inside a cell
|
|
332
|
+
}
|
|
330
333
|
if (tokens.length > 0) groups.push({ tokens, tok });
|
|
331
334
|
}
|
|
332
335
|
if (res.invalid !== undefined) {
|
|
@@ -343,6 +346,7 @@ function eventGroups(run: LexRun, res: ExpatResult): EventGroup[] {
|
|
|
343
346
|
*/
|
|
344
347
|
function deliver(run: LexRun, last: EventGroup): void {
|
|
345
348
|
const first = last.tokens[0]!;
|
|
349
|
+
releaseXb(run.cd, first);
|
|
346
350
|
attachCarry(run.carry, first);
|
|
347
351
|
const info: ChunkInfo = { src: run.src, pos: last.tok.start, end: last.tok.end, context: run.st.prev + run.st.curr };
|
|
348
352
|
const prev = run.tokens.at(-1);
|
|
@@ -364,7 +368,10 @@ function deliver(run: LexRun, last: EventGroup): void {
|
|
|
364
368
|
function runEvents(run: LexRun, res: ExpatResult): void {
|
|
365
369
|
const groups = eventGroups(run, res);
|
|
366
370
|
const last = groups.pop();
|
|
367
|
-
for (const g of groups)
|
|
371
|
+
for (const g of groups) {
|
|
372
|
+
absorb(run.carry, g.tokens);
|
|
373
|
+
keepLostText(run.cd, g.tokens);
|
|
374
|
+
}
|
|
368
375
|
if (last !== undefined) deliver(run, last);
|
|
369
376
|
}
|
|
370
377
|
|
|
@@ -439,7 +446,7 @@ const eofOutput = (carry: Carry): LexerOutput | undefined => {
|
|
|
439
446
|
export function tokenizeDeferred(src: string, label: LabelRange = { start: 0, end: src.length }): TokenizeResult {
|
|
440
447
|
const run: LexRun = {
|
|
441
448
|
src, tokens: [], st: { stack: [], prev: '', curr: '' }, expat: new ExpatFeed(src),
|
|
442
|
-
carry: { warnings: [], warn: false },
|
|
449
|
+
carry: { warnings: [], warn: false }, cd: initialCharData(),
|
|
443
450
|
};
|
|
444
451
|
let error: HtmlParseError | undefined;
|
|
445
452
|
try {
|
|
@@ -448,8 +455,7 @@ export function tokenizeDeferred(src: string, label: LabelRange = { start: 0, en
|
|
|
448
455
|
run.st.curr = src.slice(u.start, u.end);
|
|
449
456
|
run.carry.warnings.push(...u.warnings);
|
|
450
457
|
if (u.warn) run.carry.warn = true;
|
|
451
|
-
|
|
452
|
-
runEvents(run, run.expat.feed(u.start, u.end, nbytes));
|
|
458
|
+
runEvents(run, run.expat.feed(u.start, u.end, !run.st.curr.startsWith('<')));
|
|
453
459
|
}
|
|
454
460
|
const held = run.expat.held;
|
|
455
461
|
if (held !== undefined) error = unfinishedError(run, held);
|
|
@@ -44,12 +44,11 @@ const MSG_TEXT_BEFORE_TABLE = 'Syntax error: non-space string used before <TABLE
|
|
|
44
44
|
/** @see lib/common/htmlparse.y:298 (table rule) */
|
|
45
45
|
const MSG_TEXT_AFTER_TABLE = 'Syntax error: non-space string used after </TABLE>';
|
|
46
46
|
|
|
47
|
-
/**
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const nonSpace = (text: string): boolean => /[^ \t\n\r]/.test(text);
|
|
47
|
+
/** Any character other than a space (the lexer already dropped control characters). @see lib/common/htmlparse.y:nonSpace */
|
|
48
|
+
const nonSpace = (text: string): boolean => /[^ ]/.test(text);
|
|
49
|
+
|
|
50
|
+
/** Text in `ctx->xb` that no token carried, which the table rule's nonSpace also reads. */
|
|
51
|
+
const heldNonSpace = (tbl: Token | undefined): boolean => tbl?.type === 'open' && nonSpace(tbl.heldText ?? '');
|
|
53
52
|
|
|
54
53
|
/**
|
|
55
54
|
* The parse failure C reports through `htmlerror` when the lexer last returned
|
|
@@ -93,42 +92,40 @@ const applyBrTag = (
|
|
|
93
92
|
consumeClose(s, 'BR');
|
|
94
93
|
};
|
|
95
94
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
fontFace: t.attrs['face'],
|
|
104
|
-
fontSize: num(t.attrs['point-size']),
|
|
105
|
-
}));
|
|
95
|
+
/**
|
|
96
|
+
* `font text n_font` and the other font-style rules: `text` is one or more
|
|
97
|
+
* text items, and the matching close tag must follow it. Anything else, an
|
|
98
|
+
* empty element included, is a syntax error at the token found instead.
|
|
99
|
+
* @see lib/common/htmlparse.y:textitem
|
|
100
|
+
*/
|
|
101
|
+
const parseStyledText = (s: ParseState, tag: string, fontStack: FontState[], items: HtmlTextItem[]): void => {
|
|
106
102
|
const inner = parseText(s, fontStack);
|
|
107
103
|
fontStack.pop();
|
|
108
|
-
items.push(...inner.items);
|
|
109
104
|
const nxt = peek(s);
|
|
110
|
-
if (nxt?.type
|
|
105
|
+
if (inner.items.length === 0 || nxt?.type !== 'close' || nxt.tag !== tag) {
|
|
106
|
+
throw parseError(s, nxt, MSG_SYNTAX_ERROR);
|
|
107
|
+
}
|
|
108
|
+
items.push(...inner.items);
|
|
109
|
+
consume(s);
|
|
111
110
|
};
|
|
112
111
|
|
|
113
|
-
|
|
112
|
+
/** `font text n_font` / `bold text n_bold` / ...: push the tag's font, parse its text, pop. */
|
|
113
|
+
const applyStyleTag = (
|
|
114
114
|
s: ParseState, t: OpenToken, fontStack: FontState[], items: HtmlTextItem[],
|
|
115
115
|
): void => {
|
|
116
116
|
consume(s);
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
fontStack.
|
|
121
|
-
|
|
122
|
-
const nxt = peek(s);
|
|
123
|
-
if (nxt?.type === 'close' && nxt.tag === t.tag) consume(s);
|
|
117
|
+
const own = t.tag === 'FONT'
|
|
118
|
+
? { fontColor: t.attrs['color'], fontFace: t.attrs['face'], fontSize: num(t.attrs['point-size']) }
|
|
119
|
+
: inlineFontState(t.tag, t.attrs);
|
|
120
|
+
fontStack.push(mergeFontState(fontStack[fontStack.length - 1] ?? {}, own));
|
|
121
|
+
parseStyledText(s, t.tag, fontStack, items);
|
|
124
122
|
};
|
|
125
123
|
|
|
126
124
|
const applyOpenTag = (
|
|
127
125
|
s: ParseState, t: OpenToken, fontStack: FontState[], items: HtmlTextItem[],
|
|
128
126
|
): boolean => {
|
|
129
127
|
if (t.tag === 'BR') { applyBrTag(s, t, fontStack, items); return true; }
|
|
130
|
-
if (t.tag === 'FONT') {
|
|
131
|
-
if (isInlineTag(t.tag)) { applyInlineTag(s, t, fontStack, items); return true; }
|
|
128
|
+
if (t.tag === 'FONT' || isInlineTag(t.tag)) { applyStyleTag(s, t, fontStack, items); return true; }
|
|
132
129
|
return false;
|
|
133
130
|
};
|
|
134
131
|
|
|
@@ -194,7 +191,7 @@ const parseFontTable = (s: ParseState, open: OpenToken, fontStack: FontState[]):
|
|
|
194
191
|
fontStack.push(wrapperFont(open, fontStack));
|
|
195
192
|
const textBefore = skipStrings(s);
|
|
196
193
|
const tblTok = peek(s);
|
|
197
|
-
if (textBefore && tblTok !== undefined) throw parseError(s, tblTok, MSG_TEXT_BEFORE_TABLE);
|
|
194
|
+
if ((textBefore || heldNonSpace(tblTok)) && tblTok !== undefined) throw parseError(s, tblTok, MSG_TEXT_BEFORE_TABLE);
|
|
198
195
|
const table = parseTable(s, fontStack);
|
|
199
196
|
const textAfter = skipStrings(s);
|
|
200
197
|
const close = peek(s);
|
|
@@ -249,7 +246,7 @@ const scanCellTable = (
|
|
|
249
246
|
s: ParseState, t: Token, fontStack: FontState[], st: CellScan,
|
|
250
247
|
): HtmlCellContent[] => {
|
|
251
248
|
if (st.kind === 'image' || st.kind === 'table') throw parseError(s, t, MSG_SYNTAX_ERROR);
|
|
252
|
-
if (st.textNonSpace) throw parseError(s, t, MSG_TEXT_BEFORE_TABLE);
|
|
249
|
+
if (st.textNonSpace || heldNonSpace(t)) throw parseError(s, t, MSG_TEXT_BEFORE_TABLE);
|
|
253
250
|
const tbl = parseTable(s, fontStack);
|
|
254
251
|
st.kind = 'table';
|
|
255
252
|
return [tbl];
|
|
@@ -479,7 +476,7 @@ const consumeRowHr = (s: ParseState, rows: HtmlRow[]): void => {
|
|
|
479
476
|
const findFirstRealToken = (tokens: Token[], start: number): Token | undefined => {
|
|
480
477
|
for (let i = start; i < tokens.length; i++) {
|
|
481
478
|
const tk = tokens[i];
|
|
482
|
-
if (tk.type !== 'text' || tk.value
|
|
479
|
+
if (tk.type !== 'text' || nonSpace(tk.value)) return tk;
|
|
483
480
|
}
|
|
484
481
|
return undefined;
|
|
485
482
|
};
|
|
@@ -487,7 +484,7 @@ const findFirstRealToken = (tokens: Token[], start: number): Token | undefined =
|
|
|
487
484
|
const skipLeadingWs = (s: ParseState): void => {
|
|
488
485
|
while (true) {
|
|
489
486
|
const nxt = peek(s);
|
|
490
|
-
if (!nxt || nxt.type !== 'text' || nxt.value
|
|
487
|
+
if (!nxt || nxt.type !== 'text' || nonSpace(nxt.value)) break;
|
|
491
488
|
consume(s);
|
|
492
489
|
}
|
|
493
490
|
};
|
|
@@ -518,6 +515,7 @@ const parseFontTableLabel = (s: ParseState, open: OpenToken, fontStack: FontStat
|
|
|
518
515
|
|
|
519
516
|
const parseTableLabel = (s: ParseState, fontStack: FontState[]): HtmlLabel => {
|
|
520
517
|
skipLeadingWs(s);
|
|
518
|
+
if (heldNonSpace(peek(s))) throw parseError(s, peek(s), MSG_TEXT_BEFORE_TABLE);
|
|
521
519
|
const table = parseTable(s, fontStack);
|
|
522
520
|
finishTableLabel(s);
|
|
523
521
|
return { kind: 'table', table };
|
|
@@ -275,6 +275,8 @@ export interface OpenToken extends LexerOutput {
|
|
|
275
275
|
type: 'open';
|
|
276
276
|
tag: string;
|
|
277
277
|
attrs: Record<string, string>;
|
|
278
|
+
/** On a TABLE: `ctx->xb` text no token carried, read by the table rule's nonSpace. */
|
|
279
|
+
heldText?: string;
|
|
278
280
|
}
|
|
279
281
|
|
|
280
282
|
/** @see lib/common/htmllex.c:endElement */
|