@knowvah/dot-engine 2.6.5 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@knowvah/dot-engine",
3
- "version": "2.6.5",
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 "&#91;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
- * The number of bytes C passes to `XML_Parse` for a text unit: the buffer
73
- * `findNext` filled (named entities rewritten as `&#N;`, see `scanEntity`)
74
- * with a trailing `]` escaped (`protect_rsqb`). Other units go as written.
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
- export function fedBytes(unit: string, isText: boolean): number {
78
- if (!isText) return byteLength(unit);
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
- if (text.endsWith(']')) text = `${text.slice(0, -1)}&#93;`;
83
- return byteLength(text);
86
+ return endsUnit && text.endsWith(']') ? `${text.slice(0, -1)}&#93;` : 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
- const e = text.indexOf('<', p);
231
- const end = e < 0 ? text.length : e;
232
- tokens.push({ kind: 'text', start: base + p, end: base + end });
233
- p = end;
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, nbytes: number): ExpatResult {
310
- this.buffer.append(nbytes);
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, fedBytes, findUndefinedEntity, parseTagChunk } from './htmltable-lex-expat.js';
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:characterData */
97
- function cleanText(raw: string) {
98
- const isKeep = (c: string) => c === ' ' || c === '\t' || c === '\n' || c === '\r';
99
- return raw.replace(/[\x00-\x1f\x7f]/g, (c) => isKeep(c) ? c : '');
100
- }
101
-
102
- /** The text token of a text run, if anything is left after cleaning. */
103
- function textTokens(raw: string): Token[] {
104
- // Decode XML/HTML entities in the text run. C's HTML lexer resolves entities
105
- // via expat during tokenization (htmllex.c), so both sizing and emit see the
106
- // decoded UTF-8 text. Skipping this measured the raw "&#91;el..." (9-10 chars)
107
- // instead of "[el..." (6 chars), inflating edge-label vnodes and shifting
108
- // layout (corpus 1949: +18.7px). Plain-string labels already decode via the
109
- // make_label path, so only HTML-delimited labels diverged.
110
- const clean = htmlEntityUTF8(cleanText(raw));
111
- return clean.length > 0 ? [{ type: 'text', value: clean }] : [];
112
- }
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
- // @see lib/common/htmllex.c (XML_SetElementHandler callbacks)
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
- const tokens = eventTokens({ src: run.src, tok, st: run.st });
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) absorb(run.carry, g.tokens);
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
- const nbytes = fedBytes(run.st.curr, !run.st.curr.startsWith('<'));
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
- * C's `nonSpace` on the lexer's text, which has already dropped control
49
- * characters; the port's tokens still carry tab, LF and CR.
50
- * @see lib/common/htmlparse.y:nonSpace
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
- const applyFontTag = (
97
- s: ParseState, t: OpenToken, fontStack: FontState[], items: HtmlTextItem[],
98
- ): void => {
99
- consume(s);
100
- const font = fontStack[fontStack.length - 1] ?? {};
101
- fontStack.push(mergeFontState(font, {
102
- fontColor: t.attrs['color'],
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 === 'close' && nxt.tag === 'FONT') consume(s);
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
- const applyInlineTag = (
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 font = fontStack[fontStack.length - 1] ?? {};
118
- fontStack.push(mergeFontState(font, inlineFontState(t.tag, t.attrs)));
119
- const inner = parseText(s, fontStack);
120
- fontStack.pop();
121
- items.push(...inner.items);
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') { applyFontTag(s, t, fontStack, items); return true; }
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.trim() !== '') return tk;
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.trim() !== '') break;
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 */
package/src/parser/ast.ts CHANGED
@@ -61,4 +61,6 @@ export interface ParsedGraph {
61
61
  readonly directed: boolean;
62
62
  readonly id: string | null;
63
63
  readonly stmts: ParsedStmt[];
64
+ /** Offset just past the closing brace; the parser ignores input after it. */
65
+ readonly end: number;
64
66
  }